From d7d75818aa7f3d7317e92d1b93a0ce5c6bf67694 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 21 Aug 2026 21:11:23 +0200 Subject: [PATCH] feat(core): add container block API for nested blocks --- packages/core/package.json | 5 + .../commands/insertBlocks/insertBlocks.ts | 114 ++- .../insertBlocks/insertPlacement.test.ts | 206 +++++ .../commands/mergeBlocks/mergeBlocks.ts | 99 ++- .../commands/moveBlocks/moveBlocks.test.ts | 2 +- .../commands/moveBlocks/moveBlocks.ts | 60 +- .../commands/nestBlock/nestBlock.ts | 36 +- .../commands/replaceBlocks/replaceBlocks.ts | 29 +- .../commands/splitBlock/splitBlock.test.ts | 2 +- .../commands/splitBlock/splitBlock.ts | 2 +- .../commands/updateBlock/updateBlock.test.ts | 12 +- .../commands/updateBlock/updateBlock.ts | 132 +++- .../containers/containerNav.ts | 147 ++++ .../containers/containerUI.ts | 68 ++ .../containers/containers.browser.test.ts | 480 ++++++++++++ .../containers/containers.fixture.ts | 119 +++ .../containers/containers.test.ts | 349 +++++++++ .../contentContainers.browser.test.ts | 358 +++++++++ .../containers/contentContainers.fixture.ts | 96 +++ .../containers/contentContainers.test.ts | 308 ++++++++ .../containers/fixContainer.ts | 347 +++++++++ .../blockManipulation/selections/selection.ts | 7 +- .../selections/textCursorPosition.ts | 13 +- .../html/util/serializeBlocksExternalHTML.ts | 41 +- .../html/util/serializeBlocksInternalHTML.ts | 94 ++- packages/core/src/api/getBlockInfoFromPos.ts | 81 +- .../api/getBlocksChangedByTransaction.test.ts | 4 +- .../src/api/nodeConversions/blockToNode.ts | 262 ++++++- .../nodeConversions/contentContainers.test.ts | 307 ++++++++ .../api/nodeConversions/fragmentToBlocks.ts | 125 ++- .../src/api/nodeConversions/nodeToBlock.ts | 99 ++- packages/core/src/api/pmUtil.ts | 12 +- .../ListItem/ListItemKeyboardShortcuts.ts | 2 +- .../NumberedListItem/IndexingPlugin.ts | 4 +- .../src/blocks/utils/listItemEnterHandler.ts | 2 +- packages/core/src/editor/BlockNoteEditor.ts | 18 +- .../core/src/editor/managers/BlockManager.ts | 11 +- .../managers/ExtensionManager/extensions.ts | 27 +- .../editor/managers/ExtensionManager/index.ts | 2 +- packages/core/src/editor/transformPasted.ts | 14 +- packages/core/src/exporter/Exporter.ts | 35 +- .../core/src/extensions/SideMenu/SideMenu.ts | 98 ++- .../sideMenuContainerGeometry.browser.test.ts | 285 +++++++ .../SideMenu/sideMenuContainerGeometry.ts | 107 +++ ...tDraggableBlockFromElement.browser.test.ts | 111 +++ .../getDraggableBlockFromElement.ts | 47 +- .../KeyboardShortcutsExtension.ts | 569 +++++++++++--- .../tiptap-extensions/UniqueID/UniqueID.ts | 9 +- packages/core/src/fonts/inter.css | 18 +- packages/core/src/index.ts | 8 + packages/core/src/internal.ts | 74 ++ .../schema/blocks/assertSchemaInvariants.ts | 100 +++ .../core/src/schema/blocks/children.test.ts | 291 +++++++ packages/core/src/schema/blocks/children.ts | 243 ++++++ .../src/schema/blocks/containerAttributes.ts | 75 ++ .../blocks/containerParse.browser.test.ts | 394 ++++++++++ packages/core/src/schema/blocks/createSpec.ts | 717 +++++++++++++++--- packages/core/src/schema/blocks/internal.ts | 32 +- packages/core/src/schema/blocks/types.ts | 192 ++++- .../src/schema/blocks/validateChildren.ts | 408 ++++++++++ packages/core/src/schema/index.ts | 7 + packages/core/src/schema/schema.ts | 24 + .../y/extensions/AttributionExtension.test.ts | 17 +- .../core/src/yjs/extensions/FixUpSchema.ts | 10 +- packages/core/vite.config.ts | 1 + packages/core/vitestSetup.ts | 15 +- .../src/components/Popovers/BlockPopover.tsx | 24 +- packages/react/src/editor/styles.css | 7 + .../ReactBlockSpec.container.browser.test.tsx | 199 +++++ packages/react/src/schema/ReactBlockSpec.tsx | 330 ++++++-- packages/react/src/schema/useNodeViewBlock.ts | 11 + packages/react/vite.config.ts | 5 +- packages/react/vitestSetup.ts | 63 +- .../formats/html-blocks/collabUpdate.test.ts | 2 +- packages/xl-ai/src/prosemirror/agent.test.ts | 10 +- .../xl-ai/src/prosemirror/rebaseTool.test.ts | 6 +- .../cases/combinedOperationsTestCases.ts | 2 +- .../cases/updateOperationTestCases.ts | 6 +- .../src/docx/docxExporter.test.ts | 77 ++ .../xl-docx-exporter/src/docx/docxExporter.ts | 4 +- .../src/react-email/reactEmailExporter.tsx | 18 + .../xl-odt-exporter/src/odt/odtExporter.tsx | 2 +- .../xl-pdf-exporter/src/pdf/pdfExporter.tsx | 2 +- .../fixtures/suggestionFixture.tsx | 4 +- .../src/unit/react/useNodeViewBlock.test.tsx | 49 +- 85 files changed, 8166 insertions(+), 638 deletions(-) create mode 100644 packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containerNav.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containerUI.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containers.browser.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containers.fixture.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containers.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts create mode 100644 packages/core/src/api/blockManipulation/containers/contentContainers.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/fixContainer.ts create mode 100644 packages/core/src/api/nodeConversions/contentContainers.test.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts create mode 100644 packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts create mode 100644 packages/core/src/internal.ts create mode 100644 packages/core/src/schema/blocks/assertSchemaInvariants.ts create mode 100644 packages/core/src/schema/blocks/children.test.ts create mode 100644 packages/core/src/schema/blocks/children.ts create mode 100644 packages/core/src/schema/blocks/containerAttributes.ts create mode 100644 packages/core/src/schema/blocks/containerParse.browser.test.ts create mode 100644 packages/core/src/schema/blocks/validateChildren.ts create mode 100644 packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx diff --git a/packages/core/package.json b/packages/core/package.json index eb2700636d..8b9f1ee69b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,6 +72,11 @@ "import": "./dist/extensions.js", "require": "./dist/extensions.cjs" }, + "./internal": { + "types": "./types/src/internal.d.ts", + "import": "./dist/internal.js", + "require": "./dist/internal.cjs" + }, "./yjs": { "types": "./types/src/yjs/index.d.ts", "import": "./dist/yjs.js", diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index b41b268617..182baedca3 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -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,94 @@ 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( + doc: Node, + reference: { node: Node; posBeforeNode: number }, + placement: BlockPlacement, + nodeType: NodeType, +): { pos: number; wrapIn?: NodeType } | null { + const { node, posBeforeNode } = reference; + + 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 = + placement === "start" + ? descendToFirstInsertionPos(node, posBeforeNode, nodeType) + : descendToLastInsertionPos(node, posBeforeNode, nodeType); + + 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; + } + + const blockGroupPos = posBeforeNode + 1 + node.firstChild!.nodeSize; + + if (node.childCount < 2) { + return blockGroupType.contentMatch.matchType(nodeType) + ? { pos: blockGroupPos, wrapIn: blockGroupType } + : null; + } + + const pos = + placement === "start" + ? descendToFirstInsertionPos(node.lastChild!, blockGroupPos, nodeType) + : descendToLastInsertionPos(node.lastChild!, blockGroupPos, nodeType); + + return pos === null ? null : { pos }; +} export function insertBlocks< BSchema extends BlockSchema, @@ -21,7 +105,7 @@ export function insertBlocks< tr: Transaction, blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ): Block[] { const id = typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; @@ -37,14 +121,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. diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts new file mode 100644 index 0000000000..665f6fec98 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -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) => + 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; + +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/); + }); +}); diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index ce1a9455db..e93dbcd0d8 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,6 +1,10 @@ import { Node } from "prosemirror-model"; -import { EditorState } from "prosemirror-state"; +import { EditorState, TextSelection } from "prosemirror-state"; +import { + isContentContainerNode, + isSealed, +} from "../../../../schema/blocks/children.js"; import { BlockInfo, getBlockInfoFromResolvedPos, @@ -90,8 +94,20 @@ export const getNextBlockInfo = (doc: Node, beforePos: number) => { * * Then the bottom nested block returned is D. */ -export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => { - while (blockInfo.childContainer) { +export const getBottomNestedBlockInfo = ( + doc: Node, + blockInfo: BlockInfo, + // Callers that move content stop the descent at a sealed container, getting + // the container itself rather than a block inside it. Caret-only callers + // descend through. Sealed boundaries govern content, not navigation. + opts?: { stopAtSealed?: boolean }, +) => { + // A container that allows zero children can have an empty child container, + // in which case the block itself is the bottom one. + while (blockInfo.childContainer && blockInfo.childContainer.node.childCount) { + if (opts?.stopAtSealed && isSealed(blockInfo.childContainer.node)) { + break; + } const group = blockInfo.childContainer.node; const newPos = doc @@ -105,11 +121,17 @@ export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => { const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => { return ( - prevBlockInfo.isBlockContainer && + prevBlockInfo.isWrappedBlock && prevBlockInfo.blockContent.node.type.spec.content === "inline*" && prevBlockInfo.blockContent.node.childCount > 0 && - nextBlockInfo.isBlockContainer && - nextBlockInfo.blockContent.node.type.spec.content === "inline*" + // A content-bearing container is `isWrappedBlock` with an `inline*` + // title, but stitching across its boundary would orphan its required + // `__children` node. `mergeIntoContainerContent` is the only supported + // merge involving one. + !isContentContainerNode(prevBlockInfo.bnBlock.node) && + nextBlockInfo.isWrappedBlock && + nextBlockInfo.blockContent.node.type.spec.content === "inline*" && + !isContentContainerNode(nextBlockInfo.bnBlock.node) ); }; @@ -120,7 +142,7 @@ const mergeBlocks = ( nextBlockInfo: BlockInfo, ) => { // Un-nests all children of the next block. - if (!nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo.isWrappedBlock) { throw new Error( `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but next block is not a block container`, ); @@ -147,13 +169,17 @@ const mergeBlocks = ( // removing the closing tags of the first block and the opening tags of the // second one to stitch them together. if (dispatch) { - if (!prevBlockInfo.isBlockContainer) { + if (!prevBlockInfo.isWrappedBlock) { throw new Error( `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but previous block is not a block container`, ); } - // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v. + // Merging into or out of container blocks (columnLists, callouts, ...) + // is intentionally unsupported; `canMerge` refuses it above. The + // container-boundary Backspace/Delete branches in + // `KeyboardShortcutsExtension` handle those cases by moving blocks + // across the boundary instead of merging their content. dispatch( state.tr.delete( prevBlockInfo.blockContent.afterPos - 1, @@ -165,6 +191,61 @@ const mergeBlocks = ( return true; }; +/** + * Merges a container's first child into the container's own content. This is + * the Backspace-at-the-start-of-the-first-child case for a container that has + * a title of its own. The child's own children stay in the container, taking + * its place. + * + * Deliberately separate from `canMerge`/`mergeBlocks`: a pure container has + * no content to merge into, so those keep refusing container boundaries + * outright and the "move the block out" branch still handles them. Returns + * false whenever either side isn't inline content, falling through to that + * branch. + */ +export const mergeIntoContainerContent = ( + state: EditorState, + dispatch: ((args?: any) => any) | undefined, + containerInfo: BlockInfo, + childInfo: BlockInfo, +) => { + if (!containerInfo.isWrappedBlock || !childInfo.isWrappedBlock) { + return false; + } + + const title = containerInfo.blockContent; + const childContent = childInfo.blockContent; + + if ( + title.node.type.spec.content !== "inline*" || + childContent.node.type.spec.content !== "inline*" + ) { + return false; + } + + if (dispatch) { + const tr = state.tr; + + // The title lies before the children, so none of these positions shift the + // ones used after them. + if (childInfo.childContainer?.node.childCount) { + tr.insert( + childInfo.bnBlock.afterPos, + childInfo.childContainer.node.content, + ); + } + tr.delete(childInfo.bnBlock.beforePos, childInfo.bnBlock.afterPos); + + const titleEndPos = title.afterPos - 1; + tr.insert(titleEndPos, childContent.node.content); + tr.setSelection(TextSelection.create(tr.doc, titleEndPos)); + + dispatch(tr); + } + + return true; +}; + export const mergeBlocksCommand = (posBetweenBlocks: number) => ({ diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts index 61964a49ee..f034506f44 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts @@ -18,7 +18,7 @@ const getEditor = setupTestEnv(); function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr)); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error( `Selection points to a ${blockInfo.blockNoteType} node, not a blockContainer node`, ); diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 71598b7d69..510dbfbcd1 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -14,7 +14,8 @@ import { getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; -import { insertBlocks } from "../insertBlocks/insertBlocks.js"; +import { flattenNonInsertableBlocks } from "../../containers/fixContainer.js"; +import { getInsertionPos, insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; type BlockSelectionData = ( @@ -131,16 +132,6 @@ function updateBlockSelectionFromData( tr.setSelection(selection); } -// Replaces top-level `column` blocks with their children, as a `column` is not -// a valid block outside a `columnList`. Other blocks are returned as-is. -function flattenColumns( - blocks: Block[], -): Block[] { - return blocks.flatMap((block) => - block.type === "column" ? block.children : [block], - ); -} - /** * Removes the given blocks from the editor, then inserts them before/after a * reference block. @@ -169,10 +160,12 @@ export function moveBlocks( // // When the non-empty block is moved up, the column is seen as empty and // collapsed in the removal step, so the following insertion fails. - removeAndInsertBlocks(tr, blocks, [], { fixColumns: false }); + removeAndInsertBlocks(tr, blocks, [], { fixContainers: false }); insertBlocks( tr, - flattenColumns(blocks), + // Blocks that can't stand on their own outside their container (e.g. a + // `column` outside its `columnList`) are replaced by their children. + flattenNonInsertableBlocks(blocks, editor.pmSchema), referenceBlock, placement, ); @@ -207,12 +200,33 @@ export function moveSelectedBlocksAndSelection( }); } -// Checks if a block is in a valid place after being moved. This check is -// primitive at the moment and only returns false if the block's parent is a -// `columnList` block. This is because regular blocks cannot be direct children -// of `columnList` blocks. -function checkPlacementIsValid(parentBlock?: Block): boolean { - return !parentBlock || parentBlock.type !== "columnList"; +// Checks if a regular block would be in a valid place after being moved +// before/after `referenceBlock`. A regular block nests under any non-container +// block (it goes into that block's `blockGroup`), but a container block (e.g. a +// `columnList`) only accepts what its content expression allows. +// +// Deferred to `getInsertionPos` so that "can a block go here?" has exactly +// one answer, shared with `insertBlocks`, and comes from the schema rather +// than from a rule restated here. +function checkPlacementIsValid( + editor: BlockNoteEditor, + referenceBlock: Block, + placement: "before" | "after", +): boolean { + return editor.transact((tr) => { + const posInfo = getNodeById(referenceBlock.id, tr.doc); + if (!posInfo) { + return false; + } + return ( + getInsertionPos( + tr.doc, + posInfo, + placement, + editor.pmSchema.nodes["blockContainer"], + ) !== null + ); + }); } // Gets the placement for moving a block up. This has 3 cases: @@ -253,8 +267,8 @@ function getMoveUpPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveUpPlacement( editor, placement === "after" @@ -305,8 +319,8 @@ function getMoveDownPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveDownPlacement( editor, placement === "before" diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index a0f76fdff0..243e4532dd 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -19,9 +19,7 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -163,9 +161,7 @@ export function liftItem( const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -195,14 +191,36 @@ export function canNestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); - return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null; + // Mirrors `sinkItem`'s precondition: nesting is only possible under a + // previous sibling that is itself a `blockContainer`. (A previous sibling + // of another type, e.g. a container block, made this return true while + // `nestBlock` did nothing.) + return ( + tr.doc.resolve(blockContainer.beforePos).nodeBefore?.type === + editor.pmSchema.nodes["blockContainer"] + ); }); } export function canUnnestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); + const { $from, $to } = tr.selection; + + // Mirrors `liftItem`'s preconditions instead of approximating with depth. + // A block whose depth > 1 because it sits inside a container (e.g. a + // column) is not un-nestable, only a block nested under another + // `blockContainer` is. + const range = $from.blockRange( + $to, + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), + ); + if (!range) { + return false; + } - return tr.doc.resolve(blockContainer.beforePos).depth > 1; + return ( + $from.node(range.depth - 1).type === + editor.pmSchema.nodes["blockContainer"] + ); }); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d9e1e72981..75305b501d 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -11,7 +11,8 @@ import type { import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getPmSchema } from "../../../pmUtil.js"; -import { fixColumnList } from "./util/fixColumnList.js"; +import { fixContainersById } from "../../containers/fixContainer.js"; +import { getAncestorContainers } from "../../containers/containerNav.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +23,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixContainers?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,7 +44,10 @@ export function removeAndInsertBlocks< ), ); const removedBlocks: Block[] = []; - const columnListPositions = new Set(); + // Ancestor containers of removed blocks, to repair afterwards. Tracked by + // node id (not position) since the removals and earlier repairs shift + // positions; recorded with their depth so repairs run deepest-first. + const containersToFix: { id: string; depth: number }[] = []; const idOfFirstBlock = typeof blocksToRemove[0] === "string" @@ -84,10 +88,10 @@ export function removeAndInsertBlocks< const $pos = tr.doc.resolve(pos - removedSize); - if ($pos.node().type.name === "column") { - columnListPositions.add($pos.before(-1)); - } else if ($pos.node().type.name === "columnList") { - columnListPositions.add($pos.before()); + for (const container of getAncestorContainers($pos.doc, $pos.pos)) { + if (!containersToFix.some((c) => c.id === container.id)) { + containersToFix.push(container); + } } if ( @@ -119,11 +123,12 @@ export function removeAndInsertBlocks< ); } - // Collapses empty columns/columnLists. Callers where the removal isn't a - // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere - // and deliberately leaves emptied columns as-is. - if (options.fixColumns !== false) { - columnListPositions.forEach((pos) => fixColumnList(tr, pos)); + // Repairs the containers the removed blocks lived in (e.g. collapses + // emptied columns/columnLists), deepest-first. Callers where the removal + // isn't a deletion can opt out, e.g. `moveBlocks` re-inserts the blocks + // elsewhere and deliberately leaves emptied containers as-is. + if (options.fixContainers !== false) { + fixContainersById(tr, containersToFix); } // Converts the nodes created from `blocksToInsert` into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts index ab02a865f0..9a83857cd1 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -35,7 +35,7 @@ function setSelectionWithOffset( const info = getBlockInfo(posInfo); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("Target block is not a block container"); } diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 1e73471d23..ef74f8e898 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -36,7 +36,7 @@ export const splitBlockTr = ( const info = getBlockInfo(nearestBlockContainerPos); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { return false; } const schema = getPmSchema(tr); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts index e44e4a6380..c695de98ae 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts @@ -181,7 +181,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("heading-with-everything is not a block container"); } @@ -210,7 +210,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("heading-with-everything is not a block container"); } @@ -240,7 +240,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("heading-with-everything is not a block container"); } @@ -273,7 +273,7 @@ describe("Test updateBlock", () => { getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("table-0 is not a block container"); } @@ -303,7 +303,7 @@ describe("Test updateBlock", () => { getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("table-0 is not a block container"); } @@ -940,7 +940,7 @@ describe("Test updateBlock minimal steps", () => { editor.prosemirrorState.doc, )!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("paragraph-with-styled-content is not a block container"); } diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 6edfc434d5..432490a7be 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -2,6 +2,7 @@ import { Fragment, type NodeType, type Node as PMNode, + type Schema, Slice, } from "prosemirror-model"; import { TextSelection, Transaction } from "prosemirror-state"; @@ -27,7 +28,12 @@ import { } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; -import { getPmSchema } from "../../../pmUtil.js"; +import { getBlockSchema, getPmSchema } from "../../../pmUtil.js"; +import { + getContentContainerNodeTypes, + isContainerType, + isContentContainerNode, +} from "../../../../schema/blocks/children.js"; // for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface export const updateBlockCommand = < @@ -82,40 +88,75 @@ export function updateBlockTr< // Adds blockGroup node with child blocks if necessary. - const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType]; - const newNodeType = pmSchema.nodes[block.type || blockInfo.blockNoteType]; + const newBlockType = block.type || blockInfo.blockNoteType; + const newNodeType = pmSchema.nodes[newBlockType]; const newBnBlockNodeType = newNodeType.isInGroup("bnBlock") ? newNodeType : pmSchema.nodes["blockContainer"]; - if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) { - const replaceFromOffset = - replaceFromPos !== undefined && - replaceFromPos > blockInfo.blockContent.beforePos && - replaceFromPos < blockInfo.blockContent.afterPos - ? replaceFromPos - blockInfo.blockContent.beforePos - 1 - : undefined; - - const replaceToOffset = - replaceToPos !== undefined && - replaceToPos > blockInfo.blockContent.beforePos && - replaceToPos < blockInfo.blockContent.afterPos - ? replaceToPos - blockInfo.blockContent.beforePos - 1 - : undefined; + // The dispatch below is about content nodes, not block nodes. A container + // with its own content keeps that content in a generated node rather than in + // its own, so routing on the block's node type would send an update of its + // content to the full-replace arm, where it used to be silently dropped. + const isContentContainer = isContentContainerNode(blockInfo.bnBlock.node); + + const replaceFromOffset = + blockInfo.blockContent && + replaceFromPos !== undefined && + replaceFromPos > blockInfo.blockContent.beforePos && + replaceFromPos < blockInfo.blockContent.afterPos + ? replaceFromPos - blockInfo.blockContent.beforePos - 1 + : undefined; + + const replaceToOffset = + blockInfo.blockContent && + replaceToPos !== undefined && + replaceToPos > blockInfo.blockContent.beforePos && + replaceToPos < blockInfo.blockContent.afterPos + ? replaceToPos - blockInfo.blockContent.beforePos - 1 + : undefined; + if ( + blockInfo.isWrappedBlock && + blockInfo.bnBlock.node.type.name === "blockContainer" && + newNodeType.isInGroup("blockContent") + ) { updateChildren(block, tr, blockInfo); // The code below determines the new content of the block. // or "keep" to keep as-is updateBlockContentNode( block, tr, - oldNodeType, + pmSchema.nodes[blockInfo.blockNoteType], newNodeType, blockInfo, replaceFromOffset, replaceToOffset, ); - } else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) { + } else if ( + blockInfo.isWrappedBlock && + isContentContainer && + newBlockType === blockInfo.blockNoteType + ) { + // Same container, so its generated content node stays as it is. Only what + // that node holds may change. + const contentNodeType = blockInfo.blockContent.node.type; + + updateChildren(block, tr, blockInfo); + updateBlockContentNode( + block, + tr, + contentNodeType, + contentNodeType, + blockInfo, + replaceFromOffset, + replaceToOffset, + ); + } else if ( + !blockInfo.isWrappedBlock && + newNodeType.isInGroup("bnBlock") && + !getContentContainerNodeTypes(pmSchema, newBlockType) + ) { updateChildren(block, tr, blockInfo); // old node was a bnBlock type (like column or columnList) and new block as well // No op, we just update the bnBlock below (at end of function) and have already updated the children @@ -128,9 +169,21 @@ export function updateBlockTr< // for this, we do a nodeToBlock on the existing block to get the children. // it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case const existingBlock = nodeToBlock(blockInfo.bnBlock.node, tr.doc); + const carried = carryOverContent( + existingBlock.content, + newBlockType, + pmSchema, + ); + // If no children are passed in, use the existing block's, but only when + // there actually are some. `nodeToBlock` always emits an array, and an + // empty one would read as "explicitly childless", suppressing the seeding + // a container needs when converting from a childless block. + const children = [...carried.children, ...existingBlock.children]; + const replacementNode = blockToNode( { - children: existingBlock.children, // if no children are passed in, use existing children + ...(carried.content ? { content: carried.content } : {}), + ...(children.length > 0 ? { children } : {}), ...block, }, pmSchema, @@ -158,6 +211,41 @@ export function updateBlockTr< } } +function carryOverContent( + existingContent: Block["content"], + newBlockType: string, + pmSchema: Schema, +): { + content?: PartialBlock["content"]; + children: PartialBlock[]; +} { + const nothing = { children: [] }; + + if (!existingContent || !Array.isArray(existingContent)) { + return nothing; + } + if (existingContent.length === 0) { + return nothing; + } + + const targetConfig = getBlockSchema(pmSchema)[newBlockType]; + if (!targetConfig) { + return nothing; + } + + if (targetConfig.content === "inline" || targetConfig.content === "plain") { + return { content: existingContent, children: [] }; + } + + if (targetConfig.content === "none" && isContainerType(targetConfig)) { + return { + children: [{ type: "paragraph", content: existingContent } as any], + }; + } + + return nothing; +} + function updateBlockContentNode< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -521,7 +609,7 @@ function updateChildren< Fragment.from(childNodes), ); } else { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } // Inserts a new blockGroup containing the child nodes created earlier. @@ -637,7 +725,7 @@ function restoreCellAnchor( // 1) Resolve the table node in the current document let tablePos = -1; - if (blockInfo.isBlockContainer) { + if (blockInfo.isWrappedBlock) { // Prefer the blockContent position when available (points directly at the PM table node) tablePos = tr.mapping.map(blockInfo.blockContent.beforePos); } else { diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts new file mode 100644 index 0000000000..3c7b84c65f --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -0,0 +1,147 @@ +import type { Node, NodeType } from "prosemirror-model"; + +import { + isContainerBlockNode, + isContainerNode, + isContentContainerNode, + isSealed, +} from "../../../schema/blocks/children.js"; + +/** + * Seal handling for the navigation helpers below. By default the helpers + * ignore seals. The block manipulation API crosses them freely, since an + * explicit placement is an intentional crossing. Gesture code (keyboard + * merges and moves) opts in with `respectSealed`, so content never + * implicitly crosses a sealed boundary. + */ +type SealOpts = { respectSealed?: boolean }; + +export function descendToLastInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, + opts?: SealOpts, +): number | null { + if (opts?.respectSealed && isSealed(container)) { + return null; + } + const endPos = containerBeforePos + 1 + container.content.size; + if (container.contentMatchAt(container.childCount).matchType(nodeType)) { + return endPos; + } + const lastChild = container.lastChild; + if (lastChild && isContainerNode(lastChild.type)) { + return descendToLastInsertionPos( + lastChild, + endPos - lastChild.nodeSize, + nodeType, + opts, + ); + } + return null; +} + +// No seal handling: its only callers are API code, which crosses seals by +// construction. +export function descendToFirstInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + // A content container's children start after the content node. + if (isContentContainerNode(container)) { + return descendToFirstInsertionPos( + container.lastChild!, + containerBeforePos + 1 + container.firstChild!.nodeSize, + nodeType, + ); + } + + const startPos = containerBeforePos + 1; + if (container.contentMatchAt(0).matchType(nodeType)) { + return startPos; + } + const firstChild = container.firstChild; + if (firstChild && isContainerNode(firstChild.type)) { + return descendToFirstInsertionPos(firstChild, startPos, nodeType); + } + return null; +} + +export function getFirstLeafBlock( + container: Node, + containerBeforePos: number, + opts?: SealOpts, +): { node: Node; beforePos: number } | null { + // With `respectSealed`, a sealed container's leaf blocks are not reachable + // from outside. + if (opts?.respectSealed && isSealed(container)) { + return null; + } + if (isContentContainerNode(container)) { + return getFirstLeafBlock( + container.lastChild!, + containerBeforePos + 1 + container.firstChild!.nodeSize, + opts, + ); + } + + const firstChild = container.firstChild; + if (!firstChild) { + return null; + } + const firstChildBeforePos = containerBeforePos + 1; + if (isContainerNode(firstChild.type)) { + return getFirstLeafBlock(firstChild, firstChildBeforePos, opts); + } + return { node: firstChild, beforePos: firstChildBeforePos }; +} + +/** + * Climbs out of containers until it reaches a position where `nodeType` fits. + * `side` picks which edge of each climbed container to land on: `"before"` for + * moves that put a block above the containers it leaves (Backspace move-out), + * `"after"` for moves that put it below them (Enter-exit). + */ +export function ascendToInsertablePos( + doc: Node, + pos: number, + nodeType: NodeType, + opts?: SealOpts, + side: "before" | "after" = "before", +): number | null { + for (;;) { + const $pos = doc.resolve(pos); + const parent = $pos.node(); + if (parent.contentMatchAt($pos.index()).matchType(nodeType)) { + return pos; + } + // A content-bearing container is climbed out of too: the ascent may sit + // right after its `__children` node, where only that node's siblings fit. + if ($pos.depth > 0 && isContainerBlockNode(parent)) { + // With `respectSealed`, climbing out of a sealed container would move + // content across its boundary. + if (opts?.respectSealed && isSealed(parent)) { + return null; + } + pos = side === "before" ? $pos.before() : $pos.after(); + continue; + } + return null; + } +} + +export function getAncestorContainers( + doc: Node, + pos: number, +): { id: string; depth: number }[] { + const $pos = doc.resolve(pos); + const containers: { id: string; depth: number }[] = []; + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if (isContainerBlockNode(ancestor) && ancestor.attrs.id) { + containers.push({ id: ancestor.attrs.id, depth }); + } + } + return containers; +} diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts new file mode 100644 index 0000000000..08fae1f7cf --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts @@ -0,0 +1,68 @@ +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { isContainerType } from "../../../schema/blocks/children.js"; + +export type ContainerUIInfo = { + containerTypes: ReadonlySet; + draggableContainerTypes: ReadonlySet; + /** + * Regular (non-container) block types whose spec sets `meta.draggable: + * false`. Container types are tracked separately in + * `draggableContainerTypes`, because they're identified in the DOM by + * `data-node-type` while regular blocks all share the `blockContainer` node + * and are identified by their content's `data-content-type`. + */ + nonDraggableBlockTypes: ReadonlySet; + containerSelector: string | null; +}; + +function buildSelector(types: ReadonlySet): string | null { + if (types.size === 0) { + return null; + } + return [...types].map((type) => `[data-node-type="${type}"]`).join(","); +} + +export function getContainerUIInfo( + editor: Pick, "schema">, +): ContainerUIInfo { + const containerTypes = new Set(); + const draggableContainerTypes = new Set(); + const nonDraggableBlockTypes = new Set(); + + for (const [type, spec] of Object.entries( + editor.schema.blockSpecs as Record< + string, + { + config: any; + implementation?: { meta?: { draggable?: boolean } }; + } + >, + )) { + const draggable = spec.implementation?.meta?.draggable !== false; + + // Legacy: `@blocknote/xl-multi-column`'s hand-written specs, which have + // no `children` config. Removed once multi-column is migrated onto the + // container API. + const isLegacyColumnType = type === "columnList" || type === "column"; + + if (!isContainerType(spec.config) && !isLegacyColumnType) { + if (!draggable) { + nonDraggableBlockTypes.add(type); + } + continue; + } + containerTypes.add(type); + // Legacy column nodes are never draggable themselves; only the blocks + // inside them are (matching the pre-container side menu behavior). + if (draggable && !isLegacyColumnType) { + draggableContainerTypes.add(type); + } + } + + return { + containerTypes, + draggableContainerTypes, + nonDraggableBlockTypes, + containerSelector: buildSelector(containerTypes), + }; +} diff --git a/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts new file mode 100644 index 0000000000..a313b69901 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts @@ -0,0 +1,480 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { containerSchema } from "./containers.fixture.js"; + +// Keymap tests for container blocks, split off from the node-environment +// `containers.test.ts`. tiptap can only reach `handleKeyDown` through a +// mounted view, so the editor is mounted and focused here and the keys are +// pressed for real. + +const schema = containerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +let div: HTMLElement; + +beforeAll(() => { + div = document.createElement("div"); + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +/** Puts the caret at the given position and presses the key. */ +async function pressKey( + key: string, + at: { block: string; placement: "start" | "end" }, +) { + editor.setTextCursorPosition(at.block, at.placement); + editor.focus(); + await userEvent.keyboard(`{${key}}`); +} + +describe("children keyboard handling", () => { + it("Enter on an empty last child escapes the container", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "Hello" }, + { id: "c-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "c-p-1", placement: "end" }); + + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]); + expect(editor.document.map((block) => block.type)).toEqual([ + "callout", + "paragraph", + "paragraph", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "c-0", + "c-p-1", + "trailing", + ]); + // The caret moves out with the block. + expect(editor.getTextCursorPosition().block.id).toBe("c-p-1"); + }); + + it("Enter escape ascends past levels that can't hold the block", async () => { + // A grid holds only cells, so a block escaping the last cell can't stop + // at the grid level. It lands below the grid itself. + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "g-c-0", + children: [{ id: "g-p-0", type: "paragraph", content: "A" }], + }, + { + type: "gridCell", + id: "g-c-1", + children: [ + { id: "g-p-1", type: "paragraph", content: "B" }, + { id: "g-p-2", type: "paragraph", content: "" }, + ], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "g-p-2", placement: "end" }); + + expect(editor.getBlock("g-c-1")!.children.map((child) => child.id)).toEqual( + ["g-p-1"], + ); + expect(editor.document.map((block) => block.id)).toEqual([ + "g-0", + "g-p-2", + "trailing", + ]); + expect(editor.getTextCursorPosition().block.id).toBe("g-p-2"); + }); + + it("Enter on an empty block mid-container stays inside", async () => { + // The escape only fires at the end of the container. An empty block with + // siblings after it never ejects. + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "Hello" }, + { id: "c-p-1", type: "paragraph", content: "" }, + { id: "c-p-2", type: "paragraph", content: "World" }, + ], + }, + ]); + + await pressKey("Enter", { block: "c-p-1", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + expect(editor.getBlock("c-0")!.children).toHaveLength(4); + }); + + it("Backspace at the start of a container's first child moves it out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "c-p-0", placement: "start" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "c-p-0", + "c-0", + ]); + expect(editor.getBlock("c-p-0")!.content).toEqual([ + { type: "text", text: "First", styles: {} }, + ]); + }); + + it("Backspace at the start of a block after a container moves it inside", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + expect(editor.getBlock("after")!.content).toEqual([ + { type: "text", text: "After", styles: {} }, + ]); + }); + + it("Delete at the end of a block before a container pulls its first child out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Delete", { block: "before", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "c-p-0", + "c-0", + ]); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + }); + + it("Delete at the end of a container's last child pulls the next block in", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Delete", { block: "c-p-0", placement: "end" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + }); +}); + +// Sealed-boundary counterparts to the open cases above. Every implicit +// crossing must be a no-op on a sealed container, while edits within the +// container keep working. +describe("sealed boundary keyboard handling", () => { + function documentShape() { + return editor.document.map((block) => [ + block.id, + block.children.map((child) => child.id), + ]); + } + + it("Backspace at the start of the first child does not move it out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "First" }, + { id: "s-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "s-p-0", placement: "start" }); + + expect(documentShape()).toEqual(shape); + expect(editor.getTextCursorPosition().block.id).toBe("s-p-0"); + }); + + it("Backspace at the start of the second child still merges within", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "First" }, + { id: "s-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "s-p-1", placement: "start" }); + + // Asserting the merge guards against the suite passing because + // keystrokes never arrive. + const children = editor.getBlock("s-0")!.children; + expect(children).toHaveLength(1); + expect(children[0].content).toEqual([ + { type: "text", text: "FirstSecond", styles: {} }, + ]); + }); + + it("Backspace after the container does not move the block inside", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [{ id: "s-p-0", type: "paragraph", content: "Sealed" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(documentShape()).toEqual(shape); + // With no way in, the fallback node-selects the container, so a second + // Backspace deletes it explicitly. + const selection = editor.transact((tr) => tr.selection); + expect("node" in selection && (selection.node as any).type.name).toBe( + "sealedBox", + ); + }); + + it("Backspace after the container does not replace its trailing empty block", async () => { + // The previous case falls through the "descend into the previous + // container" branch; this one targets the "previous block is empty" + // branch, which descends to the bottom nested block, here the empty + // paragraph inside the sealed container. + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "Sealed" }, + { id: "s-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Delete before the container does not pull its first child out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "First" }, + { id: "s-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + const shape = documentShape(); + + await pressKey("Delete", { block: "before", placement: "end" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Delete at the end of the last child does not pull the next block in", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [{ id: "s-p-0", type: "paragraph", content: "Sealed" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Delete", { block: "s-p-0", placement: "end" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Delete at the end of a nested last block does not reach past the boundary", async () => { + // The climb in "delete next block at any level" starts from a nested + // block, where the direct last-child branch doesn't apply. Without its + // own gate, Delete here would consume "after" into the sealed container. + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { + id: "s-p-0", + type: "paragraph", + content: "Parent", + children: [{ id: "s-n-0", type: "paragraph", content: "Nested" }], + }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Delete", { block: "s-n-0", placement: "end" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Backspace after an isolated container of sealed ones selects it", async () => { + // Same shape as tables: the grid itself is not sealed, but every place a + // descent could land is sealed. The block can't move in, so the grid is + // selected for an explicit second-Backspace delete instead. + editor.replaceBlocks(editor.document, [ + { + type: "sealedGrid", + id: "g-0", + children: [ + { + type: "sealedBox", + id: "g-c-0", + children: [{ id: "g-p-0", type: "paragraph", content: "Cell" }], + }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(documentShape()).toEqual(shape); + const selection = editor.transact((tr) => tr.selection); + expect("node" in selection && (selection.node as any).type.name).toBe( + "sealedGrid", + ); + }); + + it("Enter on an empty last child stays inside the container", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "Hello" }, + { id: "s-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "s-p-1", placement: "end" }); + + // A sealed boundary means Enter never moves content out, so there is no + // double-Enter escape. The new block is created inside. + expect(editor.document.map((block) => block.type)).toEqual([ + "sealedBox", + "paragraph", + ]); + const children = editor.getBlock("s-0")!.children; + expect(children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe(children[2].id); + }); +}); + +// HTML round-trips (full, external, clipboard) live with the parse rules in +// `schema/blocks/containerParse.browser.test.ts`. +describe("children conversion", () => { + it("flattens containers to their children in markdown export", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "In callout" }, + { id: "c-p-1", type: "heading", content: "Heading in callout" }, + ], + }, + ]); + + const markdown = editor.blocksToMarkdownLossy(editor.document); + expect(markdown).toContain("In callout"); + expect(markdown).toContain("# Heading in callout"); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts new file mode 100644 index 0000000000..c6034d8c5e --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -0,0 +1,119 @@ +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + children: { + allow: "any", + default: [{ type: "paragraph" }], + }, + }, + { render: renderDiv }, +)(); + +// A compartment-style container, like a table cell. Content never implicitly +// crosses its boundary. +const SealedBox = createBlockSpec( + { + type: "sealedBox" as const, + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "sealed" }, + }, + { render: renderDiv }, +)(); + +// An open container, like a column list. Everything crosses its edge +// (PM `isolating: false`). +const OpenBox = createBlockSpec( + { + type: "openBox" as const, + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "open" }, + }, + { render: renderDiv }, +)(); + +// Same shape as tables: an isolated container (the default) that holds only +// sealed ones, so any descent into it bottoms out at a sealed boundary. +const SealedGrid = createBlockSpec( + { + type: "sealedGrid" as const, + propSchema: {}, + content: "none", + children: { allow: ["sealedBox"] }, + }, + { render: renderDiv }, +)(); + +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + children: { + allow: ["gridCell"], + min: 2, + whenEmptied: "unwrap", + }, + }, + { render: renderDiv }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + children: { allow: "any" }, + placement: "containerOnly", + }, + { render: renderDiv }, +)(); + +// A refill (default `whenEmptied`) container whose `default` has content. +// Dropping below `min` tops it back up from the unconsumed tail of `default`. +const SeededPair = createBlockSpec( + { + type: "seededPair" as const, + propSchema: {}, + content: "none", + children: { + allow: "any", + min: 2, + default: [ + { type: "paragraph", content: "Seed A" }, + { type: "paragraph", content: "Seed B" }, + ], + }, + }, + { render: renderDiv }, +)(); + +export const containerSchema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + sealedBox: SealedBox, + openBox: OpenBox, + sealedGrid: SealedGrid, + grid: Grid, + gridCell: GridCell, + seededPair: SeededPair, + } as const, +}); diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts new file mode 100644 index 0000000000..16dfd81154 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,349 @@ +// @vitest-environment node +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { containerSchema } from "./containers.fixture.js"; + +type PartialBlock = (typeof containerSchema)["PartialBlock"]; + +// Document-model behaviour of container blocks: seeding, schema enforcement, +// repair and selection. Everything is `Block` JSON in and out, so the editor +// runs headless with no DOM. +// +// The keymap (tiptap can only reach it through a mounted view) and +// HTML/markdown serialization (builds real DOM) are tested in +// `containers.browser.test.ts`. + +const schema = containerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +describe("children insertion & seeding", () => { + it("seeds `default` when inserted without children", () => { + editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"); + + const callout = editor.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + }); + + // Regression: `min` defaults to 1 and nothing seeded a container without + // `default`, so inserting one threw a raw ProseMirror + // `RangeError: Invalid content for node ...`. + it("fills a container that has no `default`, with real child ids", () => { + expect(() => + editor.insertBlocks([{ type: "sealedBox", id: "b-0" }], "p-1", "after"), + ).not.toThrow(); + + const box = editor.getBlock("b-0")!; + expect(box.children).toHaveLength(1); + expect(box.children[0].type).toBe("paragraph"); + // Auto-filled nodes come from the schema with `id: null`, and the + // UniqueID plugin never sees them because `insertBlocks` converts back + // through `nodeToBlock` before the transaction is dispatched. + expect(box.children[0].id).toBeTruthy(); + expect(editor.getBlock(box.children[0].id)).toBeDefined(); + }); + + it("does not re-seed a container round-tripped through the document", () => { + editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"); + const inserted = editor.getBlock("c-0")!; + + // `nodeToBlock` always emits an array, so a round-trip must not read an + // empty one as "unspecified" and seed on top of it. + editor.replaceBlocks([inserted], [inserted]); + + expect(editor.getBlock("c-0")!.children).toHaveLength( + inserted.children.length, + ); + }); + + // Regression: `children: []` was taken at face value, building a node below + // `min: 1`, and `insertBlocks` threw a raw + // `Invalid content for node callout: <>` from its `node.check()`. + it("fills an explicitly empty `children` array up to `min`", () => { + expect(() => + editor.insertBlocks( + [{ type: "callout", id: "c-0", children: [] }], + "p-1", + "after", + ), + ).not.toThrow(); + + const callout = editor.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].id).toBeTruthy(); + }); + + // A container that unwraps as it empties is the one case explicit children + // are not padded: adding a second column to a one-column columnList would + // invent content the next repair pass deletes anyway. + it("refuses rather than pads a container that unwraps when emptied", () => { + expect(() => + editor.insertBlocks( + [{ type: "grid", id: "g-1", children: [{ type: "gridCell" }] }], + "p-1", + "after", + ), + ).toThrow(); + }); + + // `updateBlock` conversions into and out of containers (content carried into + // the first child, etc.) are covered in `contentContainers.test.ts`. + + it("accepts arbitrary block children, including nested containers", () => { + editor.insertBlocks( + [ + { + type: "callout", + id: "c-0", + children: [ + { type: "heading", content: "In callout" }, + { + type: "callout", + id: "c-1", + children: [{ type: "paragraph", content: "Nested" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.type)).toEqual([ + "heading", + "callout", + ]); + expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph"); + }); + + it("enforces a restricted container's allow list", () => { + editor.insertBlocks( + [ + { + type: "grid", + id: "g-0", + children: [{ type: "gridCell" }, { type: "gridCell" }], + }, + ], + "p-1", + "after", + ); + expect(editor.getBlock("g-0")!.children.map((child) => child.type)).toEqual( + ["gridCell", "gridCell"], + ); + + expect(() => + editor.insertBlocks( + [ + { + type: "grid", + children: [ + { type: "paragraph", content: "not a cell" }, + { type: "paragraph", content: "not a cell" }, + ], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); + + // The `allow: "any"` wildcard compiles to the containers placeable + // anywhere, so a containerOnly block only fits where a parent names it + // explicitly: not at the root, and not under a wildcard container. + it("rejects a containerOnly block outside a parent that names it", () => { + expect(() => + editor.insertBlocks( + [{ type: "gridCell", children: [{ type: "paragraph" }] }], + "p-1", + "after", + ), + ).toThrow(); + + expect(() => + editor.insertBlocks( + [ + { + type: "callout", + children: [{ type: "gridCell", children: [{ type: "paragraph" }] }], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); +}); + +describe("boundary", () => { + it("derives ProseMirror `isolating` from `boundary`", () => { + const nodes = editor.pmSchema.nodes; + expect(nodes["openBox"].spec.isolating).toBe(false); + // "isolated" is the default. + expect(nodes["callout"].spec.isolating).toBe(true); + // "sealed" also isolates. + expect(nodes["sealedBox"].spec.isolating).toBe(true); + }); +}); + +// `initialContent` is the only path that builds a document without validating +// it, since `blockToNode` is deliberately lenient and `createDocument` builds +// from JSON. Regression: blocks that `insertBlocks` rejects loaded without +// error, and a container below its `min` stayed there for the life of the +// document. +describe("initialContent enforcement", () => { + const createWith = (initialContent: PartialBlock[]) => { + return BlockNoteEditor.create({ schema, initialContent }); + }; + + it("fills an explicitly empty `children` array up to `min`", () => { + const loaded = createWith([{ type: "callout", id: "c-0", children: [] }]); + + const callout = loaded.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + + loaded._tiptapEditor.destroy(); + }); + + it("rejects a container it cannot legally fill", () => { + expect(() => + createWith([ + { type: "grid", id: "g-0", children: [{ type: "gridCell" }] }, + ]), + ).toThrow(/initialContent/); + }); +}); + +describe("children repair", () => { + it("keeps a default container when its only child is removed (refilled)", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["c-p-0"]); + + const callout = editor.getBlock("c-0")!; + expect(callout).toBeDefined(); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].content).toEqual([]); + }); + + it("refills below `min` from the unconsumed tail of `default`", () => { + editor.replaceBlocks(editor.document, [ + { + type: "seededPair", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "Kept" }, + { id: "s-p-1", type: "paragraph", content: "Removed" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["s-p-1"]); + + // One child survives (k = 1), so the top-up seeds `default[1]`, not an + // empty paragraph and not `default[0]`. + const pair = editor.getBlock("s-0")!; + expect(pair.children).toHaveLength(2); + expect(pair.children[0].content).toEqual([ + { type: "text", text: "Kept", styles: {} }, + ]); + expect(pair.children[1].content).toEqual([ + { type: "text", text: "Seed B", styles: {} }, + ]); + }); + + it("unwraps a repair-configured container when only one non-empty child remains", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "cell-a", + children: [{ id: "cell-a-p", type: "paragraph", content: "A" }], + }, + { + type: "gridCell", + id: "cell-b", + children: [{ id: "cell-b-p", type: "paragraph", content: "B" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["cell-a-p"]); + + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "cell-b-p", + "trailing", + ]); + }); +}); + +describe("children selection", () => { + it("getSelectionCutBlocks handles selections reaching into a container", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "c-p-0"); + + // Previously threw "unexpected" for any partial selection touching a + // container (breaking comments/AI selection handling). + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.length).toBeGreaterThanOrEqual(1); + expect(result.blocks.map((block) => block.id)).toContain("before"); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts new file mode 100644 index 0000000000..8a4e687251 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts @@ -0,0 +1,358 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { contentContainerSchema } from "./contentContainers.fixture.js"; + +// Keymap tests for content-bearing containers, split off from the +// node-environment `contentContainers.test.ts`. tiptap can only reach +// `handleKeyDown` through a mounted view, so the editor is mounted and +// focused here and the keys are pressed for real. Pure-container keyboard +// handling is covered in `containers.browser.test.ts`. + +const schema = contentContainerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +let div: HTMLElement; + +beforeAll(() => { + div = document.createElement("div"); + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +/** Puts the caret at the given position and presses the key. */ +async function pressKey( + key: string, + at: { block: string; placement: "start" | "end"; offset?: number }, +) { + editor.setTextCursorPosition(at.block, at.placement); + if (at.offset) { + editor._tiptapEditor.commands.setTextSelection( + editor._tiptapEditor.state.selection.from + at.offset, + ); + } + editor.focus(); + await userEvent.keyboard(`{${key}}`); +} + +describe("content-bearing container: keyboard", () => { + it("Backspace at the start of the title unwraps the container", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "t-0", placement: "start" }); + + // The unwrap must not destroy the title or the children. + const unwrapped = editor.document[1]; + expect(unwrapped.type).toBe("paragraph"); + expect(unwrapped.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(unwrapped.children.map((child) => child.id)).toEqual([ + "t-p-0", + "t-p-1", + ]); + }); + + it("Backspace at the start of the first child merges it into the title", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "t-p-0", placement: "start" }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "TitleFirst", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-1"]); + }); + + it("Backspace after a sealed container selects it instead of merging into its title", async () => { + // A content-bearing container is an ordinary merge target (its title is + // `inline*`), so without the boundary the paragraph would merge into it. + editor.replaceBlocks(editor.document, [ + { + type: "sealedToggle", + id: "st-0", + content: "Title", + children: [{ id: "st-p-0", type: "paragraph", content: "First" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + const toggle = editor.getBlock("st-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(editor.document.map((block) => block.id)).toEqual(["st-0", "after"]); + const selection = editor.transact((tr) => tr.selection); + expect("node" in selection && (selection.node as any).type.name).toBe( + "sealedToggle", + ); + }); + + it("Delete before a sealed container selects it instead of merging it in", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "sealedToggle", + id: "st-0", + content: "Title", + children: [{ id: "st-p-0", type: "paragraph", content: "First" }], + }, + ]); + + await pressKey("Delete", { block: "before", placement: "end" }); + + expect(editor.getBlock("before")!.content).toEqual([ + { type: "text", text: "Before", styles: {} }, + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "st-0", + ]); + const selection = editor.transact((tr) => tr.selection); + expect("node" in selection && (selection.node as any).type.name).toBe( + "sealedToggle", + ); + }); + + it("Enter on an empty last child of a sealed container stays inside", async () => { + // The sealed guard has to resolve the config through the generated + // `__children` node name. A plain block-type lookup misses it and lets + // Enter escape the sealed boundary. + editor.replaceBlocks(editor.document, [ + { + type: "sealedToggle", + id: "st-0", + content: "Title", + children: [ + { id: "st-p-0", type: "paragraph", content: "First" }, + { id: "st-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "st-p-1", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "st-0", + "trailing", + ]); + const children = editor.getBlock("st-0")!.children; + expect(children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe(children[2].id); + }); + + it("Backspace after a container with an empty body moves the block into it", async () => { + // An empty-bodied content container is its own bottom nested block, and + // merging into it would stitch across its required `__children` node, + // so the block moves inside instead, like after any non-sealed container. + editor.replaceBlocks(editor.document, [ + { + type: "optionalToggle", + id: "ot-0", + content: "Title", + children: [], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + const toggle = editor.getBlock("ot-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["after"]); + expect(editor.document.map((block) => block.id)).toEqual(["ot-0"]); + }); + + it("Delete before a container merges its title in and un-nests its children", async () => { + // The forward merge no longer stitches across the container boundary; + // instead the container is dissolved: title into the previous block, + // children out to the top level. + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "First" }], + }, + ]); + + await pressKey("Delete", { block: "before", placement: "end" }); + + expect(editor.getBlock("before")!.content).toEqual([ + { type: "text", text: "BeforeTitle", styles: {} }, + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "t-p-0", + ]); + }); + + it("Enter mid-title splits, with the tail becoming the first child", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "TitleTail", + children: [{ id: "t-p-0", type: "paragraph", content: "First" }], + }, + ]); + + await pressKey("Enter", { block: "t-0", placement: "start", offset: 5 }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(toggle.children[0].content).toEqual([ + { type: "text", text: "Tail", styles: {} }, + ]); + expect(toggle.children[1].id).toBe("t-p-0"); + }); + + it("Enter on an empty last child still escapes the container", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "t-p-1", placement: "end" }); + + expect(editor.getBlock("t-0")!.children.map((child) => child.id)).toEqual([ + "t-p-0", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "t-0", + "t-p-1", + "trailing", + ]); + // The escape must leave the title intact. + expect(editor.getBlock("t-0")!.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + }); + + it("Delete at the end of the title pulls the first child's content up", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Delete", { block: "t-0", placement: "end" }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "TitleFirst", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-1"]); + }); + + it("Delete at the end of the title of a container that must keep a child", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Only" }], + }, + ]); + + await pressKey("Delete", { block: "t-0", placement: "end" }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "TitleOnly", styles: {} }, + ]); + // With `min: 1`, the schema refills the emptied children node. + expect(toggle.children).toHaveLength(1); + expect(toggle.children[0].content).toEqual([]); + }); + + it("Delete in a childless container does not throw", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "optionalToggle", + id: "t-0", + content: "Title", + children: [], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Delete", { block: "t-0", placement: "end" }); + + // Delete at the end of a childless container's title reaches past it to + // the next block. The real check is that it doesn't throw; asserting a + // change guards against the keypress never arriving. + expect(editor.getBlock("t-0")!.content).toEqual([ + { type: "text", text: "TitleAfter", styles: {} }, + ]); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts new file mode 100644 index 0000000000..7df7e7a66e --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts @@ -0,0 +1,96 @@ +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +// The content-bearing container schema shared by `contentContainers.test.ts` +// (node: document model) and `contentContainers.browser.test.ts` (browser: +// keymap), so both suites test the same blocks. + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// A toggle-shaped container with its own inline content (its "title") as +// well as children. `min` defaults to 1, so it always keeps at least one +// child. +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: { open: { default: true } }, + content: "inline", + children: { allow: "any" }, + }, + { render: renderDiv }, +)(); + +// The same, but allowed to hold no children at all. With `min: 0` there is +// no addressable child to fall back on. +const OptionalToggle = createBlockSpec( + { + type: "optionalToggle" as const, + propSchema: {}, + content: "inline", + children: { allow: "any", min: 0 }, + }, + { render: renderDiv }, +)(); + +// A content-bearing container that unwraps as it empties out. Repair has to +// treat it identically to a pure one apart from the title. +const TitledGrid = createBlockSpec( + { + type: "titledGrid" as const, + propSchema: {}, + content: "inline", + children: { allow: "any", min: 2, whenEmptied: "unwrap" }, + }, + { render: renderDiv }, +)(); + +// The toggle shape with a sealed boundary. Outside content never implicitly +// merges into or out of its title and children. +const SealedToggle = createBlockSpec( + { + type: "sealedToggle" as const, + propSchema: {}, + content: "inline", + children: { allow: "any", boundary: "sealed" }, + }, + { render: renderDiv }, +)(); + +// A pure container, used as the no-regression counterpart in each test pair. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: {}, + content: "none", + children: { allow: "any", default: [{ type: "paragraph" }] }, + }, + { render: renderDiv }, +)(); + +// A pure container allowed to hold nothing, so there is no child to place a +// text cursor in. +const EmptyBox = createBlockSpec( + { + type: "emptyBox" as const, + propSchema: {}, + content: "none", + children: { allow: "any", min: 0 }, + }, + { render: renderDiv }, +)(); + +export const contentContainerSchema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + toggle: Toggle, + optionalToggle: OptionalToggle, + sealedToggle: SealedToggle, + titledGrid: TitledGrid, + callout: Callout, + emptyBox: EmptyBox, + } as const, +}); diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts new file mode 100644 index 0000000000..1172b03fcb --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts @@ -0,0 +1,308 @@ +// @vitest-environment node +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { contentContainerSchema } from "./contentContainers.fixture.js"; + +// Document-model behaviour of content-bearing containers (the "toggle" shape: +// a container with its own inline content as well as children): repair, +// childless handling, `updateBlock` and selection. Everything is `Block` JSON +// in and out, so the editor runs headless with no DOM. +// +// The keymap is tested in `contentContainers.browser.test.ts`, since tiptap +// can only reach `handleKeyDown` through a mounted view. + +const schema = contentContainerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +describe("content-bearing container: repair", () => { + // Unwrapping the container discards its node, and with it the title text + // the user typed, so repair must refuse rather than silently destroy it. + it("does not unwrap a container whose title has content", () => { + editor.replaceBlocks(editor.document, [ + { + type: "titledGrid", + id: "g-0", + content: "Kept title", + children: [ + { id: "g-p-0", type: "paragraph", content: "A" }, + { id: "g-p-1", type: "paragraph", content: "B" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["g-p-0"]); + + const grid = editor.getBlock("g-0"); + expect(grid).toBeDefined(); + expect(grid!.content).toEqual([ + { type: "text", text: "Kept title", styles: {} }, + ]); + // `min: 2`, so ProseMirror refills the removed child rather than letting + // the container drop below what its content expression requires. + expect(grid!.children).toHaveLength(2); + expect(grid!.children.map((child) => child.id)).toContain("g-p-1"); + }); + + // The pure-container version of this unwrap is covered in + // `containers.test.ts` ("unwraps a repair-configured container..."). + it("unwraps a container whose title is empty", () => { + editor.replaceBlocks(editor.document, [ + { + type: "titledGrid", + id: "g-0", + content: "", + children: [ + { id: "g-p-0", type: "paragraph", content: "A" }, + { id: "g-p-1", type: "paragraph", content: "B" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["g-p-0"]); + + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "g-p-1", + "trailing", + ]); + }); + + it("deletes a titleless container that empties out completely", () => { + editor.replaceBlocks(editor.document, [ + { + type: "titledGrid", + id: "g-0", + content: "", + children: [ + { id: "g-p-0", type: "paragraph", content: "A" }, + { id: "g-p-1", type: "paragraph", content: "B" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["g-p-0", "g-p-1"]); + + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual(["trailing"]); + }); +}); + +describe("content-bearing container: childless container", () => { + it("setTextCursorPosition on a childless pure container does not throw", () => { + // A pure container that allows zero children has no child to descend into. + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); + editor.insertBlocks( + [{ type: "emptyBox", id: "b-0", children: [] } as any], + "p-0", + "after", + ); + + expect(() => editor.setTextCursorPosition("b-0", "start")).not.toThrow(); + expect(() => editor.setTextCursorPosition("b-0", "end")).not.toThrow(); + }); + + it("setTextCursorPosition on a childless content-bearing container works", () => { + editor.replaceBlocks(editor.document, [ + { + type: "optionalToggle", + id: "t-0", + content: "Title", + children: [], + }, + ]); + + editor.setTextCursorPosition("t-0", "end"); + expect(editor.getTextCursorPosition().block.id).toBe("t-0"); + }); +}); + +describe("content-bearing container: updateBlock", () => { + it("updates the title or props in place, leaving the rest untouched", () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("t-0", { content: "New title" }); + editor.updateBlock("t-0", { props: { open: false } }); + + const toggle = editor.getBlock("t-0")!; + expect((toggle.props as any).open).toBe(false); + expect(toggle.content).toEqual([ + { type: "text", text: "New title", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]); + }); + + it("carries content and children from a paragraph into a container", () => { + editor.replaceBlocks(editor.document, [ + { + id: "p-0", + type: "paragraph", + content: "Title", + children: [{ id: "p-c-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("p-0", { type: "toggle" }); + + // The full-replace path builds a fresh node, so the block is addressed by + // position rather than by id here. + const toggle = editor.document[0]; + expect(toggle.type).toBe("toggle"); + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["p-c-0"]); + }); + + it("carries content and children from a container back to a paragraph", () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("t-0", { type: "paragraph" }); + + const paragraph = editor.document[0]; + expect(paragraph.type).toBe("paragraph"); + expect(paragraph.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(paragraph.children.map((child) => child.id)).toEqual(["t-p-0"]); + }); + + it("carries content into a pure container's first child", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Some text" }, + ]); + + editor.updateBlock("p-0", { type: "callout" }); + + const callout = editor.document[0]; + expect(callout.type).toBe("callout"); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].content).toEqual([ + { type: "text", text: "Some text", styles: {} }, + ]); + }); + + it("an explicit `content` in the update wins over the carried one", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Old" }, + ]); + + editor.updateBlock("p-0", { type: "toggle", content: "New" }); + + expect(editor.document[0].content).toEqual([ + { type: "text", text: "New", styles: {} }, + ]); + }); + + it("treats `children: []` as inert, not as a clear", () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("t-0", { children: [], props: { open: false } }); + + const toggle = editor.getBlock("t-0")!; + expect((toggle.props as any).open).toBe(false); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]); + }); +}); + +describe("content-bearing container: selection", () => { + it("getSelectionCutBlocks handles a selection reaching into the container", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "t-p-0"); + + const result = editor.getSelectionCutBlocks(); + // The container is partially covered, so its included children are + // spliced in rather than the container being returned whole. + expect(result.blocks.map((block) => block.id)).toEqual(["before", "t-p-0"]); + }); + + it("getSelectionCutBlocks handles a selection ending inside the title", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "t-0"); + + // The selection ends inside the container's own title, before any of its + // children, so the generated `__children` node is absent from the slice. + // Converting the container must not throw. It comes back as a cut block + // (its title, no children) rather than recursing into its content node. + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.map((block) => block.id)).toEqual(["before", "t-0"]); + expect(result.blockCutAtEnd).toBe("t-0"); + const toggle = result.blocks.find((block) => block.id === "t-0")!; + expect(toggle.children).toEqual([]); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts new file mode 100644 index 0000000000..a98a73386f --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,347 @@ +import { Fragment, Slice, type Node } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; +import type { Schema } from "prosemirror-model"; + +import { + BLOCK_GROUP_CHILD_GROUP, + blockTypeOfContainerChildrenNode, + getChildrenConfig, + isContainerNode, + isContentContainerNode, + resolveChildren, +} from "../../../schema/blocks/children.js"; +import type { ResolvedChildren } from "../../../schema/blocks/children.js"; +import { seedRefillChildren } from "../../nodeConversions/blockToNode.js"; +import { getNodeById } from "../../nodeUtil.js"; +import { fixColumnList } from "../commands/replaceBlocks/util/fixColumnList.js"; + +// Defined in `children.ts` (it answers a schema-level question); re-exported +// here because the public root export (`index.ts`) imports it from this +// module. +export { isContainerNode }; + +export function isEmptyContainerChild(node: Node): boolean { + if (node.type.name === "blockContainer") { + const blockContent = node.firstChild; + return ( + node.childCount === 1 && + !!blockContent && + blockContent.type.name === "paragraph" && + blockContent.childCount === 0 + ); + } + if (isContainerNode(node.type)) { + return node.childCount === 1 && isEmptyContainerChild(node.firstChild!); + } + return false; +} + +export function removeEmptyChildren(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + for ( + let childIndex = container.childCount - 1; + childIndex >= 0; + childIndex-- + ) { + const childPos = tr.doc.resolve(containerPos + 1).posAtIndex(childIndex); + const child = tr.doc.resolve(childPos).nodeAfter; + if (!child) { + throw new Error("Invalid childPos: does not point to a child node."); + } + + if (isEmptyContainerChild(child)) { + tr.delete(childPos, childPos + child.nodeSize); + } + } +} + +function isInsertableChild(node: Node): boolean { + return ( + node.type.name === "blockContainer" || + node.type.isInGroup(BLOCK_GROUP_CHILD_GROUP) + ); +} + +type ContainerRepairTarget = { + blockPos: number; + blockNode: Node; + childrenPos: number; + contentNode: Node | undefined; +}; + +function getContainerRepairTarget( + doc: Node, + containerPos: number, +): ContainerRepairTarget | undefined { + const node = doc.resolve(containerPos).nodeAfter; + if (!node) { + return undefined; + } + + if (isContentContainerNode(node)) { + const contentNode = node.firstChild!; + return { + blockPos: containerPos, + blockNode: node, + childrenPos: containerPos + 1 + contentNode.nodeSize, + contentNode, + }; + } + + if (!isContainerNode(node.type)) { + return undefined; + } + + // A `__children` node: normalize to the block that owns it. + if (blockTypeOfContainerChildrenNode(node.type.name)) { + return getContainerRepairTarget(doc, doc.resolve(containerPos).before()); + } + + return { + blockPos: containerPos, + blockNode: node, + childrenPos: containerPos, + contentNode: undefined, + }; +} + +/** + * The (possibly rebuilt) block at the repair target, with where its children + * now live and where they start. Recomputed after each mutation of `tr`. + */ +function refreshRepairTarget( + tr: Transaction, + target: ContainerRepairTarget, +): { children: Node; childrenStart: number } | undefined { + const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter; + if (!refreshedBlock || refreshedBlock.type !== target.blockNode.type) { + return undefined; + } + + return target.contentNode + ? { + children: refreshedBlock.lastChild!, + childrenStart: + target.blockPos + 1 + refreshedBlock.firstChild!.nodeSize + 1, + } + : { children: refreshedBlock, childrenStart: target.blockPos + 1 }; +} + +export function fixContainer(tr: Transaction, containerPos: number) { + const target = getContainerRepairTarget(tr.doc, containerPos); + if (!target) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + const blockConfig = target.blockNode.type.spec.blockConfig; + const childrenConfig = blockConfig + ? getChildrenConfig(blockConfig) + : undefined; + const config = childrenConfig ? resolveChildren(childrenConfig) : undefined; + + if (!config) { + // Legacy repair for `@blocknote/xl-multi-column`'s hand-written PM nodes, + // which have no `children` config but sit in the `childContainer` group. + // Removed once multi-column is migrated onto the container API. + if (target.blockNode.type.name === "columnList") { + fixColumnList(tr, target.blockPos); + } + return; + } + + if (config.whenEmptied === "unwrap") { + // Unwrapping an emptied content-bearing container deletes the whole block, + // so don't run it while the container's own content is non-empty. (Refill + // only rewrites the `__children` node and never touches `__content`, so it + // is safe regardless.) + if (target.contentNode && target.contentNode.content.size > 0) { + return; + } + unwrapContainer(tr, target, config); + } else { + // `blockConfig` is set whenever `config` is. + refillContainer(tr, target, config, blockConfig!.type); + } +} + +function unwrapContainer( + tr: Transaction, + target: ContainerRepairTarget, + config: ResolvedChildren, +) { + removeEmptyChildren(tr, target.childrenPos); + + const refreshed = refreshRepairTarget(tr, target); + if (!refreshed) { + return; + } + const { children: refreshedChildren, childrenStart } = refreshed; + + const nonEmptyChildren: { child: Node; offset: number }[] = []; + refreshedChildren.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + nonEmptyChildren.push({ child, offset }); + } + }); + + if (nonEmptyChildren.length >= config.min) { + return; + } + + const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter!; + const blockEnd = target.blockPos + refreshedBlock.nodeSize; + + if (nonEmptyChildren.length === 0) { + tr.delete(target.blockPos, blockEnd); + return; + } + + // Unwrap: replace the container with its remaining non-empty children. + if (nonEmptyChildren.length === 1) { + const { child, offset } = nonEmptyChildren[0]; + const childStart = childrenStart + offset; + + const [gapFrom, gapTo] = isInsertableChild(child) + ? [childStart, childStart + child.nodeSize] + : [childStart + 1, childStart + child.nodeSize - 1]; + + tr.step( + new ReplaceAroundStep( + target.blockPos, + blockEnd, + gapFrom, + gapTo, + Slice.empty, + 0, + false, + ), + ); + return; + } + + // Several survivors but still below `min`: rebuild replacement content. + const replacement: Node[] = []; + for (const { child } of nonEmptyChildren) { + if (isInsertableChild(child)) { + replacement.push(child); + } else { + child.forEach((grandChild) => replacement.push(grandChild)); + } + } + tr.replaceWith(target.blockPos, blockEnd, Fragment.from(replacement)); +} + +/** + * The `whenEmptied: "refill"` repair: when fewer than `min` non-empty + * children remain, drop the emptied ones and top the container back up. + * Position `k..min-1` (k = surviving count) is seeded from the container's + * `default`, falling back to `fillBefore`-style empty fill when `default` is + * absent. Deterministic, appended at the end. + * + * Rebuilt in a single replace: removing an empty child first would make + * ProseMirror's schema fitting instantly pad the container back to `min` with + * a fresh empty child, hiding the deficit from the seeding step. + */ +function refillContainer( + tr: Transaction, + target: ContainerRepairTarget, + config: ResolvedChildren, + blockType: string, +) { + const current = refreshRepairTarget(tr, target); + if (!current) { + return; + } + const { children, childrenStart } = current; + + const survivors: Node[] = []; + children.forEach((child) => { + if (!isEmptyContainerChild(child)) { + survivors.push(child); + } + }); + // At or above the minimum, empty children are left alone: they may be + // intentional. + if (survivors.length >= config.min) { + return; + } + + const seeds = seedRefillChildren( + blockType, + tr.doc.type.schema, + survivors.length, + config.min, + ); + + if (seeds.length === 0) { + // No `default` to seed from, so empty children are the right fill, and + // ProseMirror's schema fitting has usually already padded the container + // back to `min` with them. Complete the fill only when it hasn't. + const match = children.type.contentMatch.matchFragment(children.content); + const fill = match?.fillBefore(Fragment.empty, true); + if (fill && fill.size > 0) { + tr.insert(childrenStart + children.content.size, fill); + } + return; + } + + // Survivors keep their place; the seeds land at the end, replacing the + // emptied (or schema-padded) children. + let content = Fragment.from([...survivors, ...seeds]); + const match = children.type.contentMatch.matchFragment(content); + const fill = match?.fillBefore(Fragment.empty, true); + if (fill) { + content = content.append(fill); + } + + tr.replaceWith(childrenStart, childrenStart + children.content.size, content); +} + +export function fixContainersById( + tr: Transaction, + containers: { id: string; depth: number }[], +) { + [...containers] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (!target) { + return; + } + fixContainer(tr, target.posBeforeNode); + }); +} + +export function flattenNonInsertableBlocks< + T extends { type?: string; content?: unknown; children?: T[] }, +>(blocks: T[], pmSchema: Schema): T[] { + return blocks.flatMap((block) => { + const nodeType = block.type ? pmSchema.nodes[block.type] : undefined; + if ( + nodeType && + nodeType.isInGroup("bnBlock") && + !nodeType.isInGroup(BLOCK_GROUP_CHILD_GROUP) + ) { + const children = flattenNonInsertableBlocks( + block.children ?? [], + pmSchema, + ); + return Array.isArray(block.content) && block.content.length > 0 + ? [ + { type: "paragraph", content: block.content } as unknown as T, + ...children, + ] + : children; + } + return [block]; + }); +} diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index d6229a3f0a..466845d94a 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -169,15 +169,12 @@ export function setSelection( headBlockInfo.blockNoteType as keyof typeof schema.blockSchema ]; - if ( - !anchorBlockInfo.isBlockContainer || - anchorBlockConfig.content === "none" - ) { + if (!anchorBlockInfo.isWrappedBlock || anchorBlockConfig.content === "none") { throw new Error( `Attempting to set selection anchor in block without content (id ${startBlockId})`, ); } - if (!headBlockInfo.isBlockContainer || headBlockConfig.content === "none") { + if (!headBlockInfo.isWrappedBlock || headBlockConfig.content === "none") { throw new Error( `Attempting to set selection anchor in block without content (id ${endBlockId})`, ); diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts index b0b2cc078d..38ad256457 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -74,7 +74,7 @@ export function setTextCursorPosition( const contentType: "none" | "inline" | "table" | "plain" = schema.blockSchema[info.blockNoteType]!.content; - if (info.isBlockContainer) { + if (info.isWrappedBlock) { const blockContent = info.blockContent; if (contentType === "none") { tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos)); @@ -110,8 +110,15 @@ export function setTextCursorPosition( } else { const child = placement === "start" - ? info.childContainer.node.firstChild! - : info.childContainer.node.lastChild!; + ? info.childContainer.node.firstChild + : info.childContainer.node.lastChild; + + if (!child) { + // A container allowed to hold no children has no text to put a cursor + // in, so the container itself is selected instead. + tr.setSelection(NodeSelection.create(tr.doc, info.bnBlock.beforePos)); + return; + } setTextCursorPosition(tr, getNodeId(child, tr.doc), placement); } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index e2274140f7..5ab3463e12 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -6,8 +6,10 @@ import { BlockImplementation, BlockSchema, InlineContentSchema, + isContainerType, StyleSchema, } from "../../../../schema/index.js"; +import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -270,6 +272,21 @@ function serializeBlock< } elementFragment.append(...Array.from(ret.dom.childNodes)); } else { + // Asked of the block config rather than of its ProseMirror node. See the + // same check in `serializeBlocksInternalHTML`. + if (isContainerType(editor.schema.blockSchema[block.type as any])) { + // Container blocks own their outer DOM. Make sure the attributes + // needed to parse the HTML back (the type marker and non-default + // props, in the same `data-*` convention `propsToAttributes` reads) + // are present even when the block's render didn't add them. + // Author-set attributes win. + fillContainerAttributes( + ret.dom as HTMLElement, + block.type!, + props, + editor.schema.blockSchema[block.type as any].propSchema, + ); + } elementFragment.append(ret.dom); if (nestingLevel > 0) { (ret.dom as HTMLElement).setAttribute( @@ -297,15 +314,23 @@ function serializeBlock< // round trip, we fill their content with a placeholder character that the // parser strips out again (see `EMPTY_BLOCK_PLACEHOLDER`). // - // Only applies to blocks that hold inline content: containers (columns, - // tables) fill their `contentDOM` with child blocks later on, and code - // blocks would turn the placeholder into literal content. + // Only applies to blocks that hold inline content: pure containers + // (columns, tables) fill their `contentDOM` with child blocks later on, + // and code blocks would turn the placeholder into literal content. + // + // A container that has its own content needs the placeholder for a + // second reason, and its outer node isn't `inlineContent` so it needs + // its own check. That node's content is `__content + // __children`. A parser reading a block element first has nothing + // to satisfy the content node with, so it cannot open the children node, + // and every child lands after the container instead of inside it. A + // leading text node opens the content node. const blockNodeType = editor.pmSchema.nodes[block.type as any]; - if ( - blockNodeType?.inlineContent && - !blockNodeType.spec.code && - ret.contentDOM.childNodes.length === 0 - ) { + const blockConfig = editor.schema.blockSchema[block.type as any]; + const needsPlaceholder = blockNodeType?.inlineContent + ? !blockNodeType.spec.code + : isContainerType(blockConfig) && blockConfig.content !== "none"; + if (needsPlaceholder && ret.contentDOM.childNodes.length === 0) { ret.contentDOM.appendChild(doc.createTextNode(EMPTY_BLOCK_PLACEHOLDER)); } } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..37f1cdfce5 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -5,8 +5,10 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { BlockSchema, InlineContentSchema, + isContainerType, StyleSchema, } from "../../../../schema/index.js"; +import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -126,6 +128,30 @@ export function serializeInlineContentInternalHTML< return fragment; } +/** + * Appends the two region elements a content-bearing container's generated + * `__content` / `__children` nodes render, so that internal HTML matches what + * the editor puts in the DOM (and what the generated parse rules match). + */ +function createContainerRegions( + contentDOM: HTMLElement, + blockType: string, + options?: { document?: Document }, +): { content: HTMLElement; children: HTMLElement } { + const doc = options?.document ?? document; + + const content = doc.createElement("div"); + content.className = "bn-inline-content"; + content.setAttribute("data-content-type", blockType); + + const children = doc.createElement("div"); + children.setAttribute("data-children-of", blockType); + + contentDOM.append(content, children); + + return { content, children }; +} + function serializeBlock< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -159,7 +185,29 @@ function serializeBlock< editor as any, ); - if (ret.contentDOM && block.content) { + // Asked of the block config rather than of its ProseMirror node. A + // container that has its own content compiles to an outer node holding a + // separate children node, so the outer node is not itself a + // `childContainer`, but the block is still a container and still owns its + // outer DOM. + const blockConfig = editor.schema.blockSchema[block.type as any]; + const isContainer = isContainerType(blockConfig); + + // A container with its own content holds two generated nodes, `__content` + // and `__children`, and so renders two region elements inside its content + // host. Without them only the first child parses back inside the + // container. ProseMirror has to invent the `__children` wrapping while + // parsing, and `blockContainer`'s `blockOuter` skip rule re-syncs the + // parse context to the container afterwards, closing that invented + // wrapping again. + const regions = + isContainer && ret.contentDOM && blockConfig.content !== "none" + ? createContainerRegions(ret.contentDOM, block.type!, options) + : undefined; + + const contentHost = regions?.content ?? ret.contentDOM; + + if (contentHost && block.content) { const ic = serializeInlineContentInternalHTML( editor, block.content as any, // TODO @@ -167,12 +215,34 @@ function serializeBlock< block.type, options, ); - ret.contentDOM.appendChild(ic); + contentHost.appendChild(ic); } - const pmType = editor.pmSchema.nodes[block.type as any]; + if (isContainer) { + // Container blocks own their outer DOM. Internal HTML must round-trip + // losslessly, so make sure the attributes the generated parse rules read + // (the type marker and non-default props as `data-*`) are present even + // when the block's render didn't add them. Author-set attributes win. + fillContainerAttributes( + ret.dom as HTMLElement, + block.type!, + props, + blockConfig.propSchema, + ); - if (pmType.isInGroup("bnBlock")) { + // A pure container holds its children directly in its `contentDOM`; one + // with its own content puts them in the children region, after the + // content region, matching the order the document model imposes. + const childrenHost = regions?.children ?? ret.contentDOM; + // Mark where the children live so the container's round-trip parse rule + // can scope itself to this element (`contentElement` in `getParseRules`). + // A render is free to put non-content UI text elsewhere in its DOM + // (button labels, captions, ...), and without the marker that text would + // parse back as document content. Content-bearing containers get the + // marker from `createContainerRegions`. + if (!regions && ret.contentDOM) { + ret.contentDOM.setAttribute("data-children-of", block.type!); + } if (block.children && block.children.length > 0) { const fragment = serializeBlocks( editor, @@ -181,7 +251,21 @@ function serializeBlock< options, ); - ret.contentDOM?.append(fragment); + childrenHost?.append(fragment); + } + return ret.dom; + } + + // Legacy path for `@blocknote/xl-multi-column`'s hand-written PM nodes, + // which sit in the `bnBlock` group but have no `children` config. They own + // their outer DOM and hold their children directly in their `contentDOM`. + // Removed once multi-column is migrated onto the container API. + const pmType = editor.pmSchema.nodes[block.type!]; + if (pmType?.isInGroup("bnBlock")) { + if (block.children && block.children.length > 0) { + ret.contentDOM?.append( + serializeBlocks(editor, block.children, serializer, options), + ); } return ret.dom; } diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 04ed789c98..04ad1c6ba3 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,6 +1,12 @@ import { Node, ResolvedPos } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; +import { + CHILD_CONTAINER_GROUP, + CONTAINER_CONTENT_GROUP, + isContentContainerNode, +} from "../schema/blocks/children.js"; + type SingleBlockInfo = { node: Node; beforePos: number; @@ -20,13 +26,17 @@ export type BlockInfo = { blockNoteType: string; } & ( | { - // In case we're not dealing with a BlockContainer, we're dealing with a "wrapper node" (like a Column or ColumnList), so it will always have children + // A container block (Column, ColumnList, a custom container): its own + // node holds its children directly, and it has no `blockContent` of + // its own. /** - * The Prosemirror node that holds block.children. For non-blockContainer, this node will be the same as bnBlock. + * The Prosemirror node that holds block.children. For a container block, + * this node is the same as bnBlock. */ childContainer: SingleBlockInfo; - isBlockContainer: false; + blockContent?: undefined; + isWrappedBlock: false; } | { /** @@ -38,9 +48,16 @@ export type BlockInfo = { */ blockContent: SingleBlockInfo; /** - * Whether bnBlock is a blockContainer node + * Whether `bnBlock` wraps the block's content in a node of its own: + * either a `blockContainer` (an ordinary block wrapped for nesting), or + * a container block that has its own content as well as children. Both + * have the same shape: a content node, then an optional child container. + * + * Note this is roughly the opposite of "is a container block": a + * column has `isWrappedBlock: false`. Sites that need "is this literally + * a `blockContainer`" should read `bnBlock.node.type.name`. */ - isBlockContainer: true; + isWrappedBlock: true; } ); @@ -183,48 +200,48 @@ export function getBlockInfoWithManualOffset( afterPos: bnBlockAfterPos, }; - if (bnBlockNode.type.name === "blockContainer") { + // A container block that has its own content is shaped like a + // `blockContainer`: a content node followed by a node holding its children. + // Discriminating on that shape rather than on the node's name lets every + // branch written against `blockContainer` cover it too. + const isContentContainer = isContentContainerNode(bnBlockNode); + + if (bnBlockNode.type.name === "blockContainer" || isContentContainer) { let blockContent: SingleBlockInfo | undefined; - let blockGroup: SingleBlockInfo | undefined; + let childContainer: SingleBlockInfo | undefined; bnBlockNode.forEach((node, offset) => { - if (node.type.spec.group === "blockContent") { - // console.log(beforePos, offset); - const blockContentNode = node; - const blockContentBeforePos = bnBlockBeforePos + offset + 1; - const blockContentAfterPos = blockContentBeforePos + node.nodeSize; - - blockContent = { - node: blockContentNode, - beforePos: blockContentBeforePos, - afterPos: blockContentAfterPos, - }; - } else if (node.type.name === "blockGroup") { - const blockGroupNode = node; - const blockGroupBeforePos = bnBlockBeforePos + offset + 1; - const blockGroupAfterPos = blockGroupBeforePos + node.nodeSize; + const beforePos = bnBlockBeforePos + offset + 1; + const afterPos = beforePos + node.nodeSize; - blockGroup = { - node: blockGroupNode, - beforePos: blockGroupBeforePos, - afterPos: blockGroupAfterPos, - }; + if ( + node.type.spec.group === "blockContent" || + node.type.isInGroup(CONTAINER_CONTENT_GROUP) + ) { + blockContent = { node, beforePos, afterPos }; + } else if (node.type.isInGroup(CHILD_CONTAINER_GROUP)) { + childContainer = { node, beforePos, afterPos }; } }); if (!blockContent) { throw new Error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `blockContainer node does not contain a blockContent node in its children: ${bnBlockNode}`, + `${bnBlockNode.type.name} node does not contain a content node in its children: ${bnBlockNode}`, ); } return { - isBlockContainer: true, + isWrappedBlock: true, bnBlock, blockContent, - childContainer: blockGroup, - blockNoteType: blockContent.node.type.name, + childContainer, + // A `blockContainer` is a generic wrapper, so its type comes from the + // content node inside it. A container block's node type is the block + // type itself. + blockNoteType: isContentContainer + ? bnBlockNode.type.name + : blockContent.node.type.name, }; } else { if (!bnBlock.node.type.isInGroup("childContainer")) { @@ -235,7 +252,7 @@ export function getBlockInfoWithManualOffset( } return { - isBlockContainer: false, + isWrappedBlock: false, bnBlock: bnBlock, childContainer: bnBlock, blockNoteType: bnBlock.node.type.name, diff --git a/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts index 828894cf1d..2186fefe7d 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -652,8 +652,8 @@ describe("getBlocksChangedByTransaction - ranged optimization", () => { throw new Error("block not found"); } const info = getBlockInfo(posInfo); - if (!info.isBlockContainer) { - throw new Error("expected a block container"); + if (!info.isWrappedBlock) { + throw new Error("expected a wrapped block"); } // Adding a mark produces an AddMarkStep, whose StepMap is empty — the case // getChangedRange has to recover from the step's own from/to. diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index af5c0ba1b7..a3beec552b 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -1,4 +1,11 @@ -import { Attrs, Fragment, Mark, Node, Schema } from "@tiptap/pm/model"; +import { + Attrs, + Fragment, + Mark, + Node, + NodeType, + Schema, +} from "@tiptap/pm/model"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { @@ -16,10 +23,23 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +// `isContainerNode` comes from `children.js` directly (rather than via its +// `fixContainer.js` re-export) because `fixContainer.js` imports the seeding +// machinery below; going through it would create an import cycle. +import { + getChildrenConfig, + getContentContainerNodeTypes, + isContainerNode, + resolveChildren, +} from "../../schema/blocks/children.js"; import { getColspan, isPartialTableCell } from "../../util/table.js"; import { UnreachableCaseError } from "../../util/typescript.js"; import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js"; -import { getStyleSchema, isPlainContentNodeType } from "../pmUtil.js"; +import { + getBlockSchema, + getStyleSchema, + isPlainContentNodeType, +} from "../pmUtil.js"; /** * Convert a StyledText inline element to a @@ -334,6 +354,175 @@ function blockOrInlineContentToContentNode( return contentNode; } +const EMPTY_SEEDING: ReadonlySet = new Set(); + +function unwrapsWhenEmptied(blockType: string, schema: Schema): boolean { + const blockConfig = getBlockSchema(schema)[blockType]; + const children = blockConfig ? getChildrenConfig(blockConfig) : undefined; + return !!children && resolveChildren(children).whenEmptied === "unwrap"; +} + +// `createAndFill` produces nodes with `id: null`; patch them before use. +function withGeneratedIds(node: Node): Node { + if (node.isText) { + return node; + } + + const children: Node[] = []; + let childChanged = false; + node.forEach((child) => { + const next = withGeneratedIds(child); + childChanged ||= next !== child; + children.push(next); + }); + + const needsId = node.type.isInGroup("bnBlock") && node.attrs.id === null; + if (!needsId && !childChanged) { + return node; + } + + return node.type.create( + needsId ? { ...node.attrs, id: UniqueID.options.generateID() } : node.attrs, + childChanged ? Fragment.from(children) : node.content, + node.marks, + ); +} + +function seedDefaultChildren( + blockType: string, + schema: Schema, + styleSchema: StyleSchema, + seedingTypes: ReadonlySet, +): Node[] | undefined { + const blockSchemaConfig = getBlockSchema(schema)[blockType]; + const childrenConfig = blockSchemaConfig + ? getChildrenConfig(blockSchemaConfig) + : undefined; + + if (!childrenConfig) { + return undefined; + } + + const defaultChildren = resolveChildren(childrenConfig).default; + if (!defaultChildren || defaultChildren.length === 0) { + return undefined; + } + + if (seedingTypes.has(blockType)) { + throw new Error( + `Seeding "${blockType}" ends up seeding it again (${[...seedingTypes, blockType].join(" -> ")}). ` + + "Give the cyclic default explicit children, or remove the self-reference.", + ); + } + + const nextSeeding = new Set(seedingTypes).add(blockType); + return defaultChildren.map((child) => + blockToNode( + child as PartialBlock, + schema, + styleSchema, + nextSeeding, + ), + ); +} + +/** + * The nodes `whenEmptied: "refill"` appends when a container's non-empty + * children drop below `min`: the unconsumed tail of its `default` + * (`default[from..min-1]`), each converted exactly like an inserted block. + * Empty when the container has no `default`; the caller pads any remainder + * with empty fill. + */ +export function seedRefillChildren( + blockType: string, + schema: Schema, + from: number, + min: number, +): Node[] { + const blockConfig = getBlockSchema(schema)[blockType]; + const children = blockConfig ? getChildrenConfig(blockConfig) : undefined; + const defaultChildren = children + ? resolveChildren(children).default + : undefined; + if (!defaultChildren) { + return []; + } + + return defaultChildren + .slice(from, min) + .map((child) => blockToNode(child as PartialBlock, schema)); +} + +function partialContentToInlineNodes( + block: PartialBlock, + contentNodeName: string, + schema: Schema, + styleSchema: StyleSchema, +): Node[] { + if (block.content === undefined) { + return []; + } + if (typeof block.content === "string" || Array.isArray(block.content)) { + return inlineContentToNodes( + typeof block.content === "string" ? [block.content] : block.content, + schema, + contentNodeName, + styleSchema, + ); + } + + throw new Error( + `Block "${block.type}" cannot have content of type "${block.content.type}".`, + ); +} + +function createContainerChildrenNode( + blockType: string, + type: NodeType, + schema: Schema, + styleSchema: StyleSchema, + seedingTypes: ReadonlySet, + attrs: Attrs | null = null, +): Node { + const seeded = seedDefaultChildren( + blockType, + schema, + styleSchema, + seedingTypes, + ); + + if (!seeded && unwrapsWhenEmptied(blockType, schema)) { + return type.create(attrs); + } + + const node = type.createAndFill(attrs, seeded); + if (!node) { + throw new Error( + `Cannot create block "${blockType}": its \`default\` children don't fit its \`children\` config ` + + `(it accepts \`${type.spec.content}\`).`, + ); + } + + return node; +} + +// Skips `createAndFill` for unwrap-on-empty containers (fill would be undone +// by the next repair pass) and for unfittable content (let `node.check()` +// report it). +function createExplicitChildrenNode( + blockType: string, + type: NodeType, + schema: Schema, + children: Node[], + attrs: Attrs | null = null, +): Node { + if (unwrapsWhenEmptied(blockType, schema)) { + return type.create(attrs, children); + } + + return type.createAndFill(attrs, children) ?? type.create(attrs, children); +} + /** * Converts a BlockNote block to a Prosemirror node. */ @@ -341,6 +530,7 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + seedingTypes: ReadonlySet = EMPTY_SEEDING, ) { let id = block.id; @@ -352,7 +542,7 @@ export function blockToNode( if (block.children) { for (const child of block.children) { - children.push(blockToNode(child, schema, styleSchema)); + children.push(blockToNode(child, schema, styleSchema, seedingTypes)); } } @@ -360,9 +550,11 @@ export function blockToNode( !block.type || // can happen if block.type is not defined (this should create the default node) schema.nodes[block.type].isInGroup("blockContent"); - if (isBlockContent) { - // Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer + const contentContainerTypes = block.type + ? getContentContainerNodeTypes(schema, block.type) + : undefined; + if (isBlockContent) { const contentNode = blockOrInlineContentToContentNode( block, schema, @@ -381,9 +573,43 @@ export function blockToNode( }, groupNode ? [contentNode, groupNode] : contentNode, ); - } else if (schema.nodes[block.type].isInGroup("bnBlock")) { - // `create` (not `createChecked`) so partial container blocks pass through; - // callers that mutate the doc validate via `node.check()` before inserting. + } else if (contentContainerTypes) { + // A container with its own content: the content and the children each get + // a node of their own, since a ProseMirror node holds either inline + // content or block content but never both. + const { contentType, childrenType } = contentContainerTypes; + + const contentNode = contentType.createChecked( + null, + partialContentToInlineNodes(block, contentType.name, schema, styleSchema), + ); + + const childrenNode = + block.children !== undefined + ? createExplicitChildrenNode(block.type, childrenType, schema, children) + : createContainerChildrenNode( + block.type, + childrenType, + schema, + styleSchema, + seedingTypes, + ); + + return withGeneratedIds( + schema.nodes[block.type].create({ id: id, ...block.props }, [ + contentNode, + childrenNode, + ]), + ); + } else if ( + schema.nodes[block.type].isInGroup("bnBlock") && + !getChildrenConfig(schema.nodes[block.type].spec.blockConfig ?? {}) + ) { + // Legacy path for `@blocknote/xl-multi-column`'s hand-written PM nodes, + // which sit in the `bnBlock` group but have no `children` config. Plain + // `create` (not `createChecked` and no fill), so invalid structures + // surface via `node.check()` when the caller mutates the doc. Removed + // once multi-column is migrated onto the container API. return schema.nodes[block.type].create( { id: id, @@ -391,6 +617,26 @@ export function blockToNode( }, children, ); + } else if (isContainerNode(schema.nodes[block.type])) { + const type = schema.nodes[block.type]; + const attrs = { id: id, ...block.props }; + + if (block.children !== undefined) { + return withGeneratedIds( + createExplicitChildrenNode(block.type, type, schema, children, attrs), + ); + } + + return withGeneratedIds( + createContainerChildrenNode( + block.type, + type, + schema, + styleSchema, + seedingTypes, + attrs, + ), + ); } else { throw new Error( `block type ${block.type} doesn't match blockContent or bnBlock group`, diff --git a/packages/core/src/api/nodeConversions/contentContainers.test.ts b/packages/core/src/api/nodeConversions/contentContainers.test.ts new file mode 100644 index 0000000000..22f48bbdbf --- /dev/null +++ b/packages/core/src/api/nodeConversions/contentContainers.test.ts @@ -0,0 +1,307 @@ +// @vitest-environment node +import type { Node, Schema } from "@tiptap/pm/model"; +import { afterAll, beforeAll, 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"; +import { + getBottomNestedBlockInfo, + getPrevBlockInfo, +} from "../blockManipulation/commands/mergeBlocks/mergeBlocks.js"; +import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js"; +import { blockToNode } from "./blockToNode.js"; +import { nodeToBlock } from "./nodeToBlock.js"; + +// A container block with its own inline content: the toggle shape. Its node +// holds a generated content node and a generated children node, so its +// `Block` JSON is identical to a nested regular block's. +// This suite works on nodes and a headless editor's schema, so nothing is +// rendered. `render` only has to exist for the spec to be accepted. +const notRendered = () => { + throw new Error("not rendered in this suite"); +}; + +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: { open: { default: true } }, + content: "inline", + children: { allow: "any" }, + }, + { render: notRendered }, +)(); + +// The same, but allowed to have no children at all. +const OptionalToggle = createBlockSpec( + { + type: "optionalToggle" as const, + propSchema: {}, + content: "inline", + children: { allow: "any", min: 0 }, + }, + { render: notRendered }, +)(); + +// A pure container, to pair each content-bearing container against. +const containerSpec = ( + type: TName, + config: { content: "none" | "inline"; children: any; placement?: any }, +) => + createBlockSpec( + { + type, + propSchema: {}, + ...config, + } as any, + { render: notRendered }, + )(); + +// Pairs of (pure container, content-bearing container) sharing one `children` +// config. Their content expressions must match. The same generator runs for +// both, so every `allow`/`min`/`max` option enforces identically. +const CHILDREN_CONFIGS = { + Default: { allow: "any" }, + Bounded: { allow: "any", min: 0, max: 3 }, + Restricted: { allow: ["cell"], min: 2 }, +} as const; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + toggle: Toggle, + optionalToggle: OptionalToggle, + cell: containerSpec("cell", { + content: "none", + children: { allow: "any" }, + }), + pureDefault: containerSpec("pureDefault", { + content: "none", + children: CHILDREN_CONFIGS.Default, + }), + contentDefault: containerSpec("contentDefault", { + content: "inline", + children: CHILDREN_CONFIGS.Default, + }), + pureBounded: containerSpec("pureBounded", { + content: "none", + children: CHILDREN_CONFIGS.Bounded, + }), + contentBounded: containerSpec("contentBounded", { + content: "inline", + children: CHILDREN_CONFIGS.Bounded, + }), + pureRestricted: containerSpec("pureRestricted", { + content: "none", + children: CHILDREN_CONFIGS.Restricted, + }), + contentRestricted: containerSpec("contentRestricted", { + content: "inline", + children: CHILDREN_CONFIGS.Restricted, + }), + } as const, +}); + +let editor: BlockNoteEditor; +let pmSchema: Schema; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }) as any; + pmSchema = editor.pmSchema; +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +// `nodeToBlock(node, doc)` takes the containing document as its second +// argument, so blocks built in isolation need a minimal valid doc around them. +const wrapInDoc = (...blocks: Node[]): Node => + pmSchema.nodes["doc"].createChecked( + null, + pmSchema.nodes["blockGroup"].createChecked(null, blocks), + ); + +describe("content-bearing container: node shape", () => { + it("builds a content node and a children node inside the block's node", () => { + const node = blockToNode( + { + id: "t-0", + type: "toggle", + props: { open: false }, + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + + expect(node.type.name).toBe("toggle"); + expect(node.type.isInGroup("bnBlock")).toBe(true); + expect(node.type.isInGroup("blockGroupChild")).toBe(true); + // The block's node is not itself a child container. The generated + // children node is. + expect(node.type.isInGroup("childContainer")).toBe(false); + // All props (and the id) live on the outer node, not the generated ones. + expect(node.attrs.id).toBe("t-0"); + expect(node.attrs.open).toBe(false); + + expect(node.childCount).toBe(2); + + const contentNode = node.child(0); + expect(contentNode.type.name).toBe("toggle__content"); + expect(contentNode.type.isInGroup("containerContent")).toBe(true); + // Deliberately not in `blockContent`. `blockContainer` accepts that + // group, so a paste could otherwise produce + // `blockContainer > toggle__content`. + expect(contentNode.type.isInGroup("blockContent")).toBe(false); + expect(contentNode.textContent).toBe("Title"); + expect("open" in contentNode.attrs).toBe(false); + expect("id" in contentNode.attrs).toBe(false); + + const childrenNode = node.child(1); + expect(childrenNode.type.name).toBe("toggle__children"); + expect(childrenNode.type.isInGroup("childContainer")).toBe(true); + expect(childrenNode.childCount).toBe(1); + expect(childrenNode.child(0).type.name).toBe("blockContainer"); + + expect(() => node.check()).not.toThrow(); + }); +}); + +describe("content-bearing container: Block JSON", () => { + it("round-trips identically to a nested regular block", () => { + const toggleNode = blockToNode( + { + id: "b-0", + type: "toggle", + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + const paragraphNode = blockToNode( + { + id: "b-0", + type: "paragraph", + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + + const toggleBlock = nodeToBlock(toggleNode, wrapInDoc(toggleNode)); + const paragraphBlock = nodeToBlock(paragraphNode, wrapInDoc(paragraphNode)); + + // Everything but the block's own type and props is structurally identical + // to the nested paragraph's. + const { type: _toggleType, props: _toggleProps, ...toggle } = toggleBlock; + const { + type: _paragraphType, + props: _paragraphProps, + ...paragraph + } = paragraphBlock; + expect(toggle).toEqual(paragraph); + + expect(toggleBlock).toEqual({ + id: "b-0", + type: "toggle", + props: { open: true }, + content: [{ type: "text", text: "Title", styles: {} }], + children: [ + { + id: "c-0", + type: "paragraph", + props: (paragraphBlock.children as any[])[0].props, + content: [{ type: "text", text: "Child", styles: {} }], + children: [], + }, + ], + }); + }); + + it("round-trips an empty container with no content", () => { + const node = blockToNode( + { id: "t-0", type: "optionalToggle", children: [] } as any, + pmSchema, + ); + const block = nodeToBlock(node, wrapInDoc(node)); + + expect(block.content).toEqual([]); + expect(block.children).toEqual([]); + }); +}); + +describe("content-bearing container: BlockInfo", () => { + it("is a wrapped block, with the content and children nodes resolved", () => { + const node = blockToNode( + { + id: "t-0", + type: "toggle", + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + + const info = getBlockInfoWithManualOffset(node, 0); + + // Structurally identical to a `blockContainer`, so every keyboard branch + // written against one covers this too. + expect(info.isWrappedBlock).toBe(true); + expect(info.blockContent!.node.type.name).toBe("toggle__content"); + expect(info.childContainer!.node.type.name).toBe("toggle__children"); + // The type comes from the outer node. A `blockContainer` is a generic + // wrapper, but a container block is its own type. + expect(info.blockNoteType).toBe("toggle"); + + // Positions are those of the nodes themselves. + expect(info.bnBlock.beforePos).toBe(0); + expect(info.blockContent!.beforePos).toBe(1); + expect(info.blockContent!.afterPos).toBe(1 + node.child(0).nodeSize); + expect(info.childContainer!.beforePos).toBe(1 + node.child(0).nodeSize); + }); + + it("handles a container with zero children", () => { + const paragraphNode = blockToNode( + { id: "p-0", type: "paragraph", content: "Before" } as any, + pmSchema, + ); + const toggleNode = blockToNode( + { + id: "t-0", + type: "optionalToggle", + content: "Title", + children: [], + } as any, + pmSchema, + ); + const doc = wrapInDoc(paragraphNode, toggleNode); + + const togglePos = 1 + paragraphNode.nodeSize; + const info = getBlockInfoWithManualOffset(toggleNode, togglePos); + expect(info.childContainer!.node.childCount).toBe(0); + + // An empty child container has no last child to descend into, so the + // block itself is the bottom one. + expect(() => getBottomNestedBlockInfo(doc, info)).not.toThrow(); + expect(getBottomNestedBlockInfo(doc, info).bnBlock.node).toBe(toggleNode); + + expect(() => getPrevBlockInfo(doc, togglePos)).not.toThrow(); + expect(getPrevBlockInfo(doc, togglePos)!.blockNoteType).toBe("paragraph"); + }); +}); + +describe("content-bearing container: children content expression", () => { + it.each(Object.keys(CHILDREN_CONFIGS))( + "%s compiles the same as it does for a pure container", + (name) => { + const pure = pmSchema.nodes[`pure${name}`]; + const contentBearing = pmSchema.nodes[`content${name}__children`]; + + expect(contentBearing).toBeDefined(); + expect(contentBearing.spec.content).toBe(pure.spec.content); + }, + ); +}); diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..26a0a82b54 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,60 +1,115 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + getChildrenConfig, + isContainerNode, + isContentContainerNode, + isPlaceableAnywhere, + resolveChildren, +} from "../../schema/blocks/children.js"; +import { getBlockSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; -/** - * Converts all Blocks within a fragment to BlockNote blocks. - */ +function getContainerChildren( + node: Node, +): { blockType: string; children: Node } | undefined { + if (isContentContainerNode(node)) { + return { blockType: node.type.name, children: node.lastChild! }; + } + if (isContainerNode(node.type)) { + return { blockType: node.type.name, children: node }; + } + return undefined; +} + +function isSelfContainedContainer(node: Node): boolean { + const container = getContainerChildren(node); + if (!container) { + return false; + } + const blockConfig = + getBlockSchema(node.type.schema)[container.blockType] ?? {}; + const children = getChildrenConfig(blockConfig); + if (!children) { + return false; + } + return ( + isPlaceableAnywhere(blockConfig) && + container.children.childCount >= resolveChildren(children).min + ); +} + +function containerContentAsBlock< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, +>(node: Node, root: Node): BlockNoDefaults | undefined { + if (!isContentContainerNode(node) || node.firstChild!.content.size === 0) { + return undefined; + } + const schema = node.type.schema; + const paragraph = schema.nodes["paragraph"].create( + null, + node.firstChild!.content, + ); + + return nodeToBlock( + schema.nodes["blockContainer"].createAndFill(null, paragraph)!, + root, + ); +} + export function fragmentToBlocks< B extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >(fragment: Fragment) { - // first convert selection to blocknote-style blocks, and then - // pass these to the exporter const blocks: BlockNoDefaults[] = []; + + const pushFlattened = (node: Node, root: Node) => { + const container = getContainerChildren(node); + if (container && !isSelfContainedContainer(node)) { + const content = containerContentAsBlock(node, root); + if (content) { + blocks.push(content); + } + container.children.forEach((child) => pushFlattened(child, root)); + return; + } + blocks.push(nodeToBlock(node, root)); + }; + fragment.descendants((node) => { if (node.type.name === "blockContainer") { if (node.firstChild?.type.name === "blockGroup") { - // selection started within a block group - // in this case the fragment starts with: - // - // - // - // - // - // - // - // instead of: - // - // - // - // - // - // - // - // - // so we don't need to serialize this block, just descend into the children of the blockGroup return true; } } - if (node.type.name === "columnList" && node.childCount === 1) { - // column lists with a single column should be flattened (not the entire column list has been selected) - node.firstChild?.forEach((child) => { - blocks.push(nodeToBlock(child, node)); - }); - return false; - } - if (node.type.isInGroup("bnBlock")) { - blocks.push(nodeToBlock(node, node)); - // don't descend into children, as they're already included in the block returned by nodeToBlock + // Legacy path for `@blocknote/xl-multi-column`'s hand-written PM nodes, + // which have no `children` config: flatten only a single-column + // columnList (not the entire column list has been selected), and keep + // every other column list intact, as before. Removed once multi-column + // is migrated onto the container API. + const blockConfig = getBlockSchema(node.type.schema)[node.type.name]; + if (isContainerNode(node.type) && !getChildrenConfig(blockConfig ?? {})) { + if (node.type.name === "columnList" && node.childCount === 1) { + node.firstChild?.forEach((child) => { + blocks.push(nodeToBlock(child, node)); + }); + return false; + } + blocks.push(nodeToBlock(node, node)); + return false; + } + + pushFlattened(node, node); return false; } return true; diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index fead006657..527b8a01f3 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,5 +1,7 @@ import { Mark, Node, Slice } from "@tiptap/pm/model"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; +import { isContentContainerNode } from "../../schema/blocks/children.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { BlockSchema, @@ -430,7 +432,7 @@ export function nodeToBlock< const props: any = {}; for (const [attr, value] of Object.entries({ ...node.attrs, - ...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}), + ...(blockInfo.isWrappedBlock ? blockInfo.blockContent.node.attrs : {}), })) { const propSchema = blockSpec.propSchema; @@ -452,7 +454,7 @@ export function nodeToBlock< let content: Block["content"]; if (blockConfig.content === "inline") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } content = contentNodeToInlineContent( @@ -461,7 +463,7 @@ export function nodeToBlock< styleSchema, ); } else if (blockConfig.content === "table") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } content = contentNodeToTableContent( @@ -470,7 +472,7 @@ export function nodeToBlock< styleSchema, ); } else if (blockConfig.content === "plain") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } // Plain content is a single unstyled text item; an empty block is an @@ -533,6 +535,25 @@ export function docToBlocks< * * */ +/** + * The node holding a bnBlock's children when that node holds them directly: + * the container itself for a pure container, its generated `__children` node + * for a container that also has its own content. `undefined` for a + * `blockContainer`, whose children live in an optional `blockGroup`. + */ +function getChildrenHolder(node: Node): Node | undefined { + if (isContentContainerNode(node)) { + // The children live in the generated `__children` node, which is the + // last child. When a slice boundary cuts through the container's own + // `__content`, the `__children` node is absent from the slice; its last + // (and only) child is then the `__content` node, which holds no children + // of its own. + const lastChild = node.lastChild; + return lastChild && isContainerNode(lastChild.type) ? lastChild : undefined; + } + return isContainerNode(node.type) ? node : undefined; +} + export function prosemirrorSliceToSlicedBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -563,7 +584,9 @@ export function prosemirrorSliceToSlicedBlocks< blockCutAtStart: string | undefined; blockCutAtEnd: string | undefined; } { - if (node.type.name !== "blockGroup") { + // Both `blockGroup` and container nodes (columnList, column, callout, + // ...) hold bnBlock children directly, so both can be processed here. + if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) { throw new Error("unexpected"); } const blocks: Block[] = []; @@ -571,6 +594,69 @@ export function prosemirrorSliceToSlicedBlocks< let blockCutAtEnd: string | undefined; node.forEach((blockContainer, _offset, index) => { + const isFirstBlock = index === 0; + const isLastBlock = index === node.childCount - 1; + + const childrenHolder = getChildrenHolder(blockContainer); + if (childrenHolder) { + // A container child. When the slice boundary is open inside it, the + // selection covers part of its children, so skip the container + // wrapper and splice in the included children (mirroring the + // nested-blockGroup descent below). When fully enclosed, convert it + // wholesale. + const openAtStart = isFirstBlock && openStart > 0; + const openAtEnd = isLastBlock && openEnd > 0; + + // A container that also has its own content keeps its children one + // node deeper, in its generated `__children` node. + const depthToChildren = childrenHolder === blockContainer ? 1 : 2; + + if (openAtStart || openAtEnd) { + const ret = processNode( + childrenHolder, + openAtStart ? Math.max(0, openStart - depthToChildren) : 0, + openAtEnd ? Math.max(0, openEnd - depthToChildren) : 0, + ); + if (openAtStart) { + blockCutAtStart = ret.blockCutAtStart; + } + if (openAtEnd) { + blockCutAtEnd = ret.blockCutAtEnd; + } + blocks.push(...ret.blocks); + return; + } + + blocks.push( + nodeToBlock(blockContainer, slice.content.firstChild!) as Block< + BSchema, + I, + S + >, + ); + return; + } + + if (isContentContainerNode(blockContainer)) { + // A content-bearing container whose `__children` node is absent from + // the slice: the boundary cut through its own `__content`, so it has + // no children to splice in. Convert it wholesale (with its cut + // content), recording the cut boundary so callers know the block was + // sliced. + const block = nodeToBlock( + blockContainer, + slice.content.firstChild!, + ) as Block; + if (isFirstBlock && openStart > 0) { + blockCutAtStart = block.id; + } + if (isLastBlock && openEnd > 0) { + blockCutAtEnd = block.id; + } + blocks.push(block); + return; + } + if (blockContainer.type.name !== "blockContainer") { throw new Error("unexpected"); } @@ -583,9 +669,6 @@ export function prosemirrorSliceToSlicedBlocks< ); } - const isFirstBlock = index === 0; - const isLastBlock = index === node.childCount - 1; - if (blockContainer.firstChild!.type.name === "blockGroup") { // this is the parent where a selection starts within one of its children, // e.g.: diff --git a/packages/core/src/api/pmUtil.ts b/packages/core/src/api/pmUtil.ts index 17ed2aa943..544ea35591 100644 --- a/packages/core/src/api/pmUtil.ts +++ b/packages/core/src/api/pmUtil.ts @@ -2,6 +2,7 @@ import type { Node, NodeType, Schema } from "prosemirror-model"; import { Transform } from "prosemirror-transform"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js"; +import { blockTypeOfContainerContentNode } from "../schema/blocks/children.js"; import type { BlockSchema } from "../schema/blocks/types.js"; import type { InlineContentSchema } from "../schema/inlineContent/types.js"; import type { StyleSchema } from "../schema/styles/types.js"; @@ -67,7 +68,16 @@ export function isPlainContentNodeType( schema: Schema, nodeType: NodeType, ): boolean { - if (getBlockSchema(schema)[nodeType.name]?.content === "plain") { + const blockSchema = getBlockSchema(schema); + // A content-bearing container's content lives in a generated node, so it + // isn't a key in the block schema. Resolve it back to the block it belongs + // to. + const blockType = + blockTypeOfContainerContentNode(nodeType.name) ?? nodeType.name; + + if ( + (blockSchema[nodeType.name] ?? blockSchema[blockType])?.content === "plain" + ) { return true; } diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts index 0b33335788..71f3ecaf35 100644 --- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts +++ b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts @@ -11,7 +11,7 @@ export const handleEnter = (editor: BlockNoteEditor) => { }; }); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index b268598218..5e52c8c76f 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -32,7 +32,7 @@ function calculateListItemIndex( // Fast path: previous sibling already in cache const blockInfo = getBlockInfo({ posBeforeNode: pos, node }); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } const prevBlock = tr.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore; @@ -80,7 +80,7 @@ function calculateListItemIndex( posBeforeNode: lastInChain.pos, node: lastInChain.node, }); - if (!lastInfo.isBlockContainer) { + if (!lastInfo.isWrappedBlock) { throw new Error("impossible"); } const predecessorNode = tr.doc.resolve(lastInfo.bnBlock.beforePos).nodeBefore; diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts index 12e558a453..578d3aae8b 100644 --- a/packages/core/src/blocks/utils/listItemEnterHandler.ts +++ b/packages/core/src/blocks/utils/listItemEnterHandler.ts @@ -14,7 +14,7 @@ export const handleEnter = ( }; }); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..5f0f34a746 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -7,6 +7,7 @@ import { } from "@tiptap/core"; import { type Command, type Transaction } from "@tiptap/pm/state"; import { Node, Schema } from "prosemirror-model"; +import type { BlockPlacement } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; import { @@ -37,6 +38,7 @@ import type { StyleSchema, StyleSpecs, } from "../schema/index.js"; +import { assertContainerSchemaInvariants } from "../schema/blocks/assertSchemaInvariants.js"; import "../style.css"; import { mergeCSSClasses } from "../util/browser.js"; import { EventEmitter } from "../util/EventEmitter.js"; @@ -558,6 +560,13 @@ export class BlockNoteEditor< tiptapOptions.parseOptions, ); + // `blockToNode` is lenient, and `createDocument` builds from JSON + // without validating, so without this check the initial document is + // never validated. A container below its `children.min` would reach + // the editor and stay there, while the same blocks passed to + // `insertBlocks` would have been rejected. + doc.check(); + this._tiptapEditor = new TiptapEditor({ ...tiptapOptions, content: doc.toJSON(), @@ -572,6 +581,8 @@ export class BlockNoteEditor< this.pmSchema.cached.blockNoteEditor = this; + assertContainerSchemaInvariants(this.pmSchema); + this._tiptapEditor.on("mount", () => { this.headless = false; }); @@ -1051,13 +1062,14 @@ export class BlockNoteEditor< * error if the reference block could not be found. * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. - * @param placement Whether the blocks should be inserted just before, just after, or nested inside the - * `referenceBlock`. + * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next + * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children. Throws an error if + * the `referenceBlock` (or its parent, for `"before"`/`"after"`) doesn't accept the blocks there. */ public insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ) { return this._blockManager.insertBlocks( blocksToInsert, diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index f086444ecc..a33bfcab4b 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -1,4 +1,7 @@ -import { insertBlocks } from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; +import { + BlockPlacement, + insertBlocks, +} from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import { moveBlocksDown, moveBlocksUp, @@ -150,13 +153,13 @@ export class BlockManager< * error if the reference block could not be found. * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. - * @param placement Whether the blocks should be inserted just before, just after, or nested inside the - * `referenceBlock`. + * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next + * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children. */ public insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ) { return this.editor.transact((tr) => insertBlocks(tr, blocksToInsert, referenceBlock, placement), diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..1d54a508d4 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -39,6 +39,7 @@ import { UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; +import { isContainerType } from "../../../schema/blocks/children.js"; import type { BlockNoteEditor, BlockNoteEditorOptions, @@ -62,7 +63,21 @@ export function getDefaultTiptapExtensions( UniqueID.configure({ // everything from bnBlock group (nodes that represent a BlockNote block should have an id) - types: ["blockContainer", "columnList", "column"], + types: [ + "blockContainer", + // Legacy: `@blocknote/xl-multi-column`'s hand-written PM nodes, which + // have no `children` config and so aren't picked up below. Removed + // once multi-column is migrated onto the container API. + "columnList", + "column", + // Container block specs whose PM node is itself in the `bnBlock` + // group (column, columnList, callout, etc.). The bnBlock node is the + // block itself, so the id lives on its attrs rather than on a + // wrapping blockContainer. + ...Object.entries(editor.schema.blockSpecs) + .filter(([, spec]) => isContainerType((spec as any).config)) + .map(([type]) => type), + ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, }), @@ -130,6 +145,16 @@ export function getDefaultTiptapExtensions( }), ] : []), + // Nodes the block's node depends on but which aren't blocks + // themselves (a content-bearing container's content & children nodes). + ...("extraNodes" in blockSpec.implementation + ? (blockSpec.implementation.extraNodes as Node[]).map((node) => + node.configure({ + editor: editor, + domAttributes: options.domAttributes, + }), + ) + : []), ]; }), createCopyToClipboardExtension(editor), diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 5cf6e74c1c..71167e8f5a 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -563,7 +563,7 @@ export class ExtensionManager { const blockInfo = getBlockInfoFromSelection(tr); if ( - !blockInfo.isBlockContainer || + !blockInfo.isWrappedBlock || this.editor.schema.blockSchema[blockInfo.blockNoteType] ?.content !== "inline" ) { diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index 4f0515df95..033df48484 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -118,7 +118,11 @@ export function transformPasted(slice: Slice, view: EditorView) { return retyped; } - if (isInTableCell(view)) { + // `tableParagraph` only exists in schemas with the default table blocks. A + // schema with a custom table implementation (e.g. container-block cells, + // which hold real blocks and need no inline conversion) skips this branch. + const tableParagraph = view.state.schema.nodes.tableParagraph; + if (tableParagraph && isInTableCell(view)) { let hasTableContent = false; f.descendants((node) => { if (node.type.isInGroup("tableContent")) { @@ -128,7 +132,7 @@ export function transformPasted(slice: Slice, view: EditorView) { if ( !hasTableContent && // is the content valid for a table paragraph? - !view.state.schema.nodes.tableParagraph.validContent(f) + !tableParagraph.validContent(f) ) { // if not, convert the content to inline content return new Slice( @@ -213,9 +217,7 @@ function retypeLeadingParagraphForEmptyTarget( } const blockInfo = getBlockInfoFromSelection(view.state); - const target = blockInfo.isBlockContainer - ? blockInfo.blockContent.node - : null; + const target = blockInfo.isWrappedBlock ? blockInfo.blockContent.node : null; if ( !target || target.type.name === "paragraph" || @@ -275,7 +277,7 @@ function shouldApplyFix(fragment: Fragment, view: EditorView) { // for both paste and drop events. Drop events can potentially cause // issues as they don't always happen at the current selection. const blockInfo = getBlockInfoFromSelection(view.state); - if (blockInfo.isBlockContainer) { + if (blockInfo.isWrappedBlock) { const selectedBlockHasTableContent = blockInfo.blockContent.node.type.spec.content === "tableRow+"; diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index 9c7a2650fd..5427718ad9 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -11,6 +11,7 @@ import { StyledText, Styles, } from "../schema/index.js"; +import { isContainerType } from "../schema/blocks/children.js"; import type { BlockMapping, @@ -60,15 +61,41 @@ export abstract class Exporter< RS, TS, > { + // Stored with erased generics: a generically-typed property would change + // the class's variance in B/I/S and break mapping inference at subclass + // construction sites (the schema param was previously inference-only). + private readonly blockNoteSchema: BlockNoteSchema; + public constructor( - _schema: BlockNoteSchema, // only used for type inference + schema: BlockNoteSchema, protected readonly mappings: { blockMapping: BlockMapping; inlineContentMapping: InlineContentMapping; styleMapping: StyleMapping; }, public readonly options: ExporterOptions, - ) {} + ) { + this.blockNoteSchema = schema; + } + + /** + * Whether a block type is a container block (declares `children`, e.g. + * `columnList`, `column`, or a custom callout). Container mappings own the + * placement of their children, so exporters must not append the children + * after the container's own output. + */ + public isContainerBlock(blockType: string): boolean { + // Legacy: `@blocknote/xl-multi-column`'s hand-written specs, which have + // no `children` config. Removed once multi-column is migrated onto the + // container API. + if (blockType === "columnList" || blockType === "column") { + return true; + } + const spec = (this.blockNoteSchema.blockSpecs as Record)[ + blockType + ]; + return !!spec && isContainerType(spec.config); + } /** * The strings this exporter renders into the produced document - the @@ -129,7 +156,9 @@ export abstract class Exporter< const mapping = this.mappings.blockMapping[block.type]; if (!mapping) { throw new Error( - `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`, + this.isContainerBlock(block.type) + ? `No mapping found for container block type "${block.type}". Container blocks require an explicit block mapping that places their children.` + : `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`, ); } return mapping(block, this, nestingLevel, numberedListIndex, children); diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index fddd2712e9..94c8dfd1c4 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -20,8 +20,16 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + ContainerUIInfo, + getContainerUIInfo, +} from "../../api/blockManipulation/containers/containerUI.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; import { dragStart, unsetDragImage } from "./dragging.js"; +import { + getContainerChildAtCursor, + hasHorizontalContainerAncestor, +} from "./sideMenuContainerGeometry.js"; export type SideMenuState< BSchema extends BlockSchema, @@ -37,7 +45,8 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250; function getBlockFromCoords( view: EditorView, coords: { left: number; top: number }, - adjustForColumns = true, + containerUIInfo: ContainerUIInfo, + adjustForHorizontalContainers = true, ) { const elements = view.root.elementsFromPoint(coords.left, coords.top); @@ -46,21 +55,28 @@ function getBlockFromCoords( // probably a ui overlay like formatting toolbar etc continue; } - if (adjustForColumns) { - const column = element.closest("[data-node-type=columnList]"); - if (column) { - return getBlockFromCoords( - view, - { - // TODO can we do better than this? - left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself - top: coords.top, - }, - false, - ); - } + if ( + adjustForHorizontalContainers && + containerUIInfo.containerSelector && + // Inside a container with side-by-side children (e.g. a columnList), + // the x position must be offset. The hovered coordinates land in the + // side menu's own gutter, which belongs to a different child. The + // horizontal container can be any ancestor (the element may sit inside + // a vertical child of it, like a block inside a column). + hasHorizontalContainerAncestor(element, containerUIInfo) + ) { + return getBlockFromCoords( + view, + { + // TODO can we do better than this? + left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself + top: coords.top, + }, + containerUIInfo, + false, + ); } - return getDraggableBlockFromElement(element, view); + return getDraggableBlockFromElement(element, view, containerUIInfo); } return undefined; } @@ -71,6 +87,7 @@ function getBlockFromMousePos( y: number; }, view: EditorView, + containerUIInfo: ContainerUIInfo, ): { node: HTMLElement; id: string } | undefined { // Editor itself may have padding or other styling which affects // size/position, so we get the boundingRect of the first child (i.e. the @@ -94,7 +111,7 @@ function getBlockFromMousePos( top: mousePos.y, }; - const referenceBlock = getBlockFromCoords(view, coords); + const referenceBlock = getBlockFromCoords(view, coords, containerUIInfo); if (!referenceBlock) { // could not find the reference block @@ -109,15 +126,26 @@ function getBlockFromMousePos( * ``` * Hovering at position x (left edge of BlockB) would return BlockA. * Instead, we check at position y (right edge of BlockA) to correctly identify BlockB. + * `elementsFromPoint` returns the deepest element at a point, so this single + * probe descends through any depth of regular nesting. + * + * When the reference block is a (draggable) container block, the probe is + * aimed at the direct child under the cursor instead of the container + * itself. The container's own padding can exceed the probe inset, which + * would keep resolving the container even though the cursor is aligned with + * one of its children (making the child's menu jump away as the cursor + * moves towards it). */ - const referenceBlocksBoundingBox = - referenceBlock.node.getBoundingClientRect(); + const probeTarget = + getContainerChildAtCursor(referenceBlock.node, mousePos, containerUIInfo) ?? + referenceBlock.node; return getBlockFromCoords( view, { - left: referenceBlocksBoundingBox.right - 10, + left: probeTarget.getBoundingClientRect().right - 10, top: mousePos.y, }, + containerUIInfo, false, ); } @@ -214,7 +242,12 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const containerUIInfo = getContainerUIInfo(this.editor); + const block = getBlockFromMousePos( + this.mousePos, + this.pmView, + containerUIInfo, + ); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -240,7 +273,14 @@ export class SideMenuView< // Shows or updates elements. if (this.editor.isEditable) { const blockContentBoundingBox = block.node.getBoundingClientRect(); - const column = block.node.closest("[data-node-type=column]"); + // The closest container ancestor (a column, callout, ...), excluding + // the hovered block itself, which may be a draggable container. Blocks + // inside a container anchor the side menu to the container's block + // area rather than the editor's left edge, which would put the menu + // over unrelated content (or off-screen inside columns). + const container = containerUIInfo.containerSelector + ? block.node.parentElement?.closest(containerUIInfo.containerSelector) + : undefined; const sideMenuBlock = this.editor.getBlock( this.hoveredBlock!.getAttribute("data-id")!, ); @@ -255,12 +295,16 @@ export class SideMenuView< this.state = { show: true, referencePos: new DOMRect( - column - ? // We take the first child as column elements have some default - // padding. This is a little weird since this child element will - // be the first block, but since it's always non-nested and we - // only take the x coordinate, it's ok. - column.firstElementChild!.getBoundingClientRect().x + container + ? // We anchor to the container's first block element (rather + // than the container itself, which may have padding or its own + // chrome around the block area). This is a little weird since + // this element is the first block, but since it's always + // non-nested and we only take the x coordinate, it's ok. + ( + container.querySelector('[data-node-type="blockOuter"]') ?? + container.firstElementChild! + ).getBoundingClientRect().x : ( this.pmView.dom.firstChild as HTMLElement ).getBoundingClientRect().x, diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts new file mode 100644 index 0000000000..afcbb8be59 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts @@ -0,0 +1,285 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; +import { + getContainerChildAtCursor, + getDirectChildBlocks, + hasHorizontalContainerAncestor, + isHorizontalContainer, + rectIndexAtCursor, + rectsAreSideBySide, + type BlockRect, +} from "./sideMenuContainerGeometry.js"; + +// The side-menu container geometry: the pure rect arithmetic, and the +// `querySelectorAll`/`closest` walks against live layout. A container whose +// children happen to sit side-by-side must be recognised as horizontal +// without declaring anything. +// +// The DOM trees are attached to the real document and laid out by the real +// engine; nothing stubs `getBoundingClientRect`. A column list inside a real +// editor is covered end-to-end by +// `tests/src/end-to-end/multicolumn/multicolumn.test.tsx`. + +const rect = ( + top: number, + bottom: number, + left: number, + right: number, +): BlockRect => ({ top, bottom, left, right }); + +// Two columns of a column list: same vertical band, adjacent horizontally. +const SIDE_BY_SIDE = [rect(0, 100, 0, 100), rect(0, 100, 100, 200)]; +// Two blocks of a callout: same horizontal band, stacked vertically. +const STACKED = [rect(0, 40, 0, 200), rect(50, 90, 0, 200)]; + +describe("rectsAreSideBySide", () => { + it("is true when two rects overlap vertically, false when stacked", () => { + expect(rectsAreSideBySide(SIDE_BY_SIDE)).toBe(true); + expect(rectsAreSideBySide(STACKED)).toBe(false); + // Degenerate inputs are never a row. + expect(rectsAreSideBySide([rect(0, 100, 0, 100)])).toBe(false); + expect(rectsAreSideBySide([])).toBe(false); + }); + + it("treats abutting rects as stacked, but counts a one-pixel overlap", () => { + // The second rect's top exactly meets the first's bottom. A stack with no + // gap must not be misread as a row. + expect( + rectsAreSideBySide([rect(0, 40, 0, 200), rect(40, 80, 0, 200)]), + ).toBe(false); + expect( + rectsAreSideBySide([rect(0, 41, 0, 100), rect(40, 80, 0, 100)]), + ).toBe(true); + }); + + it("finds an overlapping pair that isn't the first two", () => { + // The loop is over every pair, not just neighbours. A column list whose + // first two children happen to be stacked is still a row. + expect( + rectsAreSideBySide([ + rect(0, 40, 0, 100), + rect(40, 80, 0, 100), + rect(40, 80, 100, 200), + ]), + ).toBe(true); + }); +}); + +describe("rectIndexAtCursor", () => { + it("returns the rect whose x range contains the cursor (side-by-side)", () => { + // Both rects share the y range, so only x distinguishes them. The + // vertical-only fallback recorded for the first must not win over an x + // match found later in the list; otherwise hovering the second column of + // a row would resolve to its neighbour. + expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 150, y: 50 })).toBe(1); + expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 10, y: 50 })).toBe(0); + }); + + it("falls back to the first vertical match when x is in the gutter", () => { + // The cursor's y is in the first block's band but its x is left of it (the + // side-menu gutter). The first vertical match wins. + expect(rectIndexAtCursor(STACKED, { x: -20, y: 20 })).toBe(0); + }); + + it("returns undefined when the cursor misses every rect vertically", () => { + expect(rectIndexAtCursor(STACKED, { x: 10, y: 999 })).toBeUndefined(); + expect(rectIndexAtCursor(STACKED, { x: 10, y: -999 })).toBeUndefined(); + expect(rectIndexAtCursor([], { x: 10, y: 10 })).toBeUndefined(); + }); + + it("includes the rect edges", () => { + const single = [rect(0, 40, 0, 200)]; + expect(rectIndexAtCursor(single, { x: 0, y: 0 })).toBe(0); + expect(rectIndexAtCursor(single, { x: 200, y: 40 })).toBe(0); + }); +}); + +let mounted: HTMLElement[] = []; + +afterEach(() => { + mounted.forEach((el) => el.remove()); + mounted = []; +}); + +/** Attaches a tree to the document so the browser actually lays it out. */ +function mount(el: T): T { + document.body.appendChild(el); + mounted.push(el); + return el; +} + +function el(nodeType: string): HTMLElement { + const node = document.createElement("div"); + node.setAttribute("data-node-type", nodeType); + return node; +} + +/** The `blockOuter > blockContainer` chrome BlockNote renders around every + * regular block, with real text in it so it has a real height. */ +function regularChild(text = "block"): { + outer: HTMLElement; + blockContainer: HTMLElement; +} { + const outer = el("blockOuter"); + const blockContainer = el("blockContainer"); + blockContainer.textContent = text; + outer.append(blockContainer); + return { outer, blockContainer }; +} + +function uiInfo(containerTypes: string[]): ContainerUIInfo { + const set = new Set(containerTypes); + return { + containerTypes: set, + draggableContainerTypes: set, + nonDraggableBlockTypes: new Set(), + containerSelector: containerTypes.length + ? containerTypes.map((t) => `[data-node-type="${t}"]`).join(",") + : null, + }; +} + +/** + * A column list laid out the way the real one is: a flex row of two columns, + * each holding one block. Nothing declares "horizontal". The browser puts the + * columns side by side and the module has to notice. + */ +function buildColumnList() { + const info = uiInfo(["columnList", "column"]); + + const columnList = el("columnList"); + columnList.style.display = "flex"; + columnList.style.width = "400px"; + + const columnA = el("column"); + const columnB = el("column"); + for (const column of [columnA, columnB]) { + column.style.flex = "1"; + } + + const childA = regularChild("A"); + const childB = regularChild("B"); + columnA.append(childA.outer); + columnB.append(childB.outer); + columnList.append(columnA, columnB); + mount(columnList); + + return { info, columnList, columnA, columnB, childA, childB }; +} + +/** A callout: an ordinary block-flow container, so its children stack. */ +function buildVerticalContainer() { + const info = uiInfo(["callout"]); + + const callout = el("callout"); + callout.style.width = "400px"; + const first = regularChild("first"); + const second = regularChild("second"); + callout.append(first.outer, second.outer); + mount(callout); + + return { info, callout, first, second }; +} + +describe("getDirectChildBlocks", () => { + it("returns direct child blocks, skipping nested grandchildren", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + + // The blocks inside each column must not come back as the list's own + // children. The `closest` check stops the walk one level down. + expect(getDirectChildBlocks(columnList, info)).toEqual([columnA, columnB]); + }); + + it("sees through blockOuter wrappers to the blockContainer child", () => { + const { info, columnA, childA } = buildColumnList(); + + // The column's own direct child is the wrapped blockContainer, not the + // blockOuter chrome (which isn't a block in the selector's sense). + expect(getDirectChildBlocks(columnA, info)).toEqual([ + childA.blockContainer, + ]); + }); +}); + +describe("isHorizontalContainer", () => { + it("recognises a real flex row as horizontal", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + + // Nothing declares the column list horizontal and no rect is stubbed; + // the detection runs against real layout. + expect(isHorizontalContainer(columnList, info)).toBe(true); + + // Also asserted as raw geometry, so a failure shows whether the layout + // or the detection broke. + const a = columnA.getBoundingClientRect(); + const b = columnB.getBoundingClientRect(); + expect(a.width).toBeGreaterThan(0); + expect(b.left).toBeGreaterThanOrEqual(a.right - 1); + expect(a.top).toBe(b.top); + }); + + it("is false for a container whose children stack", () => { + const { info, callout } = buildVerticalContainer(); + + expect(isHorizontalContainer(callout, info)).toBe(false); + }); + + it("is false for a column holding a single block", () => { + const { info, columnA } = buildColumnList(); + + expect(isHorizontalContainer(columnA, info)).toBe(false); + }); +}); + +describe("hasHorizontalContainerAncestor", () => { + it("is true for a block nested inside a column of a column list", () => { + const { info, childA } = buildColumnList(); + + // The block sits inside a (vertical) column, whose parent column list is + // the horizontal one, so the walk must climb past the column. + expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe( + true, + ); + }); + + it("is false for a block inside a purely vertical container", () => { + const { info, first } = buildVerticalContainer(); + + expect(hasHorizontalContainerAncestor(first.blockContainer, info)).toBe( + false, + ); + }); +}); + +describe("getContainerChildAtCursor", () => { + it("returns undefined for a non-container element", () => { + const { info, childA } = buildColumnList(); + + expect( + getContainerChildAtCursor(childA.blockContainer, { x: 10, y: 10 }, info), + ).toBeUndefined(); + }); + + it("resolves the hovered column of a real row", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + const b = columnB.getBoundingClientRect(); + + expect( + getContainerChildAtCursor( + columnList, + { x: b.left + b.width / 2, y: b.top + b.height / 2 }, + info, + ), + ).toBe(columnB); + + const a = columnA.getBoundingClientRect(); + expect( + getContainerChildAtCursor( + columnList, + { x: a.left + a.width / 2, y: a.top + a.height / 2 }, + info, + ), + ).toBe(columnA); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts new file mode 100644 index 0000000000..87f13f9c13 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts @@ -0,0 +1,107 @@ +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; + +function containerChildSelector(containerUIInfo: ContainerUIInfo): string { + return containerUIInfo.containerSelector + ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` + : `[data-node-type="blockContainer"]`; +} + +export function getDirectChildBlocks( + container: Element, + containerUIInfo: ContainerUIInfo, +): Element[] { + const childSelector = containerChildSelector(containerUIInfo); + + const children: Element[] = []; + for (const child of container.querySelectorAll(childSelector)) { + if (child.parentElement?.closest(childSelector) === container) { + children.push(child); + } + } + return children; +} + +export type BlockRect = { + top: number; + bottom: number; + left: number; + right: number; +}; + +export function rectsAreSideBySide(rects: BlockRect[]): boolean { + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + if (rects[i].top < rects[j].bottom && rects[j].top < rects[i].bottom) { + return true; + } + } + } + return false; +} + +// X-match wins over y-only match (disambiguates side-by-side children). +export function rectIndexAtCursor( + rects: BlockRect[], + mousePos: { x: number; y: number }, +): number | undefined { + let verticalMatch: number | undefined = undefined; + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if (mousePos.y < rect.top || mousePos.y > rect.bottom) { + continue; + } + if (mousePos.x >= rect.left && mousePos.x <= rect.right) { + return i; + } + verticalMatch = verticalMatch ?? i; + } + return verticalMatch; +} + +export function isHorizontalContainer( + container: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + return rectsAreSideBySide( + getDirectChildBlocks(container, containerUIInfo).map((child) => + child.getBoundingClientRect(), + ), + ); +} + +export function hasHorizontalContainerAncestor( + element: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + if (!containerUIInfo.containerSelector) { + return false; + } + let container = element.closest(containerUIInfo.containerSelector); + while (container) { + if (isHorizontalContainer(container, containerUIInfo)) { + return true; + } + container = + container.parentElement?.closest(containerUIInfo.containerSelector) ?? + null; + } + return false; +} + +export function getContainerChildAtCursor( + element: Element, + mousePos: { x: number; y: number }, + containerUIInfo: ContainerUIInfo, +): Element | undefined { + const nodeType = element.getAttribute("data-node-type"); + if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) { + return undefined; + } + + const children = getDirectChildBlocks(element, containerUIInfo); + const index = rectIndexAtCursor( + children.map((child) => child.getBoundingClientRect()), + mousePos, + ); + return index === undefined ? undefined : children[index]; +} diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts b/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts new file mode 100644 index 0000000000..3c4cac4442 --- /dev/null +++ b/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getDraggableBlockFromElement } from "./getDraggableBlockFromElement.js"; + +// These are pure DOM walks (`closest`/`querySelector` over the block chrome), +// so we build detached trees rather than booting an editor. Only `view.dom` is +// read, as the stop condition for the upward walk. No layout is involved, but +// the unit under test is the DOM API itself, so it runs against a real +// browser engine rather than jsdom's re-implementation of it. + +/** Builds the `blockOuter > blockContainer > blockContent` chrome BlockNote + * renders around every regular block. */ +function regularBlock( + id: string, + contentType: string, +): { outer: HTMLElement; blockContainer: HTMLElement; content: HTMLElement } { + const outer = document.createElement("div"); + outer.setAttribute("data-node-type", "blockOuter"); + + const blockContainer = document.createElement("div"); + blockContainer.setAttribute("data-node-type", "blockContainer"); + blockContainer.setAttribute("data-id", id); + + const content = document.createElement("div"); + content.setAttribute("data-content-type", contentType); + + blockContainer.append(content); + outer.append(blockContainer); + return { outer, blockContainer, content }; +} + +/** Nests `child` under `parent` in a `blockGroup`, as list nesting does. */ +function nest(parent: HTMLElement, child: HTMLElement) { + const group = document.createElement("div"); + group.setAttribute("data-node-type", "blockGroup"); + group.append(child); + parent.append(group); +} + +function viewWith(root: HTMLElement) { + const dom = document.createElement("div"); + dom.append(root); + return { dom }; +} + +describe("getDraggableBlockFromElement", () => { + it("returns the block container for a regular block", () => { + const { outer, blockContainer, content } = regularBlock("a", "paragraph"); + + expect(getDraggableBlockFromElement(content, viewWith(outer))).toEqual({ + node: blockContainer, + id: "a", + }); + }); + + it("skips a block whose type opts out of dragging", () => { + const { outer, content } = regularBlock("a", "lockedBlock"); + + expect( + getDraggableBlockFromElement(content, viewWith(outer), { + nonDraggableBlockTypes: new Set(["lockedBlock"]), + }), + ).toBeUndefined(); + }); + + it("falls through to the nearest draggable ancestor", () => { + const parent = regularBlock("parent", "paragraph"); + const child = regularBlock("child", "lockedBlock"); + nest(parent.blockContainer, child.outer); + + // Dragging from inside the locked child should hand back the parent's + // handle rather than no handle at all. + expect( + getDraggableBlockFromElement(child.content, viewWith(parent.outer), { + nonDraggableBlockTypes: new Set(["lockedBlock"]), + }), + ).toEqual({ node: parent.blockContainer, id: "parent" }); + }); + + it("reads the block's own content type, not a nested block's", () => { + const parent = regularBlock("parent", "lockedBlock"); + const child = regularBlock("child", "paragraph"); + nest(parent.blockContainer, child.outer); + + // `parent`'s own content element precedes the nested `blockGroup`, so the + // first `[data-content-type]` match inside it must be "lockedBlock". + expect( + getDraggableBlockFromElement(parent.content, viewWith(parent.outer), { + nonDraggableBlockTypes: new Set(["lockedBlock"]), + }), + ).toBeUndefined(); + }); + + it("returns a container block only when its type is draggable", () => { + const column = document.createElement("div"); + column.setAttribute("data-node-type", "column"); + column.setAttribute("data-id", "col"); + + expect( + getDraggableBlockFromElement(column, viewWith(column), { + draggableContainerTypes: new Set(["columnList"]), + }), + ).toBeUndefined(); + + expect( + getDraggableBlockFromElement(column, viewWith(column), { + draggableContainerTypes: new Set(["column"]), + }), + ).toEqual({ node: column, id: "col" }); + }); +}); diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts index abc6bd2906..7423faf9fa 100644 --- a/packages/core/src/extensions/getDraggableBlockFromElement.ts +++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts @@ -1,18 +1,59 @@ import { EditorView } from "prosemirror-view"; +const EMPTY_SET: ReadonlySet = new Set(); + +/** + * Walks up from `element` to the closest element that can host a side-menu + * drag handle. Both sets are derived from each spec's `meta.draggable` (see + * `getContainerUIInfo`); a block that opts out is skipped, so the handle falls + * through to the nearest draggable ancestor rather than disappearing. + */ export function getDraggableBlockFromElement( element: Element, - view: EditorView, + // Only `dom` is read, as the stop condition for the upward walk. + view: Pick, + types: { + draggableContainerTypes?: ReadonlySet; + nonDraggableBlockTypes?: ReadonlySet; + } = {}, ) { + const draggableContainerTypes = types.draggableContainerTypes ?? EMPTY_SET; + const nonDraggableBlockTypes = types.nonDraggableBlockTypes ?? EMPTY_SET; + + const isDraggable = (el: Element) => { + const nodeType = el.getAttribute?.("data-node-type"); + + if (nodeType === "blockContainer") { + if (nonDraggableBlockTypes.size === 0) { + return true; + } + // Every regular block shares the `blockContainer` node, so its actual + // block type only shows up on its content element. That element comes + // before any nested `blockGroup`, so the first match in document order + // is this block's own content rather than a descendant's. + const contentType = el + .querySelector("[data-content-type]") + ?.getAttribute("data-content-type"); + + return !contentType || !nonDraggableBlockTypes.has(contentType); + } + + return ( + nodeType !== null && + nodeType !== undefined && + draggableContainerTypes.has(nodeType) + ); + }; + while ( element && element.parentElement && element.parentElement !== view.dom && - element.getAttribute?.("data-node-type") !== "blockContainer" + !isDraggable(element) ) { element = element.parentElement; } - if (element.getAttribute?.("data-node-type") !== "blockContainer") { + if (!isDraggable(element)) { return undefined; } return { node: element as HTMLElement, id: element.getAttribute("data-id")! }; diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..81db2e664f 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,6 +1,6 @@ import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { NodeSelection, TextSelection } from "prosemirror-state"; import { getBottomNestedBlockInfo, @@ -8,13 +8,27 @@ import { getParentBlockInfo, getPrevBlockInfo, mergeBlocksCommand, + mergeIntoContainerContent, } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; import { liftItem, nestBlock, unnestBlock, } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; -import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +import { + fixContainersById, + isContainerNode, +} from "../../../api/blockManipulation/containers/fixContainer.js"; +import { + ascendToInsertablePos, + descendToLastInsertionPos, + getAncestorContainers, + getFirstLeafBlock, +} from "../../../api/blockManipulation/containers/containerNav.js"; +import { + isContentContainerNode, + isSealed, +} from "../../../schema/blocks/children.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { @@ -45,7 +59,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -69,7 +83,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { blockContent } = blockInfo; @@ -87,12 +101,47 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the previous sibling is a sealed container, selects it instead + // of merging into it. Merging into a content-bearing container's + // title would cross the sealed boundary. Selection lets a second + // Backspace delete the container explicitly. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isWrappedBlock) { + return false; + } + + const selectionAtBlockStart = + state.selection.from === blockInfo.blockContent.beforePos + 1; + if (!selectionAtBlockStart || !state.selection.empty) { + return false; + } + + const prevBlockInfo = getPrevBlockInfo( + state.doc, + blockInfo.bnBlock.beforePos, + ); + if (!prevBlockInfo || !isSealed(prevBlockInfo.bnBlock.node)) { + return false; + } + + if ( + dispatch && + NodeSelection.isSelectable(prevBlockInfo.bnBlock.node) + ) { + tr.setSelection( + NodeSelection.create(tr.doc, prevBlockInfo.bnBlock.beforePos), + ).scrollIntoView(); + } + return true; + }), // Merges block with the previous one if it isn't indented, and the selection is at the start of the // block. The target block for merging must contain inline content. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -106,7 +155,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ // return early here. if ( !prevBlockInfo || - !prevBlockInfo.isBlockContainer || + !prevBlockInfo.isWrappedBlock || prevBlockInfo.blockContent.node.type.spec.content !== "inline*" ) { return false; @@ -127,12 +176,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the previous block is a columnList, moves the current block to - // the end of the last column in it. + // If the previous block is a container (e.g. a columnList or a + // callout), moves the current block to its deepest trailing insertion + // slot, descending through nested containers (e.g. to the end of the + // last column). () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -146,21 +197,62 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!prevBlockInfo || prevBlockInfo.isBlockContainer) { + // A content-bearing container is `isWrappedBlock` but still a + // container to descend into. Its non-empty-body merges are + // handled by the merge branch above; this catches the rest + // (e.g. an empty body, which refuses to merge). + if ( + !prevBlockInfo || + (prevBlockInfo.isWrappedBlock && + !isContentContainerNode(prevBlockInfo.bnBlock.node)) + ) { return false; } - if (dispatch) { - const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1; - const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1); + const insertionPos = descendToLastInsertionPos( + prevBlockInfo.bnBlock.node, + prevBlockInfo.bnBlock.beforePos, + state.schema.nodes["blockContainer"], + { respectSealed: true }, + ); + if (insertionPos === null) { + // When only a sealed boundary blocked the descent, the + // container can't be entered, so it's selected instead, and a + // second Backspace deletes it explicitly. A container with + // nowhere a `blockContainer` can land falls through as before. + // (The probe descends without `respectSealed`, i.e. through + // seals.) + const blockedBySeal = + descendToLastInsertionPos( + prevBlockInfo.bnBlock.node, + prevBlockInfo.bnBlock.beforePos, + state.schema.nodes["blockContainer"], + ) !== null; + if ( + blockedBySeal && + NodeSelection.isSelectable(prevBlockInfo.bnBlock.node) + ) { + if (dispatch) { + tr.setSelection( + NodeSelection.create( + tr.doc, + prevBlockInfo.bnBlock.beforePos, + ), + ).scrollIntoView(); + } + return true; + } + return false; + } + if (dispatch) { tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node); + tr.insert(insertionPos, blockInfo.bnBlock.node); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)), + TextSelection.near(tr.doc.resolve(insertionPos + 1)), ); return true; @@ -168,13 +260,55 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is the first in a column, moves it to the end of the - // previous column. If there is no previous column, moves it above the - // columnList. + // If the block is the first child of a container that has its own + // content, merges it into that content. Counterpart of the Delete + // case. A pure container has nothing to merge into, so it falls + // through to the "move it out" branch below, as before. + () => + commands.command(({ state, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isWrappedBlock) { + return false; + } + + const selectionAtBlockStart = + state.selection.from === blockInfo.blockContent.beforePos + 1; + if (!selectionAtBlockStart || !state.selection.empty) { + return false; + } + + // Only the container's first child. + if (state.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore) { + return false; + } + + const parentInfo = getParentBlockInfo( + state.doc, + blockInfo.bnBlock.beforePos, + ); + if ( + !parentInfo || + !isContentContainerNode(parentInfo.bnBlock.node) + ) { + return false; + } + + return mergeIntoContainerContent( + state, + dispatch, + parentInfo, + blockInfo, + ); + }), + // If the block is the first in a container (e.g. a column or a + // callout), moves it out: to the end of the previous sibling + // container if there is one (e.g. the previous column), otherwise to + // just before the closest enclosing boundary that accepts it (e.g. + // above the columnList / callout). () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -192,32 +326,63 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { return false; } - const $blockPos = tr.doc.resolve(blockInfo.bnBlock.beforePos); - const $columnPos = tr.doc.resolve($blockPos.before()); - const columnListPos = $columnPos.before(); + // A sealed container swallows Backspace at its first block: + // moving the block out would cross the boundary. + if (isSealed(parentBlock)) { + return true; + } + + const blockContainerType = state.schema.nodes["blockContainer"]; + const containerBeforePos = $pos.before(); + const $containerPos = tr.doc.resolve(containerBeforePos); + + // A previous sibling inside an enclosing container (e.g. the + // previous column) is a target to descend into. A sibling at a + // regular block position is not; there the block moves out to + // before the container instead. + const prevSibling = + isContainerNode($containerPos.node().type) && + $containerPos.nodeBefore && + isContainerNode($containerPos.nodeBefore.type) + ? $containerPos.nodeBefore + : null; + + const insertionPos = prevSibling + ? descendToLastInsertionPos( + prevSibling, + containerBeforePos - prevSibling.nodeSize, + blockContainerType, + { respectSealed: true }, + ) + : ascendToInsertablePos( + tr.doc, + containerBeforePos, + blockContainerType, + { respectSealed: true }, + ); + if (insertionPos === null) { + return false; + } if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - fixColumnList(tr, columnListPos); - - if ($columnPos.pos === columnListPos + 1) { - tr.insert(columnListPos, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve(columnListPos)), - ); - } else { - tr.insert($columnPos.pos - 1, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve($columnPos.pos)), - ); - } + tr.insert(insertionPos, blockInfo.bnBlock.node); + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); } return true; @@ -227,7 +392,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -247,12 +412,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, prevBlockInfo, ); - if (!bottomNestedPrevBlockInfo.isBlockContainer) { + if (!bottomNestedPrevBlockInfo.isWrappedBlock) { return false; } if ( !bottomNestedPrevBlockInfo || - !bottomNestedPrevBlockInfo.isBlockContainer + !bottomNestedPrevBlockInfo.isWrappedBlock ) { return false; } @@ -313,7 +478,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -327,12 +492,21 @@ export const KeyboardShortcutsExtension = Extension.create<{ ); if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) { + // The sealed-aware descent stops at a sealed container instead + // of finding an (empty) block inside it, so the current block + // is never cut in across the boundary. const bottomBlock = getBottomNestedBlockInfo( state.doc, prevBlockInfo, + { stopAtSealed: true }, ); - if (!bottomBlock.isBlockContainer) { + if (!bottomBlock.isWrappedBlock) { + return false; + } + // A sealed content container also stops the descent; deleting + // it here would take its children with it. + if (isSealed(bottomBlock.bnBlock.node)) { return false; } @@ -375,11 +549,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer || !blockInfo.childContainer) { + if (!blockInfo.isWrappedBlock || !blockInfo.childContainer) { return false; } const { blockContent, childContainer } = blockInfo; + // A container allowed to hold no children still has a child + // container node, but no first child to pull anything out of. + if (childContainer.node.childCount === 0) { + return false; + } + const selectionAtBlockEnd = state.selection.from === blockContent.afterPos - 1; const selectionEmpty = state.selection.empty; @@ -387,7 +567,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ const firstChildBlockInfo = getBlockInfoFromResolvedPos( state.doc.resolve(childContainer.beforePos + 1), ); - if (!firstChildBlockInfo.isBlockContainer) { + if (!firstChildBlockInfo.isWrappedBlock) { return false; } @@ -408,8 +588,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ Fragment.empty, ) .deleteRange( - // Deletes whole child container if there's only one child. - childContainer.node.childCount === 1 + // Deletes whole child container if there's only one + // child. A container with its own content always keeps + // its children node (it's part of its content + // expression), so there only the child is deleted. + childContainer.node.childCount === 1 && + !isContentContainerNode(blockInfo.bnBlock.node) ? { from: childContainer.beforePos, to: childContainer.afterPos, @@ -434,13 +618,47 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the next sibling is a sealed container, selects it instead of + // merging it in. Delete counterpart of the sealed-previous-sibling + // Backspace case. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isWrappedBlock) { + return false; + } + + const selectionAtBlockEnd = + state.selection.from === blockInfo.blockContent.afterPos - 1; + if (!selectionAtBlockEnd || !state.selection.empty) { + return false; + } + + const nextBlockInfo = getNextBlockInfo( + state.doc, + blockInfo.bnBlock.beforePos, + ); + if (!nextBlockInfo || !isSealed(nextBlockInfo.bnBlock.node)) { + return false; + } + + if ( + dispatch && + NodeSelection.isSelectable(nextBlockInfo.bnBlock.node) + ) { + tr.setSelection( + NodeSelection.create(tr.doc, nextBlockInfo.bnBlock.beforePos), + ).scrollIntoView(); + } + return true; + }), // Merges block with the next one (at the same nesting level or lower), // if one exists, the block has no children, and the selection is at the // end of the block. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -449,7 +667,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -468,12 +686,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the next block is a columnList, moves the first block from its - // first column to after the current block. + // If the next block is a container (e.g. a columnList or a callout), + // moves its first leaf block out, to after the current block. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -487,22 +705,33 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || nextBlockInfo.isWrappedBlock) { + return false; + } + + const firstLeaf = getFirstLeafBlock( + nextBlockInfo.bnBlock.node, + nextBlockInfo.bnBlock.beforePos, + { respectSealed: true }, + ); + if (!firstLeaf) { return false; } if (dispatch) { - const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1; - const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); + const containersToFix = getAncestorContainers( + tr.doc, + firstLeaf.beforePos, + ); tr.delete( - $blockBeforePos.pos, - $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, + firstLeaf.beforePos, + firstLeaf.beforePos + firstLeaf.node.nodeSize, ); - fixColumnList(tr, nextBlockInfo.bnBlock.beforePos); - tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!); + tr.insert(blockInfo.bnBlock.afterPos, firstLeaf.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), + TextSelection.near(tr.doc.resolve(firstLeaf.beforePos)), ); return true; @@ -510,13 +739,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is the last in a column, moves it to the start of the - // next column. If there is no next column, moves it below the - // columnList. + // If the block is the last in a container (e.g. a column or a + // callout), moves the next block to after it. The next block is the + // first leaf of the next sibling container, or the block following + // the enclosing containers. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -534,36 +764,56 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // Climbs out of the containers the block is the last child of, + // to the first position with a following node. + let $boundary = $pos; + while ( + $boundary.nodeAfter === null && + $boundary.depth > 0 && + isContainerNode($boundary.node().type) + ) { + // Pulling a block in from past a sealed boundary would cross + // it, so the keystroke is swallowed instead. + if (isSealed($boundary.node())) { + return true; + } + $boundary = tr.doc.resolve($boundary.after()); + } + + const nextNode = $boundary.nodeAfter; + if (!nextNode) { return false; } - const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos); - const $columnEndPos = tr.doc.resolve($blockEndPos.after()); - const columnListEndPos = $columnEndPos.after(); + // The block to pull in: the next node itself, or its first leaf + // block when it's a container. + const target = isContainerNode(nextNode.type) + ? getFirstLeafBlock(nextNode, $boundary.pos, { + respectSealed: true, + }) + : { node: nextNode, beforePos: $boundary.pos }; + if (!target) { + return false; + } if (dispatch) { - // Position before first block in next column, or first block - // after columnList if there is no next column. - const nextBlockBeforePos = - $columnEndPos.pos === columnListEndPos - 1 - ? columnListEndPos - : $columnEndPos.pos + 1; - const nextBlockInfo = getBlockInfoFromResolvedPos( - tr.doc.resolve(nextBlockBeforePos), + const containersToFix = getAncestorContainers( + tr.doc, + target.beforePos, ); tr.delete( - nextBlockInfo.bnBlock.beforePos, - nextBlockInfo.bnBlock.afterPos, + target.beforePos, + target.beforePos + target.node.nodeSize, ); - fixColumnList( - tr, - columnListEndPos - $columnEndPos.node().nodeSize, - ); - tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node); + tr.insert(blockInfo.bnBlock.afterPos, target.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), + TextSelection.near(tr.doc.resolve(target.beforePos)), ); } @@ -577,7 +827,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { blockContent } = blockInfo; @@ -597,7 +847,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlockInfo = getParentBlockInfo(doc, beforePos); - if (!parentBlockInfo) { + if ( + !parentBlockInfo || + // Never climbs past a sealed boundary. A block found + // there would be pulled in across it. + isSealed(parentBlockInfo.bnBlock.node) + ) { return undefined; } @@ -611,7 +866,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -653,7 +908,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -666,7 +921,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -715,7 +970,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -730,7 +985,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!nextBlockInfo) { return false; } - if (!nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo.isWrappedBlock) { return false; } @@ -770,7 +1025,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -859,12 +1114,142 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // Enter inside the content of a container that has children of its own + // (a toggle's title): everything after the cursor becomes a new first + // child, and the cursor moves into it. At the end of the title that's + // a new empty first child. Without this, the generic split below would + // try to split the container itself. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.isWrappedBlock || + !blockInfo.childContainer || + !isContentContainerNode(blockInfo.bnBlock.node) + ) { + return false; + } + const { blockContent, childContainer } = blockInfo; + + const titleEndPos = blockContent.afterPos - 1; + if ( + state.selection.from < blockContent.beforePos + 1 || + state.selection.to > titleEndPos + ) { + return false; + } + + if (dispatch) { + // The tail of the title, empty when the cursor is at its end. + const tail = blockContent.node.content.cut( + state.selection.to - blockContent.beforePos - 1, + ); + const newChild = state.schema.nodes[ + "blockContainer" + ].createAndFill( + undefined, + state.schema.nodes["paragraph"].create(undefined, tail), + )!; + + // Removes the tail (and anything selected) from the title, then + // prepends it to the container's children. + tr.delete(state.selection.from, titleEndPos); + const insertionPos = tr.mapping.map(childContainer.beforePos + 1); + tr.insert(insertionPos, newChild); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); + tr.scrollIntoView(); + } + + return true; + }), + // If the block is empty and the last child of a non-sealed container, + // moves the block out (double Enter exits the container). The block + // lands at the nearest enclosing position that accepts it. E.g. out + // of a column it skips the columnList, which holds only columns, and + // lands below it. Without this, Enter only ever creates new blocks + // within the container, so the cursor could never leave a trailing + // container. Shift+Enter still adds spacing inside a container. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isWrappedBlock) { + return false; + } + + const selectionEmpty = + state.selection.anchor === state.selection.head; + const blockEmpty = blockInfo.blockContent.node.childCount === 0; + if (!selectionEmpty || !blockEmpty) { + return false; + } + + const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const parentBlock = $pos.node(); + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // Only fires on the container's last child. + if (tr.doc.resolve(blockInfo.bnBlock.afterPos).nodeAfter !== null) { + return false; + } + + // A sealed boundary means Enter never moves content out. + if (isSealed(parentBlock)) { + return false; + } + + const containerAfterPos = ascendToInsertablePos( + tr.doc, + $pos.after(), + state.schema.nodes["blockContainer"], + { respectSealed: true }, + "after", + ); + if (containerAfterPos === null) { + return false; + } + + if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + + tr.delete( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + ); + // The insertion position, mapped through the deletion (and any + // schema-driven refill it triggered). + const insertionPos = tr.mapping.map(containerAfterPos); + tr.insert(insertionPos, blockInfo.bnBlock.node); + const stepsBeforeFix = tr.steps.length; + fixContainersById(tr, containersToFix); + // The exited container lies before the inserted block, so a + // repair that rewrites it (e.g. an emptied column unwrapping + // its list) shifts the block. Map the position through the + // repair's steps before placing the caret. + tr.setSelection( + TextSelection.near( + tr.doc.resolve( + tr.mapping.slice(stepsBeforeFix).map(insertionPos) + 1, + ), + ), + ); + tr.scrollIntoView(); + } + + return true; + }), // Creates a new block and moves the selection to it if the current one is empty, while the selection is also // empty & at the start of the block. () => commands.command(({ state, dispatch, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -920,7 +1305,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, chain }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { blockContent } = blockInfo; diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 7ab30b78aa..c6c57a72c9 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -67,9 +67,12 @@ const UniqueID = Extension.create({ setIdAttribute: false, isWithinEditor: undefined as ((element: Element) => boolean) | undefined, generateID: () => { - // Use mock ID if tests are running. - if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) { - const testOptions = (window as any).__TEST_OPTIONS; + // Use mock ID if tests are running. Resolved off `globalThis` rather + // than a bare `window` so that tests running in the plain `node` + // environment (no `window`) still get deterministic IDs. + const testHost: any = (globalThis as any).window ?? globalThis; + if (testHost.__TEST_OPTIONS) { + const testOptions = testHost.__TEST_OPTIONS; if (testOptions.mockID === undefined) { testOptions.mockID = 0; } else { diff --git a/packages/core/src/fonts/inter.css b/packages/core/src/fonts/inter.css index 57337cdd50..6e152551bf 100644 --- a/packages/core/src/fonts/inter.css +++ b/packages/core/src/fonts/inter.css @@ -9,7 +9,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-100.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-200 - latin */ @font-face { @@ -20,7 +20,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-200.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-300 - latin */ @font-face { @@ -31,7 +31,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-300.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-regular - latin */ @font-face { @@ -42,7 +42,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-regular.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-500 - latin */ @font-face { @@ -53,7 +53,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-500.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-600 - latin */ @font-face { @@ -64,7 +64,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-600.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-700 - latin */ @font-face { @@ -75,7 +75,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-700.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-800 - latin */ @font-face { @@ -86,7 +86,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-800.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-900 - latin */ @font-face { @@ -97,5 +97,5 @@ local(""), url("./inter-v12-latin/inter-v12-latin-900.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4f220e1e2..c1e7a3e7a4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,13 @@ export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js"; export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; +// The rest of the container machinery is on `@blocknote/core/internal`: +// repair, navigation, UI info, the node groups and the generated node names. +// `isContainerNode` stays here: it answers a schema-level question ("is this +// node type a container?") that integrations legitimately ask. It is defined +// in `children.ts` and re-exported via `fixContainer.ts`. +export { isContainerNode } from "./api/blockManipulation/containers/fixContainer.js"; +// Legacy column repair for `@blocknote/xl-multi-column`'s hand-written PM +// nodes. Removed once multi-column is migrated onto the container API. export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts new file mode 100644 index 0000000000..490b000742 --- /dev/null +++ b/packages/core/src/internal.ts @@ -0,0 +1,74 @@ +/** + * `@blocknote/core/internal` + * + * BlockNote's own machinery, exposed so the packages built on top of core + * (`@blocknote/react`, `@blocknote/xl-multi-column`, …) and BlockNote's tests + * can use it. Not part of the public API: anything here may change in any + * release, without a major version bump or a deprecation. + * + * The public counterparts stay on the root entrypoint: `isContainerType`, + * `isContainerNode`, and the `children` config types (`ChildrenConfig`, + * `ChildrenAllow`). + */ + +// How a `children` config compiles to a ProseMirror content expression, and +// the node groups and generated node names derived from it. +export { + ANY_CONTAINER_GROUP, + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + CONTAINER_CONTENT_GROUP, + CONTAINER_NODE_PRIORITY, + blockTypeOfContainerChildrenNode, + blockTypeOfContainerContentNode, + childrenContentExpression, + containerChildrenNodeName, + containerContentNodeName, + containerNodePriority, + getChildrenConfig, + getContentContainerNodeTypes, + isContainerBlockNode, + isContentContainerNode, + isPlaceableAnywhere, + resolveChildren, +} from "./schema/blocks/children.js"; + +// Validation of `children` configs, run when a schema is built. +export { + validateChildrenConfigs, + validateContainerRunsBefore, +} from "./schema/blocks/validateChildren.js"; + +export { assertContainerSchemaInvariants } from "./schema/blocks/assertSchemaInvariants.js"; + +// The attributes a container block's root element carries, and the three ways +// they get there (node view, HTML serialization, framework render). +export { + applyContainerAttributes, + fillContainerAttributes, + getContainerAttributes, +} from "./schema/blocks/containerAttributes.js"; + +// Repairing a container after its children changed. +export { + fixContainer, + fixContainersById, + flattenNonInsertableBlocks, + isEmptyContainerChild, + removeEmptyChildren, +} from "./api/blockManipulation/containers/fixContainer.js"; + +// Position-based navigation through arbitrarily nested containers. +export { + ascendToInsertablePos, + descendToFirstInsertionPos, + descendToLastInsertionPos, + getAncestorContainers, + getFirstLeafBlock, +} from "./api/blockManipulation/containers/containerNav.js"; + +// What the side menu and drag handle need to know about a schema's containers. +export { + getContainerUIInfo, + type ContainerUIInfo, +} from "./api/blockManipulation/containers/containerUI.js"; diff --git a/packages/core/src/schema/blocks/assertSchemaInvariants.ts b/packages/core/src/schema/blocks/assertSchemaInvariants.ts new file mode 100644 index 0000000000..8254a8f144 --- /dev/null +++ b/packages/core/src/schema/blocks/assertSchemaInvariants.ts @@ -0,0 +1,100 @@ +import { Fragment, type Schema } from "prosemirror-model"; + +import { + ANY_CONTAINER_GROUP, + getChildrenConfig, + isContainerNode, + isPlaceableAnywhere, +} from "./children.js"; + +/** + * Checks the structural properties the rest of the container machinery + * assumes, once, when the ProseMirror schema is built. + * + * Each property is otherwise guaranteed only by a chain of implicit reasoning + * spread across several files. Asserting them here turns silent breakage into + * a startup error naming the cause. + */ +export function assertContainerSchemaInvariants(pmSchema: Schema) { + assertBlockGroupFillsWithBlockContainer(pmSchema); + assertContainersAreFillable(pmSchema); + assertAnyContainerGroupMatchesConfigs(pmSchema); +} + +/** + * `blockGroup` must auto-fill with `blockContainer` rather than with some + * container block type. + * + * Today this holds because container nodes register below `blockContainer`'s + * priority, which drives TipTap's registration order, which drives the order + * ProseMirror resolves a group into types, which drives what `fillBefore` + * picks. Every link in that chain is implicit, and Yjs document + * initialization depends on the result (see `FixUpSchema`, which reads the + * first auto-filled child expecting it to be the id-carrying + * `blockContainer`). + */ +function assertBlockGroupFillsWithBlockContainer(pmSchema: Schema) { + const defaultType = pmSchema.nodes["blockGroup"]?.contentMatch.defaultType; + + if (defaultType?.name !== "blockContainer") { + throw new Error( + `BlockNote schema invariant broken: \`blockGroup\` auto-fills with "${defaultType?.name}" instead of "blockContainer". ` + + "Container block nodes must register at a lower priority than `blockContainer` (see CONTAINER_NODE_PRIORITY). " + + "Yjs document initialization depends on this (see FixUpSchema).", + ); + } +} + +/** + * The `anyContainer` group must contain exactly the container blocks + * placeable anywhere. It is what the `allow` container wildcards (`"any"`, + * `"containers"`) compile to. Generated nodes always get this right; a + * hand-written container node that forgets the group would silently drop out + * of every wildcard `allow`, so the mismatch is reported here instead. + */ +function assertAnyContainerGroupMatchesConfigs(pmSchema: Schema) { + for (const type of Object.values(pmSchema.nodes)) { + const blockConfig = type.spec.blockConfig; + // Only a block's own node. Generated `__content`/`__children` nodes + // carry their owning block's config under a different node name. + if (!blockConfig || blockConfig.type !== type.name) { + continue; + } + + const shouldBeInGroup = + getChildrenConfig(blockConfig) !== undefined && + isPlaceableAnywhere(blockConfig); + if (shouldBeInGroup !== type.isInGroup(ANY_CONTAINER_GROUP)) { + throw new Error( + shouldBeInGroup + ? `BlockNote schema invariant broken: container block "${type.name}" is placeable anywhere but its node is not in the "${ANY_CONTAINER_GROUP}" group, ` + + `so wildcard \`allow\` containers would not accept it. A hand-written container node must include the group itself.` + : `BlockNote schema invariant broken: node "${type.name}" is in the "${ANY_CONTAINER_GROUP}" group but its block config does not make it a container placeable anywhere.`, + ); + } + } +} + +/** + * Every container must be creatable empty, or inserting one throws a raw + * ProseMirror error at the call site instead of here. + * + * This asks ProseMirror directly rather than re-deriving the answer from the + * config, so it catches combinations a hand-written check would miss. + * `whenEmptied: "refill"`'s empty-fill fallback uses the same `fillBefore`, + * so this also guarantees that a refill repair can always complete. + */ +function assertContainersAreFillable(pmSchema: Schema) { + for (const type of Object.values(pmSchema.nodes)) { + if (!isContainerNode(type)) { + continue; + } + + if (!type.contentMatch.fillBefore(Fragment.empty, true)) { + throw new Error( + `Container block "${type.name}" can never be created empty: its \`children\` config compiles to \`${type.spec.content}\`, ` + + "which ProseMirror cannot auto-fill. Lower the minimum child count, or allow regular blocks.", + ); + } + } +} diff --git a/packages/core/src/schema/blocks/children.test.ts b/packages/core/src/schema/blocks/children.test.ts new file mode 100644 index 0000000000..2925709523 --- /dev/null +++ b/packages/core/src/schema/blocks/children.test.ts @@ -0,0 +1,291 @@ +// @vitest-environment node +import { describe, expect, it } from "vite-plus/test"; + +import { childrenContentExpression, resolveChildren } from "./children.js"; +import type { ChildrenConfig } from "./types.js"; +import { validateChildrenConfigs } from "./validateChildren.js"; + +// All enforcement happens through the content expression. If this table is +// right, `allow`/`min`/`max` are enforced by ProseMirror itself. +const CASES: [string, ChildrenConfig, string][] = [ + [ + "any block, at least one (the minimal config)", + { allow: "any" }, + "blockGroupChild+", + ], + ["any block, possibly none", { allow: "any", min: 0 }, "blockGroupChild*"], + [ + "any block, exactly one", + { allow: "any", min: 1, max: 1 }, + "blockGroupChild", + ], + ["any block, two or more", { allow: "any", min: 2 }, "blockGroupChild{2,}"], + [ + "any block, two to four", + { allow: "any", min: 2, max: 4 }, + "blockGroupChild{2,4}", + ], + [ + "any block, at most one", + { allow: "any", min: 0, max: 1 }, + "blockGroupChild?", + ], + [ + "any block, exactly three", + { allow: "any", min: 3, max: 3 }, + "blockGroupChild{3}", + ], + ["regular blocks only", { allow: "blocks" }, "blockContainer+"], + ["one container type only", { allow: ["column"], min: 2 }, "column{2,}"], + [ + "several container types", + { allow: ["column", "card"] }, + "(column | card)+", + ], + [ + "any container but no regular blocks", + { allow: "containers" }, + "anyContainer+", + ], +]; + +describe("childrenContentExpression", () => { + it.each(CASES)("%s", (_name, config, expected) => { + expect(childrenContentExpression(config)).toBe(expected); + }); +}); + +describe("resolveChildren", () => { + // The four `allow` forms and what they desugar to. The compiled expressions + // above are a direct function of this table. + it.each([ + ["any", { blocks: true, containers: true }], + ["blocks", { blocks: true, containers: [] }], + ["containers", { blocks: false, containers: true }], + [["column"], { blocks: false, containers: ["column"] }], + ] as const)("resolves allow %j", (allow, expected) => { + expect(resolveChildren({ allow })).toMatchObject(expected); + }); + + it("applies the defaults: min 1, unbounded, refill, isolated", () => { + const resolved = resolveChildren({ allow: "any" }); + expect(resolved.min).toBe(1); + expect(resolved.max).toBeUndefined(); + expect(resolved.whenEmptied).toBe("refill"); + expect(resolved.boundary).toBe("isolated"); + }); + + it("returns the same object for the same config, without mutating it", () => { + // Downstream code resolves the same config object on every node build and + // repair pass, and must never mutate the user's object. + const config: ChildrenConfig = { allow: "any", min: 1 }; + expect(resolveChildren(config)).toBe(resolveChildren(config)); + expect(config).toEqual({ allow: "any", min: 1 }); + }); +}); + +type ContainerFixture = { + children: ChildrenConfig; + placement?: "anywhere" | "containerOnly"; +}; + +function configsWith(containers: Record) { + return { + paragraph: { type: "paragraph", content: "inline" as const }, + heading: { type: "heading", content: "inline" as const }, + ...Object.fromEntries( + Object.entries(containers).map(([type, { children, placement }]) => [ + type, + { type, content: "none" as const, children, placement }, + ]), + ), + }; +} + +const validate = (containers: Record) => () => + validateChildrenConfigs(configsWith(containers)); + +describe("validateChildrenConfigs", () => { + it("accepts valid shapes: minimal, columnList-style, content-bearing", () => { + expect(validate({ callout: { children: { allow: "any" } } })).not.toThrow(); + expect( + validate({ + grid: { children: { allow: ["gridCell"], min: 2 } }, + gridCell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).not.toThrow(); + // A container may have its own inline content (the toggle shape). + expect(() => + validateChildrenConfigs({ + toggle: { + type: "toggle", + content: "inline", + children: { allow: "any" }, + }, + }), + ).not.toThrow(); + }); + + // Malformed configs, each rejected with a specific message (JS consumers + // don't get the type errors TS consumers do). `allow: ["heading"]` used to + // silently compile to "any regular block", so naming a regular block is a + // hard error until per-type filtering is supported. + it.each<[string, ContainerFixture["children"], RegExp]>([ + ["missing `allow`", {} as unknown as ChildrenConfig, /`allow` is required/], + [ + "unknown `allow` form", + { allow: "everything" } as unknown as ChildrenConfig, + /`allow` must be/, + ], + ["unknown type in allow array", { allow: ["nope"] }, /nope/], + [ + "regular block type in allow array", + { allow: ["heading"] }, + /not yet supported/, + ], + ["allow that permits nothing", { allow: [] }, /permits nothing/], + [ + "containers wildcard with no other containers", + { allow: "containers" }, + /no other container block types/, + ], + ["negative minimum", { allow: "any", min: -1 }, /non-negative integer/], + [ + "maximum smaller than minimum", + { allow: "any", min: 3, max: 2 }, + /greater than or equal/, + ], + [ + "unknown boundary value", + { allow: "any", boundary: "shut" } as unknown as ChildrenConfig, + /`boundary` must be "open", "isolated" or "sealed"/, + ], + [ + "`default` violating the child count", + { allow: "any", min: 2, default: [{ type: "paragraph" }] }, + /fewer than the 2 required/, + ], + ])("rejects %s", (_name, children, message) => { + expect(validate({ box: { children } })).toThrow(message); + }); + + it("rejects `default` containing a block that isn't permitted", () => { + expect( + validate({ + grid: { + children: { + allow: ["gridCell"], + min: 2, + default: [{ type: "paragraph" }, { type: "paragraph" }], + }, + }, + gridCell: { + children: { allow: "any" }, + placement: "containerOnly", + }, + }), + ).toThrow(/not permitted/); + }); + + // The wildcards compile to the containers placeable anywhere, so a + // containerOnly block only fits where a parent names it explicitly. Every + // configuration that would leave one unreachable, or in an unsatisfiable + // `default`, is rejected up front. + it("rejects containerOnly blocks that nothing can hold", () => { + // In a wildcard `default`, which would build an unsatisfiable node: + expect( + validate({ + box: { children: { allow: "any", default: [{ type: "cell" }] } }, + cell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).toThrow(/not permitted/); + // Unreachable, even though a wildcard container exists: + expect( + validate({ + box: { children: { allow: "any" } }, + cell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).toThrow(/could never be inserted/); + // Unreachable, because no container's allow list names it: + expect( + validate({ + grid: { children: { allow: ["gridCell"], min: 2 } }, + gridCell: { + children: { allow: "blocks" }, + placement: "containerOnly", + }, + orphan: { + children: { allow: "blocks" }, + placement: "containerOnly", + }, + }), + ).toThrow(/could never be inserted/); + // A `containers` wildcard needs at least one placeable-anywhere one: + expect( + validate({ + box: { children: { allow: "containers" } }, + cell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).toThrow(/placeable anywhere/); + }); + + it("rejects placement on a block that isn't a container", () => { + expect(() => + validateChildrenConfigs({ + paragraph: { + type: "paragraph", + content: "inline", + placement: "containerOnly", + }, + }), + ).toThrow(/only applies to container blocks/); + }); + + it("rejects `children` on a table block", () => { + expect(() => + validateChildrenConfigs({ + bad: { type: "bad", content: "table", children: { allow: "any" } }, + }), + ).toThrow(/cannot be combined with `content: "table"`/); + }); + + // The content & children nodes are generated from the block type, so a block + // type that happens to have one of those names would clash with them. + it("rejects a block type that collides with a generated node name", () => { + expect(() => + validateChildrenConfigs({ + toggle: { + type: "toggle", + content: "inline", + children: { allow: "any" }, + }, + toggle__content: { type: "toggle__content", content: "inline" }, + }), + ).toThrow(/collides with the block type of the same name/); + }); + + // `fillBefore` recurses across node types, so a cycle blows the stack + // rather than returning null. It has to be caught before the schema is + // built. A mutual reference is fine as soon as one side can be filled with + // a paragraph instead. + it("rejects a container cycle but accepts a breakable mutual reference", () => { + expect( + validate({ + card: { children: { allow: ["cardBody"] } }, + cardBody: { + children: { allow: ["card"] }, + placement: "containerOnly", + }, + }), + ).toThrow(/requires it back/); + expect( + validate({ + card: { children: { allow: ["cardBody"] } }, + cardBody: { + children: { allow: "any" }, + placement: "containerOnly", + }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts new file mode 100644 index 0000000000..ecd5c077a7 --- /dev/null +++ b/packages/core/src/schema/blocks/children.ts @@ -0,0 +1,243 @@ +import type { Node, NodeType, Schema } from "prosemirror-model"; + +import type { + BlockConfig, + ChildrenAllow, + ChildrenConfig, + PartialBlockNoDefaults, +} from "./types.js"; + +/** A {@link ChildrenConfig} with every default filled in. */ +export type ResolvedChildren = { + blocks: boolean; + /** `true` for any container type; a (possibly empty) list otherwise. */ + containers: true | readonly string[]; + /** What `whenEmptied` compares against. */ + min: number; + max: number | undefined; + default: readonly PartialBlockNoDefaults[] | undefined; + whenEmptied: "refill" | "unwrap"; + boundary: "open" | "isolated" | "sealed"; +}; + +export const CHILD_CONTAINER_GROUP = "childContainer"; + +export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; + +// Joined by every container block placeable anywhere (`placement` other than +// `"containerOnly"`). It's what the `allow` container wildcards (`"any"`, +// `"containers"`) compile to: a containerOnly type only ever lives where a +// container names it explicitly, so it stays out of the group. +export const ANY_CONTAINER_GROUP = "anyContainer"; + +// Not `blockContent`. That is a legal child of `blockContainer`, so a paste +// could produce an unrepresentable `blockContainer > toggle__content`. +export const CONTAINER_CONTENT_GROUP = "containerContent"; + +// `__` because PM's expression parser only accepts word characters in node names. +const CONTENT_NODE_SUFFIX = "__content"; +const CHILDREN_NODE_SUFFIX = "__children"; + +export function containerContentNodeName(blockType: string): string { + return `${blockType}${CONTENT_NODE_SUFFIX}`; +} + +export function containerChildrenNodeName(blockType: string): string { + return `${blockType}${CHILDREN_NODE_SUFFIX}`; +} + +export function blockTypeOfContainerContentNode( + nodeName: string, +): string | undefined { + return nodeName.endsWith(CONTENT_NODE_SUFFIX) + ? nodeName.slice(0, -CONTENT_NODE_SUFFIX.length) + : undefined; +} + +export function blockTypeOfContainerChildrenNode( + nodeName: string, +): string | undefined { + return nodeName.endsWith(CHILDREN_NODE_SUFFIX) + ? nodeName.slice(0, -CHILDREN_NODE_SUFFIX.length) + : undefined; +} + +// Whether `type` is a node that holds child blocks directly: a pure container +// block's own node, or a generated `__children` node. (`blockGroup` is in the +// group too but is regular-block nesting machinery, not a container.) +export function isContainerNode(type: NodeType): boolean { + return type.isInGroup(CHILD_CONTAINER_GROUP) && type.name !== "blockGroup"; +} + +// Whether `node` is a container that has its own content (children live in +// a generated `__children` node). Not the same as `isContainerNode`. +export function isContentContainerNode(node: Node): boolean { + return !!node.firstChild?.type.isInGroup(CONTAINER_CONTENT_GROUP); +} + +/** + * Whether `node` is a container block's node in either shape: a pure + * container (children held directly) or a content-bearing container (children + * held in a generated `__children` node). + */ +export function isContainerBlockNode(node: Node): boolean { + return isContainerNode(node.type) || isContentContainerNode(node); +} + +export function getContentContainerNodeTypes( + schema: Schema, + blockType: string, +): { contentType: NodeType; childrenType: NodeType } | undefined { + const contentType = schema.nodes[containerContentNodeName(blockType)]; + const childrenType = schema.nodes[containerChildrenNodeName(blockType)]; + + return contentType && childrenType + ? { contentType, childrenType } + : undefined; +} + +// Below `blockContainer`'s priority (50) so PM's `fillBefore` picks +// `blockContainer` first, avoiding recursion through nested containers. +export const CONTAINER_NODE_PRIORITY = 40; + +const CONTAINER_PRIORITY_BAND = { min: 30, max: 49 }; +const DEFAULT_SPEC_PRIORITY = 101; + +// Maps `sortByDependencies` priority into the container band (30–49). +// Preserves relative order but keeps all containers below regular blocks. +export function containerNodePriority(priority: number | undefined): number { + if (priority === undefined) { + return CONTAINER_NODE_PRIORITY; + } + + const steps = Math.round((priority - DEFAULT_SPEC_PRIORITY) / 10); + + return Math.min( + CONTAINER_PRIORITY_BAND.max, + Math.max(CONTAINER_PRIORITY_BAND.min, CONTAINER_NODE_PRIORITY + steps), + ); +} + +export function getChildrenConfig(config: { + children?: ChildrenConfig; +}): ChildrenConfig | undefined { + return config.children; +} + +export function isContainerType(config: { + children?: ChildrenConfig; +}): boolean { + return config.children !== undefined; +} + +export function isPlaceableAnywhere(config: { + placement?: BlockConfig["placement"]; +}): boolean { + return config.placement !== "containerOnly"; +} + +const resolvedCache = new WeakMap(); + +export function resolveChildren(children: ChildrenConfig): ResolvedChildren { + const cached = resolvedCache.get(children); + if (cached) { + return cached; + } + + const resolved: ResolvedChildren = { + ...resolveAllow(children.allow), + min: children.min ?? 1, + max: children.max, + default: children.default, + whenEmptied: children.whenEmptied ?? "refill", + boundary: children.boundary ?? "isolated", + }; + + resolvedCache.set(children, resolved); + return resolved; +} + +function resolveAllow( + allow: ChildrenAllow, +): Pick { + if (allow === "any") { + return { blocks: true, containers: true }; + } + if (allow === "blocks") { + return { blocks: true, containers: [] }; + } + if (allow === "containers") { + return { blocks: false, containers: true }; + } + return { blocks: false, containers: allow }; +} + +/** + * Whether `node` belongs to a container with a `"sealed"` boundary, one whose + * edge content may never implicitly cross (a table cell rather than a column). + * Reads the block config off the node's spec, so it works on a container + * block's own node and on its generated `__children` node alike. + */ +export function isSealed(node: Node): boolean { + const children = getChildrenConfig(node.type.spec.blockConfig ?? {}); + return ( + children !== undefined && resolveChildren(children).boundary === "sealed" + ); +} + +export function childrenContentExpression(children: ChildrenConfig): string { + const resolved = resolveChildren(children); + return allowTerm(resolved) + quantifier(resolved.min, resolved.max); +} + +function allowTerm(resolved: ResolvedChildren): string { + // "Anything" is already a group, so use it rather than spelling out a union + // that would need rebuilding whenever the schema gains a container type. + if (resolved.blocks && resolved.containers === true) { + return BLOCK_GROUP_CHILD_GROUP; + } + + const terms: string[] = []; + // `blockContainer` FIRST: PM's `fillBefore` picks the first matching type in + // a union, and filling with `blockContainer` (rather than another container) + // keeps auto-fill from recursing through nested containers. + if (resolved.blocks) { + terms.push("blockContainer"); + } + // The wildcard is the `anyContainer` group, not `childContainer`. The + // latter also contains `blockGroup`, which is not a block. + if (resolved.containers === true) { + terms.push(ANY_CONTAINER_GROUP); + } else { + terms.push(...resolved.containers); + } + + if (terms.length === 0) { + // Validation rejects this first; this is a bug-guard, not a user-facing + // error path. + throw new Error( + "Container `allow` permits nothing. This is a bug in BlockNote.", + ); + } + + return terms.length === 1 ? terms[0] : `(${terms.join(" | ")})`; +} + +function quantifier(min: number, max: number | undefined): string { + if (max === undefined) { + if (min === 0) { + return "*"; + } + if (min === 1) { + return "+"; + } + return `{${min},}`; + } + if (min === max) { + return max === 1 ? "" : `{${min}}`; + } + if (min === 0 && max === 1) { + return "?"; + } + return `{${min},${max}}`; +} diff --git a/packages/core/src/schema/blocks/containerAttributes.ts b/packages/core/src/schema/blocks/containerAttributes.ts new file mode 100644 index 0000000000..e0657b6e88 --- /dev/null +++ b/packages/core/src/schema/blocks/containerAttributes.ts @@ -0,0 +1,75 @@ +import { camelToDataKebab } from "../../util/string.js"; +import { PropSchema, Props } from "../propTypes.js"; + +export function getContainerAttributes( + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id: string | undefined, +): Record { + const attributes: Record = { "data-node-type": blockType }; + + for (const [prop, value] of Object.entries(blockProps)) { + if (value === undefined || value === propSchema[prop]?.default) { + continue; + } + attributes[camelToDataKebab(prop)] = `${value}`; + } + + if (id) { + attributes["data-id"] = id; + } + + return attributes; +} + +export function applyContainerAttributes( + dom: HTMLElement | DocumentFragment | undefined | null, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id: string | undefined, +) { + const element = dom as HTMLElement | undefined; + if (!element || typeof element.setAttribute !== "function") { + return; + } + + const attributes = getContainerAttributes( + blockType, + blockProps, + propSchema, + id, + ); + + for (const prop of Object.keys(blockProps)) { + const attr = camelToDataKebab(prop); + if (!(attr in attributes)) { + element.removeAttribute(attr); + } + } + for (const [attr, value] of Object.entries(attributes)) { + element.setAttribute(attr, value); + } +} + +// Like `applyContainerAttributes` but won't overwrite existing attributes. +export function fillContainerAttributes( + dom: HTMLElement, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, +) { + const attributes = getContainerAttributes( + blockType, + blockProps, + propSchema, + undefined, + ); + + for (const [attr, value] of Object.entries(attributes)) { + if (!dom.hasAttribute(attr)) { + dom.setAttribute(attr, value); + } + } +} diff --git a/packages/core/src/schema/blocks/containerParse.browser.test.ts b/packages/core/src/schema/blocks/containerParse.browser.test.ts new file mode 100644 index 0000000000..bf51322ac8 --- /dev/null +++ b/packages/core/src/schema/blocks/containerParse.browser.test.ts @@ -0,0 +1,394 @@ +import { Fragment } from "prosemirror-model"; +import { AllSelection } from "prosemirror-state"; +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 "./createSpec.js"; + +// Every test here goes through `tryParseHTMLToBlocks`, which parses real HTML +// into a real DOM (`document.implementation.createHTMLDocument` in +// `api/parsers/html/util/nestedLists.ts`) before ProseMirror's parser ever +// runs. The clipboard test additionally needs a mounted view for +// `view.serializeForClipboard`. Parsing HTML is the capability under test, so +// the whole suite runs against a real browser engine rather than jsdom's. + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// A pure container that recognizes its own external HTML. Before containers +// went through `getParseRules`, `parse` was silently dropped for them and this +// produced nothing at all. +const Card = createBlockSpec( + { + type: "card" as const, + propSchema: { tone: { default: "neutral" } }, + content: "none", + children: { allow: "any" }, + }, + { + render: renderDiv, + parse: (el) => + el.classList.contains("card") + ? { tone: el.getAttribute("data-tone") ?? undefined } + : undefined, + }, +)(); + +// The same, but taking over the parsing of its own body. +const Quote = createBlockSpec( + { + type: "quote" as const, + propSchema: {}, + content: "none", + children: { allow: "any" }, + }, + { + render: renderDiv, + parse: (el) => (el.tagName === "BLOCKQUOTE" ? {} : undefined), + // Returns inline nodes, the natural thing to build from an element, and + // relies on `toContainerChildren` to place them. + parseContent: ({ el, schema }) => + Fragment.from(schema.text(el.textContent?.trim() || "empty")), + }, +)(); + +// A container with its own content, to test the two generated nodes +// through the clipboard. +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: { open: { default: true } }, + content: "inline", + children: { allow: "any" }, + }, + { render: renderDiv }, +)(); + +// A content-bearing container whose `parseContent` returns a leading run of +// inline nodes followed by a block, the shape that has to split across the +// two generated nodes. +const Section = createBlockSpec( + { + type: "section" as const, + propSchema: {}, + content: "inline", + children: { allow: "any" }, + }, + { + render: renderDiv, + parse: (el) => (el.tagName === "SECTION" ? {} : undefined), + parseContent: ({ el, schema }) => + Fragment.fromArray([ + schema.text(el.getAttribute("data-title") || "untitled"), + schema.nodes["paragraph"].create( + null, + schema.text(el.textContent?.trim() || "empty"), + ), + ]), + }, +)(); + +// A pure container whose render puts non-content UI text next to the children +// host, the table-with-controls shape. That text must never round-trip into +// document content. +const Widget = createBlockSpec( + { + type: "widget" as const, + propSchema: {}, + content: "none", + children: { allow: "any" }, + }, + { + render: () => { + const dom = document.createElement("div"); + const contentDOM = document.createElement("div"); + const controls = document.createElement("div"); + controls.contentEditable = "false"; + controls.textContent = "UI LABEL"; + dom.append(contentDOM, controls); + return { dom, contentDOM }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + card: Card, + quote: Quote, + toggle: Toggle, + section: Section, + widget: Widget, + } as const, +}); + +let editor: BlockNoteEditor; +const div = document.createElement("div"); + +beforeAll(() => { + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }) as any; + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +describe("container `parse`", () => { + it("parses an external element into a container, children intact", () => { + const blocks = editor.tryParseHTMLToBlocks( + '

First

Second

', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("card"); + expect(blocks[0].props.tone).toBe("warning"); + // No `getContent` is supplied, so ProseMirror parses the children with the + // normal block rules and `findWrapping` adds the `blockContainer`s. + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + "heading", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "First", styles: {} }, + ]); + }); + + it("places inline nodes returned by `parseContent` into a child block", () => { + const blocks = editor.tryParseHTMLToBlocks( + "
Quoted text
", + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("quote"); + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "Quoted text", styles: {} }, + ]); + }); + + it("splits `parseContent` across a content-bearing container's two regions", () => { + const blocks = editor.tryParseHTMLToBlocks( + '
Body
', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("section"); + // The leading inline run is the block's own content; the block that + // follows it is a child. + expect(blocks[0].content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "Body", styles: {} }, + ]); + }); +}); + +describe("container HTML round-trip", () => { + const toggleBlocks = [ + { + id: "t-0", + type: "toggle" as const, + props: { open: false }, + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph" as const, content: "Body" }, + { id: "t-p-1", type: "heading" as const, content: "Sub" }, + ], + }, + ]; + + const expectRoundTripped = (parsed: any[]) => { + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("toggle"); + expect(parsed[0].props.open).toBe(false); + expect(parsed[0].content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect( + parsed[0].children.map((child: any) => [ + child.type, + child.content?.[0]?.text, + ]), + ).toEqual([ + ["paragraph", "Body"], + ["heading", "Sub"], + ]); + }; + + it("round-trips a content-bearing container through full HTML", () => { + editor.replaceBlocks(editor.document, toggleBlocks); + + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain('data-node-type="toggle"'); + + expectRoundTripped(editor.tryParseHTMLToBlocks(html)); + }); + + it("round-trips a content-bearing container through the clipboard", () => { + editor.replaceBlocks(editor.document, toggleBlocks); + + // A copy puts ProseMirror's own serialization on the clipboard, which + // renders the generated content & children nodes. + const view = editor._tiptapEditor.view; + view.dispatch(view.state.tr.setSelection(new AllSelection(view.state.doc))); + const clipboardHTML = view.serializeForClipboard( + view.state.selection.content(), + ).dom.innerHTML; + + expect(clipboardHTML).toContain('data-content-type="toggle"'); + expect(clipboardHTML).toContain('data-children-of="toggle"'); + + expectRoundTripped(editor.tryParseHTMLToBlocks(clipboardHTML)); + }); + + it("round-trips a content-bearing container through external HTML", () => { + editor.replaceBlocks(editor.document, toggleBlocks); + + const html = editor.blocksToHTMLLossy(editor.document); + expect(html).toContain('data-node-type="toggle"'); + + expectRoundTripped(editor.tryParseHTMLToBlocks(html)); + }); + + // Two children, because the one-child case passes either way. External HTML + // has no marker element for the container's own content, so an empty title + // leaves the parser reading a block element first. With nothing to satisfy + // the content node, it can't open the children node, and every child used + // to land after the container. + it("round-trips an empty-titled container's children through external HTML", () => { + editor.replaceBlocks(editor.document, [ + { ...toggleBlocks[0], content: undefined }, + ]); + + const html = editor.blocksToHTMLLossy(editor.document); + const parsed = editor.tryParseHTMLToBlocks(html); + + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("toggle"); + expect( + (parsed[0] as any).children.map((child: any) => [ + child.type, + child.content?.[0]?.text, + ]), + ).toEqual([ + ["paragraph", "Body"], + ["heading", "Sub"], + ]); + }); + + // Regression: internal HTML renders the block's full DOM, so a render with + // non-content UI text next to the children host (control buttons, labels) + // used to leak that text into the document as extra blocks on re-parse. + // The serializer marks the children host with `data-children-of` and the + // round-trip rule scopes itself to it. + it("excludes a render's non-content UI text from a pure container's round-trip", () => { + editor.replaceBlocks(editor.document, [ + { + id: "w-0", + type: "widget" as const, + children: [ + { id: "w-p-0", type: "paragraph" as const, content: "Inside" }, + ], + }, + ]); + + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain('data-children-of="widget"'); + expect(html).toContain("UI LABEL"); + + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("widget"); + expect( + (parsed[0] as any).children.map((child: any) => [ + child.type, + child.content?.[0]?.text, + ]), + ).toEqual([["paragraph", "Inside"]]); + expect(JSON.stringify(parsed)).not.toContain("UI LABEL"); + }); +}); + +describe("container `runsBefore`", () => { + const ambiguous = (type: string) => + createBlockSpec( + { + type, + propSchema: {}, + content: "none", + children: { allow: "any" }, + } as any, + { + render: renderDiv, + parse: (el: HTMLElement) => + el.classList.contains("shared") ? {} : undefined, + }, + ); + + const makeEditor = (betaRunsBefore?: string[]) => { + const alpha = ambiguous("alpha")(); + const beta = ambiguous("beta")(); + if (betaRunsBefore) { + (beta.implementation as any).runsBefore = betaRunsBefore; + } + + return BlockNoteEditor.create({ + schema: BlockNoteSchema.create().extend({ + blockSpecs: { ...defaultBlockSpecs, alpha, beta } as any, + }), + }) as BlockNoteEditor; + }; + + it("orders a container's parse rules before another container's", () => { + // Declaration order wins by default; `runsBefore` overrides it. + for (const [runsBefore, winner] of [ + [undefined, "alpha"], + [["alpha"], "beta"], + ] as const) { + const other = makeEditor(runsBefore ? [...runsBefore] : undefined); + try { + expect( + other.tryParseHTMLToBlocks('

x

')[0] + .type, + ).toBe(winner); + } finally { + other._tiptapEditor.destroy(); + } + } + }); + + it("rejects a `runsBefore` naming a regular block", () => { + // Container nodes all register below `blockContainer`, so this ordering is + // not something the schema could ever produce. + expect(() => makeEditor(["paragraph"])).toThrow( + /can never be ordered before a regular block/, + ); + }); +}); diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index b1e54d640a..6a0d9943f1 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -1,11 +1,13 @@ -import { Editor, Node } from "@tiptap/core"; +import { Editor, Node, NodeViewRendererProps } from "@tiptap/core"; import { DOMParser, Fragment, Node as PMNode, + Schema as PMSchema, TagParseRule, } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { Extension, @@ -13,8 +15,24 @@ import { } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + ANY_CONTAINER_GROUP, + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + CONTAINER_CONTENT_GROUP, + childrenContentExpression, + containerChildrenNodeName, + containerContentNodeName, + containerNodePriority, + getChildrenConfig, + isPlaceableAnywhere, + resolveChildren, +} from "./children.js"; +import { applyContainerAttributes } from "./containerAttributes.js"; +import { + applyDOMAttributes, getBlockFromNodeView, propsToAttributes, wrapInBlockStructure, @@ -45,9 +63,78 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) { }; } -// Function that uses the 'parse' function of a blockConfig to create a -// TipTap node's `parseHTML` property. This is only used for parsing content -// from the clipboard. +// Wraps inline runs from `parseContent` into paragraphs so they fit a +// container's block content expression. A leading inline run in a +// content-bearing container stays inline (it's the block's own content). +function toContainerChildren( + fragment: Fragment, + schema: PMSchema, + hasOwnContent: boolean, +): Fragment { + const out: PMNode[] = []; + let inlineRun: PMNode[] = []; + let seenBlock = false; + + const flush = () => { + if (inlineRun.length === 0) { + return; + } + out.push( + ...(hasOwnContent && !seenBlock + ? inlineRun + : [schema.nodes["paragraph"].create(null, inlineRun)]), + ); + inlineRun = []; + }; + + fragment.forEach((child) => { + if (child.isInline) { + inlineRun.push(child); + return; + } + flush(); + seenBlock = true; + out.push(child); + }); + flush(); + + return Fragment.fromArray(out); +} + +// Finds the element holding a serialized container block's content, marked +// `data-children-of` by the internal HTML serializer. For a pure container +// that element holds the children and is the content element; for a container +// with its own content it's the generated children *region*, whose parent +// hosts both regions and is the content element. Returns undefined when no +// marker belonging to *this* block (rather than a same-typed nested +// container) is present. +function findContainerContentElement( + el: HTMLElement, + config: { type: string; content: string }, +): HTMLElement | undefined { + const selector = `[data-children-of="${config.type}"]`; + + const resolve = (host: HTMLElement) => + config.content === "none" ? host : (host.parentElement ?? undefined); + + // The block's root may itself be the children host (a render that passes + // its own root to `contentRef`). `querySelectorAll` only sees descendants. + if (el.matches(selector)) { + return resolve(el); + } + + for (const host of el.querySelectorAll(selector)) { + // Skip hosts of same-typed *nested* containers: this block's own host is + // the one with no other container root between it and `el`. + if (host.parentElement?.closest("[data-node-type]") === el) { + return resolve(host); + } + } + + return undefined; +} + +// Creates `parseHTML` rules for clipboard parsing. export function getParseRules< TName extends string, TProps extends PropSchema, @@ -55,12 +142,28 @@ export function getParseRules< >( config: BlockConfig, implementation: BlockImplementation, + kind: "regular" | "container" = "regular", ) { + const isContainer = kind === "container"; + const rules: TagParseRule[] = [ - { - tag: "[data-content-type=" + config.type + "]", - contentElement: ".bn-inline-content", - }, + isContainer + ? { + tag: `[data-node-type=${config.type}]`, + // Scope the round-trip parse to the block's content region, so text + // the render puts elsewhere in its DOM (button labels, captions, + // ...) doesn't parse back as document content. The internal HTML + // serializer marks the region with `data-children-of`; HTML without + // the marker (older or hand-written) falls back to the whole + // element, the previous behavior. + contentElement: (el) => + findContainerContentElement(el as HTMLElement, config) ?? + (el as HTMLElement), + } + : { + tag: "[data-content-type=" + config.type + "]", + contentElement: ".bn-inline-content", + }, ]; if (implementation.parse) { @@ -81,10 +184,25 @@ export function getParseRules< }, // Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed preserveWhitespace: true, - getContent: - config.content === "inline" || - config.content === "none" || - config.content === "plain" + getContent: isContainer + ? implementation.parseContent + ? (node, schema) => + toContainerChildren( + implementation.parseContent!({ + el: node as HTMLElement, + schema, + }) ?? + DOMParser.fromSchema(schema).parse(node as HTMLElement, { + topNode: schema.nodes["blockGroup"].create(), + preserveWhitespace: true, + }).content, + schema, + config.content !== "none", + ) + : undefined + : config.content === "inline" || + config.content === "none" || + config.content === "plain" ? (node, schema) => { if (implementation.parseContent) { const result = implementation.parseContent({ @@ -167,147 +285,465 @@ export function getParseRules< return rules; } -// A function to create custom block for API consumers -// we want to hide the tiptap node from API consumers and provide a simpler API surface instead -export function addNodeAndExtensionsToSpec< +function buildContainerNode( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + priority?: number, +) { + const children = getChildrenConfig(blockConfig)!; + + const groups = ["bnBlock", CHILD_CONTAINER_GROUP]; + if (isPlaceableAnywhere(blockConfig)) { + groups.push(BLOCK_GROUP_CHILD_GROUP, ANY_CONTAINER_GROUP); + } + + return Node.create({ + name: blockConfig.type, + content: childrenContentExpression(children), + group: groups.join(" "), + marks() { + return suggestionMarks(this.editor); + }, + selectable: blockImplementation.meta?.selectable ?? true, + // Derived from `boundary`: an "open" container lets everything cross its + // edge; "isolated" and "sealed" both map to PM `isolating: true`. + isolating: resolveChildren(children).boundary !== "open", + defining: true, + priority: containerNodePriority(priority), + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return getParseRules(blockConfig, blockImplementation, "container"); + }, + + renderHTML({ HTMLAttributes }) { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", blockConfig.type); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + dom.setAttribute(attribute, value as string); + } + return { dom, contentDOM: dom }; + }, + + addNodeView() { + return (props) => + containerNodeView(blockConfig, blockImplementation, props, { + editor: this.options.editor, + tiptapEditor: this.editor, + blockContentDOMAttributes: + this.options.domAttributes?.blockContent || {}, + }); + }, + }); +} + +function containerRootDOM(output: { + dom: HTMLElement | DocumentFragment; + rootDOM?: HTMLElement | null; +}): HTMLElement | DocumentFragment | null | undefined { + return output.rootDOM === undefined ? output.dom : output.rootDOM; +} + +function containerNodeView< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + props: NodeViewRendererProps, + context: { + editor: unknown; + tiptapEditor: Editor; + blockContentDOMAttributes: Record; + }, +): NodeView { + const block = nodeToBlock(props.node, props.view.state.doc); + + const nodeView = blockImplementation.render.call( + { + blockContentDOMAttributes: context.blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + context.editor as any, + ); + + const rootDOM = () => containerRootDOM(nodeView); + + applyContainerAttributes( + rootDOM(), + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + + const typedNodeView = nodeView as unknown as NodeView; + + // Mark the children host in the live DOM, mirroring what the internal HTML + // serializer emits, so the container's round-trip parse rule can scope + // itself to it (`contentElement` in `getParseRules`) when ProseMirror + // re-reads editor DOM. Content-bearing containers get the marker from their + // generated `__children` node's own DOM instead. + if (blockConfig.content === "none" && typedNodeView.contentDOM) { + (typedNodeView.contentDOM as HTMLElement).setAttribute( + "data-children-of", + blockConfig.type, + ); + } + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, context.tiptapEditor); + } + + ignoreNonContentMutations(typedNodeView); + + const update = typedNodeView.update?.bind(typedNodeView); + if (update) { + typedNodeView.update = (node, decorations, innerDecorations) => { + if (node.type.name !== blockConfig.type) { + return false; + } + if (update(node, decorations, innerDecorations) === false) { + return false; + } + applyContainerAttributes( + rootDOM(), + blockConfig.type, + nodeToBlock(node, props.view.state.doc).props as any, + blockConfig.propSchema, + node.attrs.id, + ); + return true; + }; + } + + return typedNodeView; +} + +function buildContentContainerNode< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + priority?: number, +): { node: Node; extraNodes: Node[] } { + const children = getChildrenConfig(blockConfig)!; + + const contentName = containerContentNodeName(blockConfig.type); + const childrenName = containerChildrenNodeName(blockConfig.type); + const nodePriority = containerNodePriority(priority); + + const groups = ["bnBlock"]; + if (isPlaceableAnywhere(blockConfig)) { + groups.push(BLOCK_GROUP_CHILD_GROUP, ANY_CONTAINER_GROUP); + } + + const node = Node.create({ + name: blockConfig.type, + content: `${contentName} ${childrenName}`, + group: groups.join(" "), + marks() { + return suggestionMarks(this.editor); + }, + selectable: blockImplementation.meta?.selectable ?? true, + // Derived from `boundary`: an "open" container lets everything cross its + // edge; "isolated" and "sealed" both map to PM `isolating: true`. + isolating: resolveChildren(children).boundary !== "open", + defining: true, + priority: nodePriority, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return getParseRules(blockConfig, blockImplementation, "container"); + }, + + renderHTML({ HTMLAttributes }) { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", blockConfig.type); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + dom.setAttribute(attribute, value as string); + } + return { dom, contentDOM: dom }; + }, + + addNodeView() { + return (props) => + containerNodeView(blockConfig, blockImplementation, props, { + editor: this.options.editor, + tiptapEditor: this.editor, + blockContentDOMAttributes: + this.options.domAttributes?.blockContent || {}, + }); + }, + }); + + const contentNode = Node.create({ + name: contentName, + group: CONTAINER_CONTENT_GROUP, + content: blockConfig.content === "plain" ? "text*" : "inline*", + marks() { + return blockConfig.content === "plain" + ? nonFormattingMarks(this.editor) + : undefined; + }, + code: blockImplementation.meta?.code ?? false, + defining: true, + priority: nodePriority, + + parseHTML() { + return [{ tag: `[data-content-type=${blockConfig.type}]` }]; + }, + + renderHTML() { + const dom = document.createElement("div"); + dom.className = "bn-inline-content"; + dom.setAttribute("data-content-type", blockConfig.type); + return { dom, contentDOM: dom }; + }, + }); + + const childrenNode = Node.create({ + name: childrenName, + group: CHILD_CONTAINER_GROUP, + content: childrenContentExpression(children), + marks() { + return suggestionMarks(this.editor); + }, + priority: nodePriority, + + parseHTML() { + return [{ tag: `[data-children-of=${blockConfig.type}]` }]; + }, + + renderHTML() { + const dom = document.createElement("div"); + dom.setAttribute("data-children-of", blockConfig.type); + return { dom, contentDOM: dom }; + }, + }); + + return { node, extraNodes: [contentNode, childrenNode] }; +} + +function buildRegularNode< TName extends string, TProps extends PropSchema, TContent extends "inline" | "none" | "table" | "plain", >( blockConfig: BlockConfig, blockImplementation: BlockImplementation, - extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, -): LooseBlockSpec { - const node = - ((blockImplementation as any).node as Node) || - Node.create({ - name: blockConfig.type, - content: (blockConfig.content === "inline" - ? "inline*" - : blockConfig.content === "plain" - ? "text*" - : blockConfig.content === "none" - ? "" - : blockConfig.content) as TContent extends "inline" - ? "inline*" - : TContent extends "plain" - ? "text*" - : "", - // "plain" blocks hold unstyled text, so they disallow formatting marks. - // They still allow the non-formatting marks (comments and - // suggestions/diffs) — those annotate content without changing it and are - // ignored by the block model. `nonFormattingMarks` resolves the group only - // when at least one such mark is registered, so a plain block in an editor - // without any of them doesn't reference an empty (unknown) mark group. - marks() { - return blockConfig.content === "plain" - ? nonFormattingMarks(this.editor) - : undefined; - }, - group: "blockContent", - selectable: blockImplementation.meta?.selectable ?? true, - isolating: blockImplementation.meta?.isolating ?? true, - code: blockImplementation.meta?.code ?? false, - defining: blockImplementation.meta?.defining ?? true, - priority, - addAttributes() { - return propsToAttributes(blockConfig.propSchema); - }, +) { + return Node.create({ + name: blockConfig.type, + content: (blockConfig.content === "inline" + ? "inline*" + : blockConfig.content === "plain" + ? "text*" + : blockConfig.content === "none" + ? "" + : blockConfig.content) as TContent extends "inline" + ? "inline*" + : TContent extends "plain" + ? "text*" + : "", + // "plain" blocks hold unstyled text, so they disallow formatting marks. + // They still allow the non-formatting marks (comments and + // suggestions/diffs), which annotate content without changing it and are + // ignored by the block model. `nonFormattingMarks` resolves the group only + // when at least one such mark is registered, so a plain block in an editor + // without any of them doesn't reference an empty (unknown) mark group. + marks() { + return blockConfig.content === "plain" + ? nonFormattingMarks(this.editor) + : undefined; + }, + group: "blockContent", + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + code: blockImplementation.meta?.code ?? false, + defining: blockImplementation.meta?.defining ?? true, + priority, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, - parseHTML() { - return getParseRules(blockConfig, blockImplementation); - }, + parseHTML() { + return getParseRules(blockConfig, blockImplementation); + }, + + renderHTML({ HTMLAttributes }) { + // renderHTML is used for copy/pasting content from the editor back into + // the editor, so we need to make sure the `blockContent` element is + // structured correctly as this is what's used for parsing blocks. We + // just render a placeholder div inside as the `blockContent` element + // already has all the information needed for proper parsing. + const div = document.createElement("div"); + return wrapInBlockStructure( + { + dom: div, + contentDOM: + blockConfig.content === "inline" || blockConfig.content === "plain" + ? div + : undefined, + }, + blockConfig.type, + {}, + blockConfig.propSchema, + blockImplementation.meta?.fileBlockAccept !== undefined, + HTMLAttributes, + ); + }, - renderHTML({ HTMLAttributes }) { - // renderHTML is used for copy/pasting content from the editor back into - // the editor, so we need to make sure the `blockContent` element is - // structured correctly as this is what's used for parsing blocks. We - // just render a placeholder div inside as the `blockContent` element - // already has all the information needed for proper parsing. - const div = document.createElement("div"); - return wrapInBlockStructure( + addNodeView() { + return (props) => { + // Gets the BlockNote editor instance + const editor = this.options.editor; + // Gets the block. Resolving this can't rely on `getPos()` alone: + // node views are constructed part-way through ProseMirror's + // reconciliation, where positions don't always line up with + // `view.state.doc` yet (see `getBlockFromNodeView`). + const block = getBlockFromNodeView( + props.getPos, + props.node, + props.view.state.doc, + ); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + const nodeView = blockImplementation.render.call( { - dom: div, - contentDOM: - blockConfig.content === "inline" || - blockConfig.content === "plain" - ? div - : undefined, + blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, }, - blockConfig.type, - {}, - blockConfig.propSchema, - blockImplementation.meta?.fileBlockAccept !== undefined, - HTMLAttributes, + block as any, + editor as any, ); - }, - addNodeView() { - return (props) => { - // Gets the BlockNote editor instance - const editor = this.options.editor; - // Gets the block. Resolving this can't rely on `getPos()` alone — - // node views are constructed part-way through ProseMirror's - // reconciliation, where positions don't always line up with - // `view.state.doc` yet (see `getBlockFromNodeView`). - const block = getBlockFromNodeView( - props.getPos, - props.node, - props.view.state.doc, - ); - // Gets the custom HTML attributes for `blockContent` nodes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; + // Cast needed because render returns `dom: HTMLElement | DocumentFragment` + // but tiptap's NodeView expects `dom: HTMLElement` + const typedNodeView = nodeView as unknown as NodeView; - const nodeView = blockImplementation.render.call( - { - blockContentDOMAttributes, - props, - renderType: "nodeView", - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ); + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, this.editor); + } - // Cast needed because render returns `dom: HTMLElement | DocumentFragment` - // but tiptap's NodeView expects `dom: HTMLElement` - const typedNodeView = nodeView as unknown as NodeView; + // Ignores DOM mutations that don't affect the block's content, so + // that browser extensions which rewrite the DOM (e.g. Dark Reader) + // can't trigger an infinite re-render loop that freezes the tab. + ignoreNonContentMutations(typedNodeView); + + // See explanation for why `update` is not implemented for NodeViews + // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 + // TODO: in a future version, we might want to implement updates so that + // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220) + return typedNodeView; + }; + }, + }); +} - if (blockImplementation.meta?.selectable === false) { - applyNonSelectableBlockFix(typedNodeView, this.editor); +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function addNodeAndExtensionsToSpec< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + extensions?: (ExtensionFactoryInstance | Extension)[], + priority?: number, +): LooseBlockSpec { + // A `children` + `content: "table"` combination is rejected by + // `validateChildrenConfigs` when the schema is built. + const childrenConfig = getChildrenConfig(blockConfig); + + const isContainer = childrenConfig !== undefined; + + // A container with its own content is built from three nodes (see + // `buildContentContainerNode`); every other kind of block is a single node. + const built: { node: Node; extraNodes?: Node[] } = ( + blockImplementation as any + ).node + ? { node: (blockImplementation as any).node as Node } + : childrenConfig && blockConfig.content !== "none" + ? buildContentContainerNode( + blockConfig as unknown as BlockConfig< + TName, + TProps, + "inline" | "plain" + >, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "inline" | "plain" + >, + priority, + ) + : childrenConfig + ? { + node: buildContainerNode( + blockConfig as unknown as BlockConfig, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "none" + >, + priority, + ), } + : { + node: buildRegularNode(blockConfig, blockImplementation, priority), + }; - // Ignores DOM mutations that don't affect the block's content, so - // that browser extensions which rewrite the DOM (e.g. Dark Reader) - // can't trigger an infinite re-render loop that freezes the tab. - ignoreNonContentMutations(typedNodeView); - - // See explanation for why `update` is not implemented for NodeViews - // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 - // https://github.com/TypeCellOS/BlockNote/issues/220 - return typedNodeView; - }; - }, - }); + const { node: builtNode, extraNodes } = built; - if (node.name !== blockConfig.type) { + if (builtNode.name !== blockConfig.type) { throw new Error( "Node name does not match block type. This is a bug in BlockNote.", ); } + // The block's config is stored on its nodes' PM specs + // (`NodeSpec.blockConfig`), so code holding a bare `Node` can consult it + // without an editor or schema reference. Generated `__content`/`__children` + // nodes carry their owning block's config. (`extendNodeSchema` hooks run + // for every node in the schema, hence the name gate.) + const specNodeNames = new Set([ + builtNode.name, + ...(extraNodes?.map((extraNode) => extraNode.name) ?? []), + ]); + const node = builtNode.extend({ + extendNodeSchema(extension) { + return specNodeNames.has(extension.name) ? { blockConfig } : {}; + }, + }); + return { config: blockConfig, implementation: { ...blockImplementation, node, + ...(extraNodes ? { extraNodes } : {}), render(block, editor) { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - return blockImplementation.render.call( + const output = blockImplementation.render.call( { blockContentDOMAttributes, props: undefined, @@ -317,6 +753,18 @@ export function addNodeAndExtensionsToSpec< block as any, editor as any, ); + + if (isContainer) { + applyContainerAttributes( + containerRootDOM(output), + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + } + + return output; }, // TODO: this should not have wrapInBlockStructure and generally be a lot simpler // post-processing in externalHTMLExporter should not be necessary @@ -324,7 +772,7 @@ export function addNodeAndExtensionsToSpec< const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - return ( + const output = blockImplementation.toExternalHTML?.call( { blockContentDOMAttributes, propSchema: blockConfig.propSchema }, block as any, @@ -340,8 +788,19 @@ export function addNodeAndExtensionsToSpec< }, block as any, editor as any, - ) - ); + ); + + if (output && isContainer) { + applyContainerAttributes( + containerRootDOM(output), + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + } + + return output; }, }, extensions, @@ -452,6 +911,8 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + const isContainer = getChildrenConfig(blockConfig) !== undefined; + return { config: blockConfig, implementation: { @@ -470,6 +931,11 @@ export function createBlockSpec< return undefined; } + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -489,6 +955,11 @@ export function createBlockSpec< editor as any, ); + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + const nodeView = wrapInBlockStructure( output, block.type, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index cfd17b9d11..49135f9f7f 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,7 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; -import { LooseBlockSpec } from "./types.js"; +import { BlockConfig, ChildrenConfig, LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -157,6 +157,26 @@ export function getBlockFromNodeView( } } +/** + * Applies custom `blockContent` DOM attributes to an element, merging (rather + * than overwriting) its class list. + */ +export function applyDOMAttributes( + dom: HTMLElement | DocumentFragment, + domAttributes: Record | undefined, +) { + if (!domAttributes || !(dom instanceof HTMLElement)) { + return; + } + for (const [attr, value] of Object.entries(domAttributes)) { + if (attr === "class") { + dom.className = mergeCSSClasses(dom.className, value); + } else { + dom.setAttribute(attr, value); + } + } +} + // Function that wraps the `dom` element returned from 'blockConfig.render' in a // `blockContent` div, which contains the block type and props as HTML // attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds @@ -232,6 +252,12 @@ export function createBlockSpecFromTiptapNode< node: Node; type: string; content: "inline" | "table" | "none" | "plain"; + // Declares the block's container semantics (child counts/repair etc.) + // even though the node itself is hand-written. The node's own content + // expression stays authoritative for the PM schema, while BlockNote-level + // behavior (repair, seeding, validation) reads this config. + children?: ChildrenConfig; + placement?: BlockConfig["placement"]; }, P extends PropSchema, >( @@ -244,6 +270,10 @@ export function createBlockSpecFromTiptapNode< type: config.type as T["type"], content: config.content, propSchema, + ...(config.children !== undefined ? { children: config.children } : {}), + ...(config.placement !== undefined + ? { placement: config.placement } + : {}), }, implementation: { node: config.node, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 8d7e203e61..8c1b3975a1 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,11 +1,7 @@ /** Define the main block types **/ // import { Extension, Node } from "@tiptap/core"; import type { Node, NodeViewRendererProps } from "@tiptap/core"; -import type { - Fragment, - Node as ProsemirrorNode, - Schema, -} from "prosemirror-model"; +import type { Fragment, Node as PMNode, Schema } from "prosemirror-model"; import type { ViewMutationRecord } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { @@ -67,6 +63,16 @@ export interface BlockConfigMeta< */ isolating?: boolean; + /** + * Whether this block type gets a side menu drag handle (and can be dragged + * by it). Applies to any block type, container or not: e.g. a + * "locked" block can opt out of dragging entirely. A block that opts out is + * skipped when looking for a drag handle, so the handle falls through to the + * nearest draggable ancestor. + * @default true + */ + draggable?: boolean; + /** * Enables syntax highlighting of the contents of the block with the result of this callback */ @@ -80,6 +86,98 @@ export interface BlockConfigMeta< hasPreview?: boolean; } +/** + * What may appear as a child of a container block. + * + * - `"any"`: any regular block, or any container block placeable anywhere. + * - `"blocks"`: regular (non-container) blocks only. This cannot be narrowed + * to specific block types: every regular block is the *same* ProseMirror + * node (`blockContainer`), so paragraphs, headings and code blocks are + * indistinguishable at the node level. + * - `"containers"`: any container block placeable anywhere, no regular blocks. + * - `readonly string[]`: only these types, enforced exactly by the schema. + * Today the array may only name *container* block types (naming a regular + * block type is a startup error); per-type regular-block filtering can be + * added to this same form later, with no API change. + * + * The wildcards (`"any"`, `"containers"`) never include + * `placement: "containerOnly"` types. Those appear only where a parent names + * them explicitly in an array. + */ +export type ChildrenAllow = "any" | "blocks" | "containers" | readonly string[]; + +/** + * Marks a block as a *container*: a block whose body is other blocks, exposed + * as `block.children` at runtime. + * + * The config describes one uniform body, semantically a single implicit + * slot. Ordered multi-slot bodies (a `sequence` of slots) can be added later + * as a sibling form. + */ +export type ChildrenConfig = { + /** What may appear as a child. See {@link ChildrenAllow}. */ + allow: ChildrenAllow; + /** @default 1 */ + min?: number; + /** @default unbounded */ + max?: number; + /** + * Children to create the container with when it is inserted without an + * explicit `children` array. When omitted, BlockNote fills the container + * with whatever its content expression requires (usually one empty + * paragraph), so a container can never be created in an invalid state. + * + * Also the seed that `whenEmptied: "refill"` tops up from, when children + * drop below `min`. + */ + default?: readonly PartialBlockNoDefaults[]; + /** + * What happens as children are emptied out (Backspace merges the last child + * away, `removeBlocks` deletes children, ...) and fewer than `min` non-empty + * children remain: + * + * - `"refill"` (the default): drop the emptied children and top the + * container back up to `min`, seeding the missing positions from the + * unconsumed tail of `default` (falling back to empty blocks when + * `default` is absent or too short). + * - `"unwrap"`: drop the emptied children and replace the container with its + * survivors, or remove it entirely when none remain. Column lists use this + * so emptied columns disappear and a one-column list unwraps. + * + * Coupled to the child count, so it lives here rather than in `meta`: + * ProseMirror's schema fitting always pads a container back up to its + * minimum with empty children, so "effectively below the minimum" can only + * be detected by discounting those. + * @default "refill" + */ + whenEmptied?: "refill" | "unwrap"; + /** + * What may cross the container's edge. + * + * - `"open"`: the caret, editing gestures and text selections all cross + * the edge (ProseMirror `isolating: false`). Right for flow regions like + * column lists, where a selection may span columns. + * - `"isolated"` (the default): the caret and editing gestures cross + * exactly as with `"open"`; only a text selection cannot span the edge + * (`isolating: true`). + * - `"sealed"`: atomic to gestures, like a table cell. The caret doesn't + * enter via arrows/Backspace, and the block selects as a unit + * (`isolating: true`). Key-agnostic, so compartments need no hand-written + * keyboard handlers. + * + * Seals bind editing gestures only: the block manipulation API + * (`insertBlocks` etc.) ignores them. + * @default "isolated" + */ + boundary?: "open" | "isolated" | "sealed"; +}; + +// `ResolvedChildren`, the fully-defaulted, desugared shape a `ChildrenConfig` +// compiles to, is internal machinery, not part of the consumer-facing config +// surface. So it lives in `./children.ts` (which is not re-exported wholesale) +// rather than here, where `export *` would leak it onto `@blocknote/core`'s +// public types. + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -106,8 +204,44 @@ export interface BlockConfig< * The content that the block supports */ content: C; - // TODO: how do you represent things that have nested content? - // e.g. tables, alerts (with title & content) + /** + * Makes this a *container* block: a block whose body is other blocks, + * exposed on `block.children`. The block's `render` places them via + * `contentRef` (React) / `contentDOM` (vanilla), the same way it would place + * inline content. + * + * Can be combined with `content: "inline"` / `"plain"`, in which case the + * block has its own content *and* children, and both are placed in that one + * editable region. Only `content: "table"` is incompatible. + * + * `children: { allow: "any" }` is the minimal container. + */ + children?: ChildrenConfig; + /** + * Where this block may be placed. + * + * - `"anywhere"` (default): anywhere a regular block goes, the document + * root or nested under any other block. + * - `"containerOnly"`: only inside a container that names this type in its + * `children.allow` array (e.g. a `column` inside a `columnList`). + * + * Only meaningful for container blocks; regular blocks are always placeable + * anywhere. + */ + placement?: "anywhere" | "containerOnly"; +} + +declare module "prosemirror-model" { + interface NodeSpec { + /** + * The config of the BlockNote block this node was built from, so code + * holding a bare `Node` can read block-level facts (children config, + * placement, ...) without an editor or schema reference. Set on every + * node built from a block spec; a container's generated + * `__content`/`__children` nodes carry their owning block's config. + */ + blockConfig?: BlockConfig; + } } /** @@ -227,9 +361,11 @@ export type LooseBlockSpec< ) => { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; + /** See {@link BlockImplementation.render}'s `rootDOM`. */ + rootDOM?: HTMLElement | null; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -246,6 +382,12 @@ export type LooseBlockSpec< | undefined; node: Node; + /** + * Nodes the block's own node needs in the schema but which aren't blocks + * themselves: the generated content & children nodes of a container block + * that has its own content. Registered alongside `node`. + */ + extraNodes?: Node[]; }; extensions?: (Extension | ExtensionFactoryInstance)[]; }; @@ -286,9 +428,11 @@ export type BlockSpecs = { ) => { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; + /** See {@link BlockImplementation.render}'s `rootDOM`. */ + rootDOM?: HTMLElement | null; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -590,19 +734,31 @@ export type BlockImplementation< ) => { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; + /** + * The block author's own root element, when it isn't `dom` itself. React + * renders a node view through wrapper elements of its own, so the element + * ProseMirror is handed is not the one the author wrote. This points at + * the author's element, which container attributes (`data-node-type`, + * `data-id`, prop `data-*`) are applied to. + * @default dom + */ + rootDOM?: HTMLElement | null; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + destroy?: () => void; /** - * Called by ProseMirror when this block's node is updated (e.g. its content - * or props change). Return `true` to handle the update in place - keeping - * the existing DOM - or `false` to have the node view recreated via - * `render`. When omitted, ProseMirror keeps the node view and reconciles its - * `contentDOM` in place as long as the node type stays the same. + * Optional NodeView update hook. Called when the underlying ProseMirror + * node's attributes change (or its decorations change). Return `false` to + * tell ProseMirror to destroy and recreate the NodeView (i.e. re-run + * `render` from scratch). Return `true` (or `undefined`) when you have + * patched `dom` in-place and PM should keep the existing view. * - * Useful for blocks whose `render` builds custom DOM that needs to stay in - * sync with the node (e.g. a code block rendering a preview of its content). + * Only honored for container blocks (blocks with `children`), where + * recreating the node view would remount every child block: e.g. column + * resizing patches widths in place through this hook. Non-container + * blocks always recreate on attr changes (see + * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464). */ - update?: (node: ProsemirrorNode) => boolean; - destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; /** diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts new file mode 100644 index 0000000000..08a7e34f57 --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -0,0 +1,408 @@ +import { + containerChildrenNodeName, + containerContentNodeName, + getChildrenConfig, + isContainerType, + isPlaceableAnywhere, + resolveChildren, +} from "./children.js"; +import type { ResolvedChildren } from "./children.js"; +import type { BlockConfig, ChildrenConfig } from "./types.js"; + +type ValidatableConfig = Pick & { + children?: ChildrenConfig; + placement?: BlockConfig["placement"]; +}; + +/** + * Validates the `children` config of every block in a schema, so that + * misconfigurations are reported as a clear error at schema-creation time + * instead of as an opaque ProseMirror one (or a stack overflow) much later. + * + * @param blockConfigs The configs of every block in the schema, keyed by type. + */ +export function validateChildrenConfigs( + blockConfigs: Record, +) { + const isContainerBlockType = (blockType: string) => + !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]); + const acceptCtx = { + isContainerBlockType, + isPlaceableAnywhereType: (blockType: string) => + !!blockConfigs[blockType] && isPlaceableAnywhere(blockConfigs[blockType]), + }; + + for (const [type, config] of Object.entries(blockConfigs)) { + const children = getChildrenConfig(config); + + if (!children) { + // `placement: "anywhere"` is the documented default for every block, so + // writing it on a regular block is a harmless restatement. Only + // `"containerOnly"` is meaningless without `children`. + if (config.placement === "containerOnly") { + fail( + type, + '`placement: "containerOnly"` only applies to container blocks, but this block does not declare `children`. Regular blocks can always be placed anywhere.', + ); + } + continue; + } + + validateOne(type, config, children, blockConfigs, acceptCtx); + } + + validateContainerOnlyIsReachable(blockConfigs); + validateNoCycles(blockConfigs, isContainerBlockType); +} + +function fail(type: string, message: string): never { + throw new Error( + `Invalid \`children\` config for block "${type}": ${message}`, + ); +} + +type AllowAcceptContext = { + isContainerBlockType: (blockType: string) => boolean; + isPlaceableAnywhereType: (blockType: string) => boolean; +}; + +function validateOne( + type: string, + config: ValidatableConfig, + children: ChildrenConfig, + blockConfigs: Record, + acceptCtx: AllowAcceptContext, +) { + // A container may have its own content: it then becomes a node holding a + // content node and a children node. A table can't. Its content is already + // a node tree of its own, with nowhere to put the children node. + if (config.content === "table") { + fail( + type, + '`children` cannot be combined with `content: "table"`. A table block\'s content is already a structure of its own.', + ); + } + + if (config.content !== "none") { + // The content & children nodes are generated from the block type, so a + // block type that happens to have the generated name would silently + // overwrite one of them. + for (const generated of [ + containerContentNodeName(type), + containerChildrenNodeName(type), + ]) { + if (generated in blockConfigs) { + fail( + type, + `it has its own content as well as \`children\`, so it generates a node named "${generated}", which collides with the block type of the same name. Rename one of the two.`, + ); + } + } + } + + // Mirror the type-level contract for JS consumers: `allow` is required, and + // takes exactly the four forms. Widened to `unknown` because the type + // narrowing would otherwise leave `never` for the message. + const allow: unknown = children.allow; + if (allow === undefined) { + fail( + type, + '`allow` is required. Use `children: { allow: "any" }` for a container that accepts any block.', + ); + } + if ( + !Array.isArray(allow) && + allow !== "any" && + allow !== "blocks" && + allow !== "containers" + ) { + fail( + type, + `\`allow\` must be "any", "blocks", "containers" or an array of container block types, but is ${JSON.stringify(allow)}.`, + ); + } + + const boundary: string | undefined = children.boundary; + if ( + boundary !== undefined && + boundary !== "open" && + boundary !== "isolated" && + boundary !== "sealed" + ) { + fail( + type, + `\`boundary\` must be "open", "isolated" or "sealed", but is "${boundary}".`, + ); + } + + const resolved = resolveChildren(children); + + if (!Number.isInteger(resolved.min) || resolved.min < 0) { + fail( + type, + `minimum child count must be a non-negative integer, but is ${resolved.min}.`, + ); + } + if (resolved.max !== undefined) { + if (!Number.isInteger(resolved.max) || resolved.max < 1) { + fail( + type, + `maximum child count must be a positive integer, but is ${resolved.max}.`, + ); + } + if (resolved.max < resolved.min) { + fail( + type, + `maximum child count (${resolved.max}) must be greater than or equal to the minimum (${resolved.min}).`, + ); + } + } + + validateAllow(type, resolved, blockConfigs, acceptCtx); + validateDefault(type, resolved, blockConfigs, acceptCtx); +} + +function validateAllow( + type: string, + resolved: ResolvedChildren, + blockConfigs: Record, + { isContainerBlockType, isPlaceableAnywhereType }: AllowAcceptContext, +) { + if (resolved.containers !== true) { + for (const allowed of resolved.containers) { + if (!(allowed in blockConfigs)) { + fail( + type, + `\`allow\` contains "${allowed}", which is not a block type in this schema.`, + ); + } + // An `allow` array is exact by construction: each named type is its own + // ProseMirror node. Every *regular* block, by contrast, is the same node + // (`blockContainer`), so naming one here would promise a restriction the + // schema cannot keep. + if (!isContainerBlockType(allowed)) { + fail( + type, + `\`allow\` contains "${allowed}", which is a regular block, not a container block. ` + + "Restricting which regular block types a container accepts is not yet supported, as every regular block is the same ProseMirror node. " + + 'Use `allow: "blocks"` to accept all regular blocks, or name only container block types.', + ); + } + } + } + + if ( + !resolved.blocks && + resolved.containers !== true && + resolved.containers.length === 0 + ) { + fail( + type, + "`allow` permits nothing. A container must accept at least one block or container type; drop `children` entirely for a block that holds none.", + ); + } + + if (!resolved.blocks && resolved.containers === true) { + // The wildcard compiles to the containers placeable anywhere, so only + // those make the container fillable. `containerOnly` blocks are never + // included. + const hasContainer = Object.keys(blockConfigs).some( + (blockType) => + isContainerBlockType(blockType) && + blockType !== type && + isPlaceableAnywhereType(blockType), + ); + if (!hasContainer) { + fail( + type, + "`allow` permits only container blocks, but this schema has no other container block types placeable anywhere. " + + 'The `"containers"` wildcard never includes `placement: "containerOnly"` blocks. Name those explicitly in an `allow` array.', + ); + } + } +} + +function validateDefault( + type: string, + resolved: ResolvedChildren, + blockConfigs: Record, + acceptCtx: AllowAcceptContext, +) { + const { default: defaultChildren, min, max } = resolved; + if (!defaultChildren) { + return; + } + + if (defaultChildren.length < min) { + fail( + type, + `\`default\` has ${defaultChildren.length} block(s), fewer than the ${min} required.`, + ); + } + if (max !== undefined && defaultChildren.length > max) { + fail( + type, + `\`default\` has ${defaultChildren.length} block(s), more than the ${max} allowed.`, + ); + } + + for (const child of defaultChildren) { + const childType = child.type ?? "paragraph"; + if (!(childType in blockConfigs)) { + fail( + type, + `\`default\` contains a block of type "${childType}", which is not a block type in this schema.`, + ); + } + + if (!allowAccepts(resolved, childType, acceptCtx)) { + fail( + type, + `\`default\` contains a block of type "${childType}", which is not permitted.`, + ); + } + } +} + +/** + * Whether a container's `allow` accepts a block type. Matches what the schema + * enforces: the only lever for regular blocks is whether `blockContainer` is + * in the content expression, and the container wildcards compile to the + * containers placeable anywhere, so a `placement: "containerOnly"` block is + * only accepted where it is named explicitly. + */ +function allowAccepts( + resolved: ResolvedChildren, + blockType: string, + ctx: AllowAcceptContext, +): boolean { + if (ctx.isContainerBlockType(blockType)) { + return resolved.containers === true + ? ctx.isPlaceableAnywhereType(blockType) + : resolved.containers.includes(blockType); + } + return resolved.blocks; +} + +/** + * Container nodes register in a priority band strictly below `blockContainer` + * (see `containerNodePriority`), which is below every regular block. So a + * container's `runsBefore` can only order it against other containers. Naming + * a regular block there promises an ordering the schema cannot produce. + * + * @param blockConfigs The configs of every block in the schema, keyed by type. + * @param runsBefore The `runsBefore` each block's implementation declares. + */ +export function validateContainerRunsBefore( + blockConfigs: Record, + runsBefore: Record, +) { + for (const [type, config] of Object.entries(blockConfigs)) { + if (!isContainerType(config)) { + continue; + } + + for (const other of runsBefore[type] ?? []) { + // "default" is `sortByDependencies`' reference point rather than a + // block type. A type that isn't in the schema is not this check's + // concern. + if (other === "default" || !(other in blockConfigs)) { + continue; + } + if (!isContainerType(blockConfigs[other])) { + throw new Error( + `Invalid \`runsBefore\` for container block "${type}": it names "${other}", which is a regular block, not a container block. ` + + "Container block nodes always register below regular ones, so a container can never be ordered before a regular block. " + + "`runsBefore` on a container can only name other container blocks.", + ); + } + } + } +} + +/** + * A `placement: "containerOnly"` block that no container accepts could never + * be inserted anywhere, which is always a mistake rather than a choice. + * + * Only explicit `allow` arrays count: the container wildcards compile to the + * containers placeable anywhere, so they never accept a `containerOnly` + * block. Otherwise deliberately conservative. Proving that the block is + * reachable from a block placeable at the root is full graph reachability, + * and this check only exists to catch typos. + */ +function validateContainerOnlyIsReachable( + blockConfigs: Record, +) { + const accepted = new Set(); + for (const config of Object.values(blockConfigs)) { + const children = getChildrenConfig(config); + if (!children) { + continue; + } + const { containers } = resolveChildren(children); + if (containers === true) { + continue; + } + for (const allowed of containers) { + accepted.add(allowed); + } + } + + for (const [type, config] of Object.entries(blockConfigs)) { + if (!isPlaceableAnywhere(config) && !accepted.has(type)) { + fail( + type, + `it declares \`placement: "containerOnly"\`, but no container's \`children.allow\` array includes it, so it could never be inserted.`, + ); + } + } +} + +/** + * A container that requires a child which in turn requires it back can never + * be created: ProseMirror's `fillBefore` recurses across node types and + * overflows the stack rather than returning `null`. So this has to be caught + * statically, before the schema is built. + */ +function validateNoCycles( + blockConfigs: Record, + isContainerBlockType: (blockType: string) => boolean, +) { + // A container that allows regular blocks can always be filled with a plain + // paragraph, so it never forces recursion. Only container-only lists do. + const requiredContainers = (type: string): string[] => { + const children = getChildrenConfig(blockConfigs[type]); + if (!children) { + return []; + } + const resolved = resolveChildren(children); + return resolved.min >= 1 && !resolved.blocks && resolved.containers !== true + ? resolved.containers.filter(isContainerBlockType) + : []; + }; + + const state = new Map(); + + const visit = (type: string, path: string[]) => { + const seen = state.get(type); + if (seen === "done") { + return; + } + if (seen === "visiting") { + fail( + type, + `it requires a child that requires it back (${[...path, type].join(" -> ")}), so it could never be created. Allow regular blocks in one of the containers to break the cycle.`, + ); + } + + state.set(type, "visiting"); + for (const next of requiredContainers(type)) { + visit(next, [...path, type]); + } + state.set(type, "done"); + }; + + for (const type of Object.keys(blockConfigs)) { + visit(type, []); + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..967a65bb5e 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,3 +1,10 @@ +// `children.js` and `validateChildren.js` are deliberately *not* re-exported +// wholesale: almost everything in them is machinery for compiling a `children` +// config into a ProseMirror content expression, which lives on +// `@blocknote/core/internal` (see `src/internal.ts`). Only the question a +// block author asks, "is this a container?", belongs here; the config types +// come from `./blocks/types.js` below. +export { isContainerType } from "./blocks/children.js"; export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index a7a04e93dc..b69ba53fbf 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -16,6 +16,10 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "./index.js"; +import { + validateChildrenConfigs, + validateContainerRunsBefore, +} from "./blocks/validateChildren.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -91,6 +95,26 @@ export class CustomBlockNoteSchema< })), ); + // Validation runs before the nodes are built, so misconfigurations + // surface as clear errors rather than as opaque ProseMirror ones. + const blockConfigs = Object.fromEntries( + Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [ + key, + blockSpec.config, + ]), + ); + + validateChildrenConfigs(blockConfigs); + validateContainerRunsBefore( + blockConfigs, + Object.fromEntries( + Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [ + key, + blockSpec.implementation?.runsBefore, + ]), + ), + ); + const blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { return [ diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts index df0267f093..f752b48182 100644 --- a/packages/core/src/y/extensions/AttributionExtension.test.ts +++ b/packages/core/src/y/extensions/AttributionExtension.test.ts @@ -17,15 +17,14 @@ const editors: BlockNoteEditor[] = []; // No Yjs/collaboration needed — the extension's load plugin only cares that a // transaction adds a `y-attributed-*` mark, which we do directly below. function createEditor() { - const resolveUsers = vi.fn( - async (ids: string[]): Promise => - ids.map((id) => ({ - id, - username: `name-${id}`, - avatarUrl: "", - color: "#123456", - colorLight: "#abcdef", - })), + const resolveUsers = vi.fn(async (ids: string[]): Promise => + ids.map((id) => ({ + id, + username: `name-${id}`, + avatarUrl: "", + color: "#123456", + colorLight: "#abcdef", + })), ); const editor = BlockNoteEditor.create({ diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts index 37fb1fd4e9..7dc3f4253d 100644 --- a/packages/core/src/yjs/extensions/FixUpSchema.ts +++ b/packages/core/src/yjs/extensions/FixUpSchema.ts @@ -25,7 +25,15 @@ export const FixUpSchemaExtension = createExtension(({ editor }) => { // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) const jsonNode = JSON.parse(JSON.stringify(ret.toJSON())); - jsonNode.content[0].content[0].attrs.id = "initialBlockId"; + // The first fill of the doc's blockGroup is guaranteed to be a + // `blockContainer` (container block nodes register at lower priority + // precisely so auto-fill picks `blockContainer` first), but guard on + // the node actually carrying an id attr in case a custom schema + // changes that. + const firstBlock = jsonNode.content?.[0]?.content?.[0]; + if (firstBlock?.attrs && "id" in firstBlock.attrs) { + firstBlock.attrs.id = "initialBlockId"; + } cache = Node.fromJSON(schema, jsonNode); return cache; diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 2763b9723c..4f743d69fe 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ blocks: path.resolve(__dirname, "src/blocks/index.ts"), locales: path.resolve(__dirname, "src/i18n/index.ts"), extensions: path.resolve(__dirname, "src/extensions/index.ts"), + internal: path.resolve(__dirname, "src/internal.ts"), yjs: path.resolve(__dirname, "src/yjs/index.ts"), y: path.resolve(__dirname, "src/y/index.ts"), }, diff --git a/packages/core/vitestSetup.ts b/packages/core/vitestSetup.ts index bf9678c8f8..cc3bdd45f1 100644 --- a/packages/core/vitestSetup.ts +++ b/packages/core/vitestSetup.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach } from "vite-plus/test"; +// This setup file also runs for test files that opt into the plain `node` +// environment (`@vitest-environment node`), where there is no `window` at +// all. `__TEST_OPTIONS` (which drives deterministic block IDs) is therefore +// set on `window` when there is one and on `globalThis` otherwise, matching +// the resolution `UniqueID`'s `generateID` uses. +const testHost: any = (globalThis as any).window ?? globalThis; + beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; + testHost.__TEST_OPTIONS = {}; }); afterEach(() => { - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + delete testHost.__TEST_OPTIONS; }); // Mock ClipboardEvent @@ -19,7 +26,7 @@ class ClipboardEventMock extends Event { }, }; } -(global as any).ClipboardEvent = ClipboardEventMock; +(globalThis as any).ClipboardEvent = ClipboardEventMock; // Mock DragEvent class DragEventMock extends Event { @@ -32,4 +39,4 @@ class DragEventMock extends Event { }, }; } -(global as any).DragEvent = DragEventMock; +(globalThis as any).DragEvent = DragEventMock; diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 2bf0e4fa57..a79a935078 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -1,4 +1,4 @@ -import { getNodeById } from "@blocknote/core"; +import { getNodeById, isContainerNode } from "@blocknote/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -29,6 +29,28 @@ export const BlockPopover = ( return undefined; } + // For container blocks the PM node is the block itself, so a + // position inside it resolves to its contentDOM (the child-blocks + // area), which would anchor the popover to the first child's rows + // instead of the block's own element. + if (isContainerNode(nodePosInfo.node.type)) { + const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode); + // Frameworks like React wrap the node view in a `display: contents` + // element that has no box of its own (a zero-size bounding rect), so + // anchoring to it would place the popover at (0, 0). The block's + // actual box is the author's root element inside it, which core + // stamps with `data-node-type`; vanilla containers render that boxed + // element directly as the node view's DOM. + if (dom instanceof Element) { + const boxed = dom.matches("[data-node-type]") + ? dom + : dom.querySelector("[data-node-type]"); + if (boxed) { + return { element: boxed }; + } + } + } + const { node } = editor.prosemirrorView.domAtPos( nodePosInfo.posBeforeNode + 1, ); diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 507f2cd46f..c3a39005a5 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -111,6 +111,13 @@ width: 100%; } +/* Container blocks own their outer DOM: the block's root element is the one + its `render` returned, so the wrapper React needs around it must not be a + box of its own. */ +.bn-react-node-view-renderer.bn-container-node-view { + display: contents; +} + /* Indent line styling */ .bn-block-group .bn-block:not(:has(.bn-toggle-wrapper)) diff --git a/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx new file mode 100644 index 0000000000..a38b8195a9 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx @@ -0,0 +1,199 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +/** + * Tests for React container blocks in a real browser. + * + * Everything here needs a real DOM: the external-HTML path renders the block + * through a temporary `createRoot` (see `@util/ReactRenderUtil`), and a React + * node view only runs once `contentComponent` is set, which happens when + * `BlockNoteViewRaw` mounts the editor. Document-model behaviour of + * containers in general is covered by the core suites in + * `api/blockManipulation/containers/`. + */ + +// A pure container: its `contentRef` element holds its child blocks. +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { flavor: { default: "tip" } }, + content: "none", + children: { allow: "any", default: [{ type: "paragraph" }] }, + }, + { + render: (props) => ( +
+
+
+ ), + }, +); + +// Adding `children` to an existing block takes one config line and no render +// changes. This render is the structure every inline-content React block +// already has, `contentRef` on a plain div +// (`examples/06-custom-schema/01-alert-block` reduced to its structure). +const createAlertWithBody = createReactBlockSpec( + { + type: "alertWithBody", + propSchema: { flavor: { default: "warning" } }, + content: "inline", + children: { allow: "any" }, + }, + { + render: ({ contentRef }) => ( +
+
+
+
+ ), + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + callout: createCallout(), + alertWithBody: createAlertWithBody(), + }, +}); + +describe("React container block document model", () => { + it("an inline-content block gains a body by adding `children` alone", () => { + const headless = BlockNoteEditor.create({ schema }); + + headless.replaceBlocks(headless.document, [ + { + id: "b-0", + type: "alertWithBody", + content: "Heads up", + children: [{ id: "b-child", type: "paragraph", content: "Details" }], + }, + ] as any); + + const block = headless.getBlock("b-0")!; + expect(block.content).toEqual([ + { type: "text", text: "Heads up", styles: {} }, + ]); + expect(block.children.map((child: any) => child.id)).toEqual(["b-child"]); + // The child is an ordinary block of the document, reachable by id. + expect(headless.getBlock("b-child")).toBeDefined(); + }); +}); + +describe("React container block external HTML", () => { + it("serializes the author's own root element, unwrapped", () => { + const editor = BlockNoteEditor.create({ schema }); + + const html = editor.blocksToHTMLLossy([ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }], + }, + ] as any); + + // Container blocks own their outer DOM entirely. Regression test for the + // React `toExternalHTML` path wrapping them in a spurious + // `bn-block-content` div (core's `createBlockSpec` passes them through). + // The root is the element `render` returned, with no React wrapper in + // between, so `.callout[data-*]` CSS matches it here exactly as in the + // live editor. + expect(html).not.toContain('data-content-type="callout"'); + expect(html).not.toContain("data-node-view-wrapper"); + expect(html).toContain('class="callout"'); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain("Hello"); + }); +}); + +let root: Root | undefined; +let div: HTMLDivElement | undefined; +let editor: BlockNoteEditor | undefined; + +afterEach(() => { + root?.unmount(); + root = undefined; + if (div) { + document.body.removeChild(div); + div = undefined; + } + editor?._tiptapEditor.destroy(); + editor = undefined; +}); + +/** Lets TipTap's deferred node-view render and React's commit run. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function mountEditor(initialContent: any[]) { + div = document.createElement("div"); + document.body.appendChild(div); + + editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent, + }) as BlockNoteEditor; + + root = createRoot(div); + flushSync(() => { + root!.render(); + }); + // TipTap only renders a node view synchronously when this is set; BlockNote + // mounts the editor itself and never does, so the first batch of node views + // takes the deferred path (see `tests/src/unit/react/staleNodeViewPos.test.tsx`). + (editor as any)._tiptapEditor.isEditorContentInitialized = true; + await tick(); + + return { editor: editor!, div: div! }; +} + +describe("React container block node view", () => { + it("stamps only non-default props onto the block's own root, and keeps them in sync", async () => { + const mounted = await mountEditor([ + { id: "c-0", type: "callout", children: [{ type: "paragraph" }] }, + ]); + + const calloutRoot = mounted.div.querySelector(".callout")!; + // The author's element, not `div.react-renderer` or the node view + // wrapper: exactly the class the author wrote, and nothing else. + expect(calloutRoot.className).toBe("callout"); + expect(calloutRoot.getAttribute("data-id")).toBe("c-0"); + // `flavor` is at its default, so no attribute is written for it. + expect(calloutRoot.hasAttribute("data-flavor")).toBe(false); + + mounted.editor.updateBlock("c-0", { props: { flavor: "warning" } } as any); + await tick(); + + // Re-queried: a prop change must land on whatever element is now the + // block's root, so `.callout[data-flavor="warning"]` selects in the live + // editor exactly as it does in the serialized HTML above. + expect( + mounted.div + .querySelector(".callout")! + .getAttribute("data-flavor"), + ).toBe("warning"); + }); + + it("mounts a pure container's children inside its `contentRef` element", async () => { + const mounted = await mountEditor([ + { + id: "c-0", + type: "callout", + children: [{ id: "c-child", type: "paragraph", content: "Child" }], + }, + ]); + + const body = mounted.div.querySelector(".callout-body")!; + // A container with no content of its own puts its children where the + // author placed `contentRef`, not somewhere else in the node view. The + // child's own block element is a descendant, so this checks structure, + // not just text that happened to bubble up. + expect(body.querySelector('[data-id="c-child"]')).not.toBeNull(); + expect(body.textContent).toBe("Child"); + }); +}); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 5311d4e37d..5946b0c96d 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -1,3 +1,4 @@ +import { applyContainerAttributes } from "@blocknote/core/internal"; import { BlockConfig, BlockConfigOrCreator, @@ -6,11 +7,14 @@ import { BlockNoteEditor, BlockSpec, camelToDataKebab, + ChildrenConfig, CustomBlockImplementation, Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, + isContainerType, mergeCSSClasses, + nodeToBlock, Props, PropSchema, } from "@blocknote/core"; @@ -20,12 +24,29 @@ import { ReactNodeViewRenderer, useReactNodeView, } from "@tiptap/react"; -import { FC, ReactNode } from "react"; +import { CSSProperties, FC, ReactNode, useLayoutEffect } from "react"; import { renderToDOMSpec } from "./@util/ReactRenderUtil.js"; import { useNodeViewBlock } from "./useNodeViewBlock.js"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks +// A container block's root element is the block's own element, so every +// wrapper React puts above it has to contribute no box of its own. Module +// scope so the style object is referentially stable across renders. +const DISPLAY_CONTENTS: CSSProperties = { display: "contents" }; + +/** + * Whether the block has an editable region for its `render` to place: its + * inline content, its child blocks, or both for a container that also has + * its own content. Only a `content: "none"` block without `children` has + * nothing to place, so it is the only kind that doesn't get a `contentRef`. + */ +type HasEditableRegion = Config extends { children: ChildrenConfig } + ? true + : Config extends { content: "none" } + ? false + : true; + export type ReactCustomBlockRenderProps< B extends BlockConfigOrCreator, Config extends ExtractBlockConfigFromConfigOrCreator = @@ -33,11 +54,16 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; -} & (Config["content"] extends "inline" | "plain" - ? { - contentRef: (node: HTMLElement | null) => void; - } - : object); +} & (Config["content"] extends "table" + ? object + : HasEditableRegion extends true + ? { + // Points to where the block's editable region mounts: its inline + // content, its child blocks, or, for a container that has its own + // content, its content followed by its children. + contentRef: (node: HTMLElement | null) => void; + } + : object); // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< @@ -131,20 +157,20 @@ export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, const TContent extends "inline" | "none" | "plain", + // Inferred from the config object itself rather than widened to + // `BlockConfig<...>`, so `children` survives into the render props and + // `contentRef` is offered exactly when the block has an editable region. + const BlockConf extends BlockConfig, const TOptions extends Record | undefined = undefined, >( - blockConfigOrCreator: BlockConfig, + blockConfigOrCreator: BlockConf, blockImplementationOrCreator: - | ReactCustomBlockImplementation> + | ReactCustomBlockImplementation | (TOptions extends undefined - ? () => ReactCustomBlockImplementation< - BlockConfig - > + ? () => ReactCustomBlockImplementation : ( options: Partial, - ) => ReactCustomBlockImplementation< - BlockConfig - >), + ) => ReactCustomBlockImplementation), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] | (TOptions extends undefined @@ -152,7 +178,13 @@ export function createReactBlockSpec< : ( options: Partial, ) => (ExtensionFactoryInstance | Extension)[]), -): (options?: Partial) => BlockSpec; +): ( + options?: Partial, +) => BlockSpec< + BlockConf["type"], + BlockConf["propSchema"], + BlockConf["content"] +>; export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, @@ -230,10 +262,33 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { - const BlockContent = - blockImplementation.toExternalHTML || blockImplementation.render; + const isContainer = isContainerType(blockConfig); + const BlockContent = (blockImplementation.toExternalHTML || + blockImplementation.render) as FC; const output = renderToDOMSpec((refCB) => { - return ( + const content = ( + { + refCB(element); + if (element && !isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + context={context} + /> + ); + // A container block's render output is the block's root element, + // with no wrapper. The attributes core stamps afterwards then + // land on the author's own element, the same element they land + // on in the live editor. + return isContainer ? ( + content + ) : ( - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - context={context} - /> + {content} ); }, editor); @@ -268,78 +310,210 @@ export function createReactBlockSpec< // constructed (itself guarded, via `getBlockFromNodeView`). Seeds // the fallback below so there is always something to render. const initialBlock = block; + // Container-ness is fixed per spec, so the node-view component + // can be chosen once. Each variant uses only the hooks and + // wrappers it needs. + const isContainer = isContainerType(blockConfig); + const BlockContent = blockImplementation.render as FC; + const blockContentDOMAttributes = this.blockContentDOMAttributes; - return ReactNodeViewRenderer( - (props: NodeViewProps) => { - // Vanilla JS node views are recreated on each update. However, - // using `ReactNodeViewRenderer` makes it so the node view is - // only created once, so the block we get in the node view will - // be outdated. Therefore, we have to get the block in the - // `ReactNodeViewRenderer` instead. That position can be stale, - // so resolving it is guarded (see `useNodeViewBlock`). - const block = useNodeViewBlock(props, initialBlock); + // Set by the container node view's `NodeViewWrapper` below. The + // author's own root element is that wrapper's first element child; + // it's read lazily because React may not have committed yet when + // this node view is handed to core, and because the author's + // component is free to swap its root element on a re-render. + const wrapper: { current: HTMLElement | null } = { current: null }; + const authorRootDOM = () => + (wrapper.current?.firstElementChild as HTMLElement | null) ?? + null; - const ref = useReactNodeView().nodeViewContentRef; + // Vanilla JS node views are recreated on each update. However, + // using `ReactNodeViewRenderer` makes it so the node view is only + // created once, so the block we get in the node view will be + // outdated. Therefore, both variants have to (re-)resolve the + // block inside the `ReactNodeViewRenderer` component. - if (!ref) { - throw new Error("nodeViewContentRef is not set"); + const ContainerNodeView = (props: NodeViewProps) => { + // Container blocks are bnBlock nodes (no `blockContainer` + // wrapper), so the id lives on the node's own attrs and the + // block resolves by id. Position-based resolution + // (`useNodeViewBlock`) would walk up to a parent bnBlock, + // which is the wrong block here. Ids are also immune to the + // stale positions it has to guard against. + const id = (props.node.attrs as Record).id; + if (!id) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute.`, + ); + } + // The id lookup misses when the node was just removed from the + // document (e.g. a suggestion-mode deletion still rendering); + // fall back to converting the node the view was handed. + const block = + editor.getBlock(id) ?? + nodeToBlock(props.node, props.view.state.doc); + + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } + + const selected = props.selected; + + // Stamped imperatively rather than spread as JSX props: the root + // element belongs to the block's author, so there is nothing to + // spread onto. Runs after every render, since both the block's + // props and the author's root element can change. + useLayoutEffect(() => { + const root = authorRootDOM(); + if (!root) { + return; } - const BlockContent = blockImplementation.render; - return ( - - { - ref(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, + applyContainerAttributes( + root, + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + + // ProseMirror marks the outermost element with + // `ProseMirror-selectednode`, but for containers that element + // has `display: contents`, which suppresses any outline drawn + // on it. So the state is mirrored onto the author's root, + // which is the block's actual box. + if (selected) { + root.setAttribute("data-selected", ""); + } else { + root.removeAttribute("data-selected"); + } + }); + + return ( + + { + ref(element); + if (element) { + element.dataset.nodeViewContent = ""; + // Mark the children host of a pure container so the + // round-trip parse rule can scope itself to it (see + // `getParseRules`); a content-bearing container's + // regions carry their own markers. + if (blockConfig.content === "none") { + element.setAttribute( + "data-children-of", + blockConfig.type, ); - element.dataset.nodeViewContent = ""; } - }} - /> - - ); - }, - { - className: "bn-react-node-view-renderer", - }, - )(this.props!) as ReturnType; - } else { - const BlockContent = blockImplementation.render; - const output = renderToDOMSpec((refCB) => { + } + }} + /> + + ); + }; + + const RegularNodeView = (props: NodeViewProps) => { + // The node view's position can be stale mid-render, so + // resolving it is guarded (see `useNodeViewBlock`). + const block = useNodeViewBlock(props, initialBlock); + + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } + return ( { - refCB(element); + contentRef={(element: HTMLElement | null) => { + ref(element); if (element) { element.className = mergeCSSClasses( "bn-inline-content", element.className, ); + element.dataset.nodeViewContent = ""; } }} /> ); + }; + + const nodeView = ReactNodeViewRenderer( + isContainer ? ContainerNodeView : RegularNodeView, + { + // The container class is separate because it removes the + // box the regular class relies on (see `Block.css`). + className: isContainer + ? "bn-react-node-view-renderer bn-container-node-view" + : "bn-react-node-view-renderer", + }, + )(this.props!) as ReturnType; + + if (isContainer) { + // TipTap appends its content host into whichever element the + // block passed `contentRef` to. `display: contents` keeps that + // host from contributing a box, so the block's editable region + // lays out exactly where the author put the ref. For a + // container that has its own content, the content and children + // regions sit there as siblings. + if (nodeView.contentDOM) { + nodeView.contentDOM.style.display = "contents"; + } + // Where core stamps the container attributes: the author's own + // element, not React's outermost wrapper (`dom`). + Object.defineProperty(nodeView, "rootDOM", { + get: authorRootDOM, + }); + } + + return nodeView; + } else { + const isContainer = isContainerType(blockConfig); + const BlockContent = blockImplementation.render as FC; + const output = renderToDOMSpec((refCB) => { + const content = ( + { + refCB(element); + if (element && !isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + /> + ); + // See `toExternalHTML` above: a container block owns its outer + // DOM, so its render output is the block's root element. + return isContainer ? ( + content + ) : ( + + {content} + + ); }, editor); return output; } diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts index 02393a2fd0..2577150aec 100644 --- a/packages/react/src/schema/useNodeViewBlock.ts +++ b/packages/react/src/schema/useNodeViewBlock.ts @@ -42,6 +42,17 @@ export function useNodeViewBlock( const lastBlockRef = useRef(initialBlock); const doc = props.view.state.doc; + // Position-based resolution finds the nearest bnBlock parent of the + // position. That is correct for blockContent node views, but wrong for + // container blocks, whose node is itself the bnBlock: it would return an + // ancestor block. This guard throws so a container node view can't + // silently render the wrong block. + if (props.node.type.isInGroup("bnBlock")) { + throw new Error( + `useNodeViewBlock cannot resolve container block "${props.node.type.name}": position-based resolution returns the nearest bnBlock parent, which is the wrong block when the node view's node is the block itself. Resolve container blocks by id instead, e.g. editor.getBlock(props.node.attrs.id).`, + ); + } + try { // Deliberate render-phase write: a monotonic "last good value" cache, so a // repeated render (e.g. StrictMode's double invoke) recomputes the same diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 2a835469db..ee43b8792d 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,7 +1,7 @@ import react from "@vitejs/plugin-react"; import * as path from "path"; import { webpackStats } from "rollup-plugin-webpack-stats"; -import { defineConfig, type UserConfig } from "vite-plus"; +import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; @@ -24,6 +24,9 @@ export default defineConfig( test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], + // `.browser.test` files need a real browser; the tests package's + // browser suite runs them. + exclude: [...configDefaults.exclude, "**/*.browser.test.*"], }, plugins: [react(), webpackStats()], // used so that vitest resolves the core package from the sources instead of the built version diff --git a/packages/react/vitestSetup.ts b/packages/react/vitestSetup.ts index beafe25357..ad44ba1c66 100644 --- a/packages/react/vitestSetup.ts +++ b/packages/react/vitestSetup.ts @@ -1,10 +1,21 @@ import { afterEach, beforeEach } from "vite-plus/test"; +// This setup file also runs for test files that opt into the plain `node` +// environment (`@vitest-environment node`), where there is no `window` at +// all. Everything below is a DOM mock, so it is a no-op there. +const hasWindow = typeof window !== "undefined"; + beforeEach(() => { + if (!hasWindow) { + return; + } (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; }); afterEach(() => { + if (!hasWindow) { + return; + } delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; }); @@ -19,7 +30,7 @@ class ClipboardEventMock extends Event { }, }; } -(global as any).ClipboardEvent = ClipboardEventMock; +(globalThis as any).ClipboardEvent = ClipboardEventMock; // Mock DragEvent class DragEventMock extends Event { @@ -32,28 +43,30 @@ class DragEventMock extends Event { }, }; } -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => { - // - }, // Deprecated - removeListener: () => { - // - }, // Deprecated - addEventListener: () => { - // - }, - removeEventListener: () => { - // - }, - dispatchEvent: () => { - // - }, - }), -}); +if (hasWindow) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => { + // + }, // Deprecated + removeListener: () => { + // + }, // Deprecated + addEventListener: () => { + // + }, + removeEventListener: () => { + // + }, + dispatchEvent: () => { + // + }, + }), + }); +} -(global as any).DragEvent = DragEventMock; +(globalThis as any).DragEvent = DragEventMock; diff --git a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts index fec31293a5..34d60aa6bf 100644 --- a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts +++ b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts @@ -80,7 +80,7 @@ function createCollabEditor(text: string) { function selectWholeFirstBlock(editor: BlockNoteEditor) { const id = editor.document[0].id; const info = getBlockInfo(getNodeById(id, editor.prosemirrorState.doc)!); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("not a block container"); } const from = info.blockContent.beforePos + 1; diff --git a/packages/xl-ai/src/prosemirror/agent.test.ts b/packages/xl-ai/src/prosemirror/agent.test.ts index 44d87c8108..d2a7d9178b 100644 --- a/packages/xl-ai/src/prosemirror/agent.test.ts +++ b/packages/xl-ai/src/prosemirror/agent.test.ts @@ -39,7 +39,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -72,7 +72,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -98,7 +98,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -128,7 +128,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -157,7 +157,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } diff --git a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts index 21454b7b79..edd8a3b1bb 100644 --- a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts +++ b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts @@ -21,7 +21,7 @@ function getExampleEditorWithSuggestions() { const blockPos = getNodeById("1", editor.prosemirrorState.doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -56,7 +56,7 @@ it("should be able to apply changes to a clean doc (use invertMap)", async () => const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -85,7 +85,7 @@ it("should be able to apply changes to a clean doc (use rebaseTr)", async () => const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } diff --git a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts index 6262f505cb..8bbcb29315 100644 --- a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts +++ b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts @@ -47,7 +47,7 @@ export const combinedOperationsTestCases: DocumentOperationTestCase[] = [ const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } return { diff --git a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts index 2261863430..3d4d25f152 100644 --- a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts +++ b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts @@ -41,7 +41,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ getTestSelection: (editor: BlockNoteEditor) => { const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } return { @@ -68,7 +68,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ getTestSelection: (editor: BlockNoteEditor) => { const posInfo = getNodeById("ref1", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } // 'ello, world! Dow are yo' @@ -737,7 +737,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ getTestSelection(editor) { const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } return { diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 16e45a304f..7141fefdfd 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -1,5 +1,6 @@ import { BlockNoteSchema, + createBlockSpec, defaultBlockSpecs, createPageBreakBlockSpec, PartialBlock, @@ -415,6 +416,82 @@ describe("exporter", () => { ); }); +describe("custom container blocks", () => { + const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + children: { allow: "any" }, + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "box"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, + )(); + + const boxSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + box: Box, + }, + }); + + const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [ + { + type: "box", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + it("passes children to a custom container mapping", async () => { + const exporter = new DOCXExporter( + boxSchema, + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + box: ( + _block: any, + _exporter: any, + _nesting: any, + _index: any, + children: any, + ) => + new Paragraph({ + children: [new TextRun(`BOX(${children?.length ?? 0})`)], + }), + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const transformed = await exporter.transformBlocks(boxDocument as any); + expect(transformed).toHaveLength(1); + const xml = JSON.stringify(transformed[0]); + expect(xml).toContain("BOX(2)"); + }); + + it("throws a clear error for an unmapped container block", async () => { + const exporter = new DOCXExporter( + boxSchema, + docxDefaultSchemaMappings as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow( + /container block type "box"/, + ); + }); +}); + function prettify(sourceXml: string) { let ret = xmlFormat(sourceXml); diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index f987ad4a7d..5caf6b5606 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -116,7 +116,7 @@ export class DOCXExporter< for (const b of blocks) { let children = await this.transformBlocks(b.children, nestingLevel + 1); - if (!["columnList", "column"].includes(b.type)) { + if (!this.isContainerBlock(b.type)) { children = children.map((c, _i) => { // NOTE: nested tables not supported (we can't insert the new Tab before a table) if ( @@ -139,7 +139,7 @@ export class DOCXExporter< 0 /*unused*/, children, ); // TODO: any - if (["columnList", "column"].includes(b.type)) { + if (this.isContainerBlock(b.type)) { ret.push(self as Table); } else if (Array.isArray(self)) { ret.push(...self, ...children); diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx index 5f4eecf3c5..df9fafdf61 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx @@ -246,6 +246,24 @@ export class ReactEmailExporter< i = nextIndex; continue; } + if (this.isContainerBlock(b.type)) { + // Container blocks (columnList, column, custom containers): the + // mapping owns the placement of the children, so they are passed in + // and not rendered as an indented sibling list. + const containerChildren = await this.transformBlocks( + b.children, + nestingLevel + 1, + ); + const containerSelf = (await this.mapBlock( + b as any, + nestingLevel, + 0, + containerChildren as any, + )) as any; + ret.push({containerSelf}); + i++; + continue; + } // Non-list blocks const children = await this.transformBlocks(b.children, nestingLevel + 1); const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any; diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index 7c17cad0ad..ee7bde9e60 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -142,7 +142,7 @@ export class ODTExporter< numberedListIndex = 0; } - if (["columnList", "column"].includes(block.type)) { + if (this.isContainerBlock(block.type)) { const children = await this.transformBlocks(block.children, 0); const content = await this.mapBlock( block as any, diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx index f91ec93a86..1063ea5daa 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx @@ -176,7 +176,7 @@ export class PDFExporter< children, ); // TODO: any - if (["pageBreak", "columnList", "column"].includes(b.type)) { + if (b.type === "pageBreak" || this.isContainerBlock(b.type)) { ret.push(self); continue; } diff --git a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx index f3e12560d9..2c768cff6c 100644 --- a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx +++ b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx @@ -348,8 +348,8 @@ function opToXml(op: DeltaInsertOp): string { // concurrent merge of two marks), which would otherwise make these // snapshots flaky. Sorted ascending => the alphabetically-first mark // ends up innermost (e.g. `world`). - for (const [name, value] of Object.entries(op.format ?? {}).sort(([a], [b]) => - a < b ? -1 : a > b ? 1 : 0, + for (const [name, value] of Object.entries(op.format ?? {}).sort( + ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0), )) { if (value !== null && typeof value === "object") { // Object value: trivial empty `{}` renders as a bare tag, richer diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx index 71101c7fa7..33b8eb5521 100644 --- a/tests/src/unit/react/useNodeViewBlock.test.tsx +++ b/tests/src/unit/react/useNodeViewBlock.test.tsx @@ -27,8 +27,15 @@ const createReproBlock = createReactBlockSpec( { render: (props) =>

}, ); +// A container block, whose node view's node is itself the bnBlock, resolved +// by id instead of by position. +const createBoxBlock = createReactBlockSpec( + { type: "box", propSchema: {}, content: "none", children: { allow: "any" } }, + { render: (props) =>

}, +); + const schema = BlockNoteSchema.create().extend({ - blockSpecs: { repro: createReproBlock() }, + blockSpecs: { repro: createReproBlock(), box: createBoxBlock() }, }); let editor: BlockNoteEditor; @@ -43,6 +50,7 @@ beforeEach(() => { { type: "paragraph", content: "first" }, { type: "repro", content: "target block" }, { type: "paragraph", content: "last" }, + { type: "box", children: [{ type: "paragraph", content: "inside" }] }, ], }) as BlockNoteEditor; @@ -78,11 +86,14 @@ function renderHook( return resolved; } -// Only the two fields `useNodeViewBlock` reads. Built structurally so `tests` -// doesn't need a dependency on `@tiptap/react` just for its prop types. -function makeProps(getPos: () => number | undefined) { +// Only the fields `useNodeViewBlock` reads. Built structurally so `tests` +// doesn't need a dependency on `@tiptap/react` just for its prop types. The +// `node` defaults to a regular (non-container) block's node shape; container +// tests pass the real PM node instead. +function makeProps(getPos: () => number | undefined, node?: unknown) { return { getPos, + node: node ?? { type: { isInGroup: () => false } }, view: { state: { doc: editor.prosemirrorState.doc } }, } as unknown as Parameters[0]; } @@ -170,4 +181,34 @@ describe("useNodeViewBlock", () => { expect(resolved.id).toBe(target.id); expect(resolved).not.toBe(seed); }); + + it("rejects container blocks loudly instead of resolving the wrong block", () => { + const box = editor.document[3]; + const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!; + const props = makeProps(() => undefined, node); + + let captured: unknown; + + function Probe() { + useNodeViewBlock(props, box); + return null; + } + + root = createRoot(div, { + // React 19 reports uncaught render errors here instead of rethrowing + // out of `flushSync`. + onUncaughtError: (error: unknown) => { + captured = error; + }, + }); + try { + flushSync(() => { + root!.render(); + }); + } catch (error) { + captured = error; + } + + expect(String(captured)).toMatch(/cannot resolve container block "box"/); + }); });