-
-
Notifications
You must be signed in to change notification settings - Fork 765
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
Changes from all commits
ff19497
24ec0c8
f7f3623
9bd0dc9
31ab641
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
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. should this be an error? |
||
| } | ||
|
|
||
| const blockGroupPos = posBeforeNode + 1 + node.firstChild!.nodeSize; | ||
|
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. for safety and readability, can't we use |
||
|
|
||
| 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. | ||
|
|
||
| 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/); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would "as-first-child" or "as-last-child" be clearer than "start" / "end"?