-
-
Notifications
You must be signed in to change notification settings - Fork 767
feat(core): container block API for nested blocks #2997
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
ff19497
feat(core): add container block API for nested blocks
nperez0111 24ec0c8
fix(core): handle content-bearing containers in navigation and slicing
nperez0111 f7f3623
fix(core): address container-block review findings
nperez0111 9bd0dc9
refactor(core): dedupe container helpers for net-smaller diff
nperez0111 31ab641
fix(core): resolve an element root for fragment-rendered containers
nperez0111 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { Fragment, Slice } from "prosemirror-model"; | ||
| import { Fragment, Node, NodeType, Slice } from "prosemirror-model"; | ||
| import type { Transaction } from "prosemirror-state"; | ||
| import { ReplaceStep } from "prosemirror-transform"; | ||
| import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; | ||
|
|
@@ -8,10 +8,93 @@ import { | |
| InlineContentSchema, | ||
| StyleSchema, | ||
| } from "../../../../schema/index.js"; | ||
| import { isContainerBlockNode } from "../../../../schema/blocks/children.js"; | ||
| import { blockToNode } from "../../../nodeConversions/blockToNode.js"; | ||
| import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; | ||
| import { getNodeById } from "../../../nodeUtil.js"; | ||
| import { getPmSchema } from "../../../pmUtil.js"; | ||
| import { | ||
| descendToFirstInsertionPos, | ||
| descendToLastInsertionPos, | ||
| } from "../../containers/containerNav.js"; | ||
|
|
||
| /** | ||
| * Where blocks go relative to a reference block. `"before"`/`"after"` make them | ||
| * siblings of it; `"start"`/`"end"` nest them inside it, as its first or last | ||
| * children. | ||
| * | ||
| * The nested placements cover containers that have no children to point at: | ||
| * a `min: 0` container that is currently empty has no child block to insert | ||
| * before or after. | ||
| */ | ||
| export type BlockPlacement = "before" | "after" | "start" | "end"; | ||
|
|
||
| /** | ||
| * Resolves a `placement` against a reference block into the document position | ||
| * a node of `nodeType` should be inserted at, or `null` when the reference | ||
| * block cannot take it there. | ||
| * | ||
| * Shared by `insertBlocks` and the move commands, so "does this block fit | ||
| * here?" is answered in one place. The answer comes from the schema's content | ||
| * matches rather than from a hand-written rule, so a container's `children` | ||
| * config decides it. | ||
| * | ||
| * `wrapIn` is set when the position only becomes valid once the nodes are | ||
| * wrapped: a regular block with no children yet has no `blockGroup` for them | ||
| * to go in, so one is created around them. | ||
| */ | ||
| export function getInsertionPos( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (nice to have) this function is exported in @blocknote/core now, which afaik doesn't need to be. Maybe a good opportunity to introduce the "index"-file-per-folder pattern of exporting? |
||
| doc: Node, | ||
| reference: { node: Node; posBeforeNode: number }, | ||
| placement: BlockPlacement, | ||
| nodeType: NodeType, | ||
| ): { pos: number; wrapIn?: NodeType } | null { | ||
| const { node, posBeforeNode } = reference; | ||
|
|
||
| const descend = (holder: Node, pos: number) => | ||
| placement === "start" | ||
| ? descendToFirstInsertionPos(holder, pos, nodeType) | ||
| : descendToLastInsertionPos(holder, pos, nodeType); | ||
|
|
||
| if (placement === "before" || placement === "after") { | ||
| const pos = | ||
| placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize; | ||
| const $pos = doc.resolve(pos); | ||
|
|
||
| return $pos.parent.contentMatchAt($pos.index()).matchType(nodeType) | ||
| ? { pos } | ||
| : null; | ||
| } | ||
|
|
||
| // A container holds its children itself, or, when it has content of its | ||
| // own, in its generated `__children` node, which the descent helpers step | ||
| // into. The helpers ignore sealed boundaries by default, which is correct | ||
| // here: an explicit `insertBlocks` placement is an intentional crossing. | ||
| if (isContainerBlockNode(node)) { | ||
| const pos = descend(node, posBeforeNode); | ||
|
|
||
| return pos === null ? null : { pos }; | ||
| } | ||
|
|
||
| // A regular block keeps its children in a `blockGroup` that only exists once | ||
| // it has some. | ||
| const blockGroupType = nodeType.schema.nodes["blockGroup"]; | ||
| if (node.type.name !== "blockContainer" || !blockGroupType) { | ||
| return null; | ||
|
nperez0111 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| const blockGroupPos = posBeforeNode + 1 + node.firstChild!.nodeSize; | ||
|
nperez0111 marked this conversation as resolved.
|
||
|
|
||
| if (node.childCount < 2) { | ||
| return blockGroupType.contentMatch.matchType(nodeType) | ||
| ? { pos: blockGroupPos, wrapIn: blockGroupType } | ||
| : null; | ||
| } | ||
|
|
||
| const pos = descend(node.lastChild!, blockGroupPos); | ||
|
|
||
| return pos === null ? null : { pos }; | ||
| } | ||
|
|
||
| export function insertBlocks< | ||
| BSchema extends BlockSchema, | ||
|
|
@@ -21,7 +104,7 @@ export function insertBlocks< | |
| tr: Transaction, | ||
| blocksToInsert: PartialBlock<BSchema, I, S>[], | ||
| referenceBlock: BlockIdentifier, | ||
| placement: "before" | "after" = "before", | ||
| placement: BlockPlacement = "before", | ||
| ): Block<BSchema, I, S>[] { | ||
| const id = | ||
| typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; | ||
|
|
@@ -37,14 +120,30 @@ export function insertBlocks< | |
| throw new Error(`Block with ID ${id} not found`); | ||
| } | ||
|
|
||
| let pos = posInfo.posBeforeNode; | ||
| if (placement === "after") { | ||
| pos += posInfo.node.nodeSize; | ||
| if (nodesToInsert.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| tr.step( | ||
| new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)), | ||
| const target = getInsertionPos( | ||
| tr.doc, | ||
| posInfo, | ||
| placement, | ||
| nodesToInsert[0].type, | ||
| ); | ||
| if (!target) { | ||
| throw new Error( | ||
| `Cannot insert a block of type "${blocksToInsert[0].type ?? "paragraph"}" ` + | ||
| (placement === "before" || placement === "after" | ||
| ? `${placement} block with ID ${id}: its parent does not accept it.` | ||
| : `at the ${placement} of block with ID ${id}: the block does not accept it as a child.`), | ||
| ); | ||
| } | ||
|
|
||
| const fragment = target.wrapIn | ||
| ? Fragment.from(target.wrapIn.create(null, nodesToInsert)) | ||
| : Fragment.from(nodesToInsert); | ||
|
|
||
| tr.step(new ReplaceStep(target.pos, target.pos, new Slice(fragment, 0, 0))); | ||
|
|
||
| // Now that the `PartialBlock`s have been converted to nodes, we can | ||
| // re-convert them into full `Block`s. | ||
|
|
||
206 changes: 206 additions & 0 deletions
206
packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| // @vitest-environment node | ||
| import { | ||
| afterAll, | ||
| beforeAll, | ||
| beforeEach, | ||
| describe, | ||
| expect, | ||
| it, | ||
| } from "vite-plus/test"; | ||
|
|
||
| import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js"; | ||
| import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js"; | ||
| import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; | ||
| import { createBlockSpec } from "../../../../schema/blocks/createSpec.js"; | ||
|
|
||
| // The editor stays headless, so these blocks are never rendered. `render` | ||
| // only has to exist for `createBlockSpec` to accept the spec. | ||
| const container = (type: string, config: Record<string, unknown>) => | ||
| createBlockSpec({ type, propSchema: {}, ...config } as any, { | ||
| render: () => { | ||
| throw new Error("not rendered in this suite"); | ||
| }, | ||
| })(); | ||
|
|
||
| const schema = BlockNoteSchema.create().extend({ | ||
| blockSpecs: { | ||
| ...defaultBlockSpecs, | ||
| // Why `"start"`/`"end"` exist: a container that may legally hold nothing | ||
| // has no child block to address, so `"before"`/`"after"` cannot reach | ||
| // inside it. | ||
| box: container("box", { | ||
| content: "none", | ||
| children: { allow: "any", min: 0 }, | ||
| }), | ||
| titledBox: container("titledBox", { | ||
| content: "inline", | ||
| children: { allow: "any", min: 0 }, | ||
| }), | ||
| // A container that only accepts other containers, so an insertion has to | ||
| // descend a level to find a place for a regular block. | ||
| grid: container("grid", { | ||
| content: "none", | ||
| children: { allow: ["cell"], min: 2 }, | ||
| }), | ||
| cell: container("cell", { | ||
| content: "none", | ||
| children: { allow: "any" }, | ||
| placement: "containerOnly", | ||
| }), | ||
| // A container that is full once it has one child. | ||
| single: container("single", { | ||
| content: "none", | ||
| children: { allow: "any", max: 1 }, | ||
| }), | ||
| } as const, | ||
| }); | ||
|
|
||
| let editor: BlockNoteEditor<any, any, any>; | ||
|
|
||
| beforeAll(() => { | ||
| editor = BlockNoteEditor.create({ schema }) as any; | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| editor._tiptapEditor.destroy(); | ||
| editor = undefined as any; | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { id: "p-0", type: "paragraph", content: "Paragraph 0" }, | ||
| ]); | ||
| }); | ||
|
|
||
| describe('insertBlocks "start" / "end"', () => { | ||
| it("inserts into a childless container", () => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { id: "b-0", type: "box" }, | ||
| { id: "trailing", type: "paragraph", content: "" }, | ||
| ]); | ||
| expect(editor.getBlock("b-0")!.children).toHaveLength(0); | ||
|
|
||
| editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start"); | ||
| editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end"); | ||
|
|
||
| expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ | ||
| "first", | ||
| "last", | ||
| ]); | ||
| }); | ||
|
|
||
| it("prepends and appends around existing children", () => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { | ||
| id: "b-0", | ||
| type: "box", | ||
| children: [{ id: "existing", type: "paragraph", content: "Existing" }], | ||
| }, | ||
| { id: "trailing", type: "paragraph", content: "" }, | ||
| ]); | ||
|
|
||
| editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start"); | ||
| editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end"); | ||
|
|
||
| expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ | ||
| "first", | ||
| "existing", | ||
| "last", | ||
| ]); | ||
| }); | ||
|
|
||
| it("inserts into a childless container that has its own content", () => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { id: "t-0", type: "titledBox", content: "Title" }, | ||
| { id: "trailing", type: "paragraph", content: "" }, | ||
| ]); | ||
| expect(editor.getBlock("t-0")!.children).toHaveLength(0); | ||
|
|
||
| editor.insertBlocks([{ id: "first", type: "paragraph" }], "t-0", "start"); | ||
| editor.insertBlocks([{ id: "last", type: "paragraph" }], "t-0", "end"); | ||
|
|
||
| const toggle = editor.getBlock("t-0")!; | ||
| // The title is content, not a child. A nested insertion must not land | ||
| // in it, or before it. | ||
| expect(toggle.content).toEqual([ | ||
| { type: "text", text: "Title", styles: {} }, | ||
| ]); | ||
| expect(toggle.children.map((child) => child.id)).toEqual(["first", "last"]); | ||
| }); | ||
|
|
||
| it("descends into a nested container that accepts the block", () => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { | ||
| id: "g-0", | ||
| type: "grid", | ||
| children: [ | ||
| { id: "c-0", type: "cell" }, | ||
| { id: "c-1", type: "cell" }, | ||
| ], | ||
| }, | ||
| { id: "trailing", type: "paragraph", content: "" }, | ||
| ]); | ||
|
|
||
| // `grid` itself only accepts `cell`s, so both placements have to find the | ||
| // leading/trailing cell rather than giving up. | ||
| editor.insertBlocks([{ id: "first", type: "paragraph" }], "g-0", "start"); | ||
| editor.insertBlocks([{ id: "last", type: "paragraph" }], "g-0", "end"); | ||
|
|
||
| const grid = editor.getBlock("g-0")!; | ||
| expect(grid.children[0].children.map((child: any) => child.id)).toContain( | ||
| "first", | ||
| ); | ||
| expect(grid.children[1].children.map((child: any) => child.id)).toContain( | ||
| "last", | ||
| ); | ||
| }); | ||
|
|
||
| it("nests under a regular block, with or without existing children", () => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { id: "p-0", type: "paragraph", content: "Paragraph 0" }, | ||
| ]); | ||
|
|
||
| editor.insertBlocks([{ id: "existing", type: "paragraph" }], "p-0", "end"); | ||
| editor.insertBlocks([{ id: "first", type: "paragraph" }], "p-0", "start"); | ||
|
|
||
| expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ | ||
| "first", | ||
| "existing", | ||
| ]); | ||
| }); | ||
|
|
||
| it("throws when the container has no room for the block", () => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { | ||
| id: "s-0", | ||
| type: "single", | ||
| children: [{ id: "only", type: "paragraph" }], | ||
| }, | ||
| { id: "trailing", type: "paragraph", content: "" }, | ||
| ]); | ||
|
|
||
| expect(() => | ||
| editor.insertBlocks([{ type: "paragraph" }], "s-0", "end"), | ||
| ).toThrow(/does not accept it as a child/); | ||
| }); | ||
|
|
||
| it("throws when a sibling placement isn't allowed either", () => { | ||
| editor.replaceBlocks(editor.document, [ | ||
| { | ||
| id: "g-0", | ||
| type: "grid", | ||
| children: [ | ||
| { id: "c-0", type: "cell" }, | ||
| { id: "c-1", type: "cell" }, | ||
| ], | ||
| }, | ||
| { id: "trailing", type: "paragraph", content: "" }, | ||
| ]); | ||
|
|
||
| // `grid`'s children are `cell`s only, so a paragraph can't become one's | ||
| // sibling. Previously this threw a raw ProseMirror `ReplaceError`. | ||
| expect(() => | ||
| editor.insertBlocks([{ type: "paragraph" }], "c-0", "after"), | ||
| ).toThrow(/its parent does not accept it/); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.