From 2d4269cd9bbd190b4483e67737727e0e2d57f0b5 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 4 Sep 2026 14:14:13 +0200 Subject: [PATCH 1/2] feat(core): container blocks, compartments and frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the container-block API from #2997 / #3014: `children` on the block config, compiled into the node's content expression, columns and column lists declared through the ordinary `createBlockSpec`, and a repair pass that keeps containers valid as children are removed. Adds two things on top of that: - compartments — a block that keeps content of its own *and* declares `children`, so a callout can have a rich text title above a body of blocks. Editing gestures treat title and body as one unit. - `renderFrame` — markup wrapping a block's content and children together, so the author draws the box. Independent of `children`: a toggle frames itself but keeps ordinary nesting. Narrows the rest of the API: no `default` seeds, no `whenEmptied`, no `boundary`, no `rootDOM`, and `allow` is `"any"` or a list of container type names. BREAKING CHANGE: `fixColumnList`, `removeEmptyColumns` and `isEmptyColumn` are replaced by `fixContainer`, `removeEmptyChildren`, `isEmptyContainerChild`, `containerAncestorIds` and `fixContainersById`. Columns no longer serialize `data-width` when the width is the default. --- .../13-callout-block/.bnexample.json | 7 + .../13-callout-block/README.md | 17 + .../13-callout-block/index.html | 14 + .../13-callout-block/main.tsx | 11 + .../13-callout-block/package.json | 30 ++ .../13-callout-block/src/App.tsx | 53 +++ .../13-callout-block/src/Callout.tsx | 73 ++++ .../13-callout-block/src/styles.css | 52 +++ .../13-callout-block/tsconfig.json | 32 ++ .../13-callout-block/vite-env.d.ts | 1 + .../13-callout-block/vite.config.ts | 35 ++ .../commands/mergeBlocks/mergeBlocks.ts | 47 +++ .../commands/moveBlocks/moveBlocks.ts | 46 ++- .../commands/nestBlock/nestBlock.ts | 17 +- .../commands/replaceBlocks/replaceBlocks.ts | 29 +- .../replaceBlocks/util/fixColumnList.ts | 173 --------- .../containers/fixContainer.ts | 171 +++++++++ .../clipboard/toClipboard/copyExtension.ts | 4 +- packages/core/src/api/getBlockInfoFromPos.ts | 2 +- .../api/nodeConversions/fragmentToBlocks.ts | 24 +- .../managers/ExtensionManager/extensions.ts | 11 +- packages/core/src/editor/transformPasted.ts | 2 +- .../core/src/extensions/SideMenu/SideMenu.ts | 13 +- .../core/src/extensions/SideMenu/dragging.ts | 12 +- .../extensions/TrailingNode/TrailingNode.ts | 12 +- .../KeyboardShortcutsExtension.ts | 328 +++++++++++++--- packages/core/src/index.ts | 3 +- packages/core/src/pm-nodes/BlockContainer.ts | 80 +++- .../src/schema/blocks/compartments.test.ts | 353 ++++++++++++++++++ .../core/src/schema/blocks/containers.test.ts | 207 ++++++++++ packages/core/src/schema/blocks/containers.ts | 229 ++++++++++++ packages/core/src/schema/blocks/createSpec.ts | 199 +++++++++- .../src/schema/blocks/renderFrame.test.ts | 166 ++++++++ packages/core/src/schema/blocks/types.ts | 90 ++++- packages/react/src/schema/ReactBlockSpec.tsx | 144 +++++-- .../src/blocks/Columns/index.ts | 70 +++- .../ColumnResize/ColumnResizeExtension.ts | 16 +- .../xl-multi-column/src/pm-nodes/Column.ts | 91 ----- .../src/pm-nodes/ColumnList.ts | 47 --- ...test.ts.snap => fixContainer.test.ts.snap} | 14 +- ...lumnLists.test.ts => fixContainer.test.ts} | 36 +- .../multi-column/undefined/external.html | 2 +- .../multi-column/undefined/internal.html | 2 +- playground/src/examples.gen.tsx | 19 + pnpm-lock.yaml | 43 +++ tests/src/unit/react/reactContainer.test.tsx | 109 ++++++ tests/src/unit/react/reactFrame.test.tsx | 73 ++++ 47 files changed, 2688 insertions(+), 521 deletions(-) create mode 100644 examples/06-custom-schema/13-callout-block/.bnexample.json create mode 100644 examples/06-custom-schema/13-callout-block/README.md create mode 100644 examples/06-custom-schema/13-callout-block/index.html create mode 100644 examples/06-custom-schema/13-callout-block/main.tsx create mode 100644 examples/06-custom-schema/13-callout-block/package.json create mode 100644 examples/06-custom-schema/13-callout-block/src/App.tsx create mode 100644 examples/06-custom-schema/13-callout-block/src/Callout.tsx create mode 100644 examples/06-custom-schema/13-callout-block/src/styles.css create mode 100644 examples/06-custom-schema/13-callout-block/tsconfig.json create mode 100644 examples/06-custom-schema/13-callout-block/vite-env.d.ts create mode 100644 examples/06-custom-schema/13-callout-block/vite.config.ts delete mode 100644 packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts create mode 100644 packages/core/src/api/blockManipulation/containers/fixContainer.ts create mode 100644 packages/core/src/schema/blocks/compartments.test.ts create mode 100644 packages/core/src/schema/blocks/containers.test.ts create mode 100644 packages/core/src/schema/blocks/containers.ts create mode 100644 packages/core/src/schema/blocks/renderFrame.test.ts delete mode 100644 packages/xl-multi-column/src/pm-nodes/Column.ts delete mode 100644 packages/xl-multi-column/src/pm-nodes/ColumnList.ts rename packages/xl-multi-column/src/test/commands/util/__snapshots__/{fixColumnLists.test.ts.snap => fixContainer.test.ts.snap} (94%) rename packages/xl-multi-column/src/test/commands/util/{fixColumnLists.test.ts => fixContainer.test.ts} (91%) create mode 100644 tests/src/unit/react/reactContainer.test.tsx create mode 100644 tests/src/unit/react/reactFrame.test.tsx diff --git a/examples/06-custom-schema/13-callout-block/.bnexample.json b/examples/06-custom-schema/13-callout-block/.bnexample.json new file mode 100644 index 0000000000..0ad4741705 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/.bnexample.json @@ -0,0 +1,7 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Intermediate", "Blocks", "Custom Schemas", "Nesting"], + "dependencies": {} +} diff --git a/examples/06-custom-schema/13-callout-block/README.md b/examples/06-custom-schema/13-callout-block/README.md new file mode 100644 index 0000000000..0ec60c2a4b --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/README.md @@ -0,0 +1,17 @@ +# Callout Block with a Title and a Body + +A callout is one block with two editable regions: a **title**, which is the +block's own rich text, and a **body**, which is the blocks nested under it. + +Both are ordinary BlockNote content, so everything already works on them: +Enter splits the title, Tab indents inside the body, blocks can be dragged in +and out, and the whole thing serializes and pastes like any other block. + +What makes them look like one box is `renderFrame`: the block returns the +markup that frames it, plus the `slot` element that BlockNote renders the +title and the body into. + +**Relevant Docs:** + +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/13-callout-block/index.html b/examples/06-custom-schema/13-callout-block/index.html new file mode 100644 index 0000000000..e036d54617 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/index.html @@ -0,0 +1,14 @@ + + + + + Callout Block with a Title and a Body + + + +
+ + + diff --git a/examples/06-custom-schema/13-callout-block/main.tsx b/examples/06-custom-schema/13-callout-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/06-custom-schema/13-callout-block/package.json b/examples/06-custom-schema/13-callout-block/package.json new file mode 100644 index 0000000000..b47d91f6fe --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/package.json @@ -0,0 +1,30 @@ +{ + "name": "@blocknote/example-custom-schema-callout-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/06-custom-schema/13-callout-block/src/App.tsx b/examples/06-custom-schema/13-callout-block/src/App.tsx new file mode 100644 index 0000000000..4133076c81 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/src/App.tsx @@ -0,0 +1,53 @@ +import { BlockNoteSchema } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; + +import { createCallout } from "./Callout"; +import "./styles.css"; + +// Our schema with block specs, which contain the configs and implementations +// for blocks that we want our editor to use. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + // Creates an instance of the Callout block and adds it to the schema. + callout: createCallout(), + }, +}); + +export default function App() { + // Creates a new editor instance. + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "A callout has a title and a body:", + }, + { + type: "callout", + props: { flavor: "warning" }, + content: "Careful with this one", + children: [ + { + type: "paragraph", + content: + "The body is made of nested blocks, so it takes anything: lists, headings, even another callout.", + }, + { + type: "bulletListItem", + content: "Press Tab and Enter in here as usual", + }, + ], + }, + { + type: "paragraph", + content: "Click the icon to change the callout's flavor.", + }, + ], + }); + + // Renders the editor instance. + return ; +} diff --git a/examples/06-custom-schema/13-callout-block/src/Callout.tsx b/examples/06-custom-schema/13-callout-block/src/Callout.tsx new file mode 100644 index 0000000000..74a79e21b5 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/src/Callout.tsx @@ -0,0 +1,73 @@ +import { createReactBlockSpec } from "@blocknote/react"; + +const FLAVORS = { + info: { emoji: "💡", label: "Info" }, + warning: { emoji: "⚠️", label: "Warning" }, + success: { emoji: "✅", label: "Success" }, +} as const; + +type Flavor = keyof typeof FLAVORS; + +export const createCallout = createReactBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "info" as const, + values: ["info", "warning", "success"] as const, + }, + }, + // The callout's own content is its title: ordinary rich text. + content: "inline" as const, + // ...and its children are its body. Declaring them a compartment is what + // makes the editing gestures treat the box as a unit: Enter at the end of + // the title starts the body, Shift-Tab doesn't escape it, and a block + // moved in from below arrives whole. + children: { allow: "any" as const }, + }, + { + // The title. `contentRef` marks the element the rich text goes in, exactly + // as in any other custom block. + render: (props) => ( +
+ ), + + // The frame around the whole block. BlockNote renders the title *and* the + // block's nested children into `slot`, so the box wraps both. + renderFrame: (block, editor) => { + const dom = document.createElement("div"); + dom.className = "callout"; + + const button = document.createElement("button"); + button.className = "callout-flavor"; + button.type = "button"; + button.contentEditable = "false"; + + const slot = document.createElement("div"); + slot.className = "callout-body"; + + // The button goes after the slot: BlockNote's drag handle hovers over + // the block's left edge, so a control there would sit under it. + dom.append(slot, button); + + const paint = (flavor: Flavor) => { + dom.dataset.flavor = flavor; + button.textContent = FLAVORS[flavor].emoji; + button.title = `${FLAVORS[flavor].label} — click to change`; + }; + paint(block.props.flavor); + + // Cycles the flavor. `updateBlock` is the normal editor API; the frame + // is told about the new props through `update` below. + button.addEventListener("click", () => { + const flavors = Object.keys(FLAVORS) as Flavor[]; + const current = dom.dataset.flavor as Flavor; + const next = flavors[(flavors.indexOf(current) + 1) % flavors.length]; + + editor.updateBlock(block, { props: { flavor: next } }); + }); + + return { dom, slot, update: (updated) => paint(updated.props.flavor) }; + }, + }, +); diff --git a/examples/06-custom-schema/13-callout-block/src/styles.css b/examples/06-custom-schema/13-callout-block/src/styles.css new file mode 100644 index 0000000000..7e3e8b98b9 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/src/styles.css @@ -0,0 +1,52 @@ +.callout { + display: flex; + gap: 0.5rem; + align-items: flex-start; + margin: 4px 0; + padding: 0.75rem; + border-radius: 6px; + border-left: 4px solid var(--callout-accent); + background: var(--callout-bg); +} + +.callout[data-flavor="info"] { + --callout-accent: #3b82f6; + --callout-bg: #eff6ff; +} +.callout[data-flavor="warning"] { + --callout-accent: #f59e0b; + --callout-bg: #fffbeb; +} +.callout[data-flavor="success"] { + --callout-accent: #10b981; + --callout-bg: #ecfdf5; +} + +.callout-flavor { + flex: none; + order: 2; + border: none; + background: none; + padding: 0; + font-size: 1.1rem; + line-height: 1.6; + cursor: pointer; + user-select: none; +} + +/* The title and the body both render in here. */ +.callout-body { + flex: 1; + min-width: 0; + order: 1; +} + +.callout-title { + font-weight: 600; +} + +/* The body's blocks are nested children, so they carry BlockNote's usual + nesting indent. Inside the callout the box already sets it off. */ +.callout-body .bn-block-group { + padding-left: 0; +} diff --git a/examples/06-custom-schema/13-callout-block/tsconfig.json b/examples/06-custom-schema/13-callout-block/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/tsconfig.json @@ -0,0 +1,32 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true, + "paths": { + "@shared/*": ["../../../shared/*"] + } + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/13-callout-block/vite-env.d.ts b/examples/06-custom-schema/13-callout-block/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/13-callout-block/vite.config.ts b/examples/06-custom-schema/13-callout-block/vite.config.ts new file mode 100644 index 0000000000..a96f1f04ff --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/vite.config.ts @@ -0,0 +1,35 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // The repo-wide alias for the shared test-utils directory (private, + // so it only resolves inside the monorepo). Harmless for examples + // that don't use it. + "@shared": path.resolve(__dirname, "../../../shared/"), + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index ce1a9455db..e6f727d29f 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 { + isCompartment, + isContainerNode, +} from "../../../../schema/blocks/containers.js"; import { BlockInfo, getBlockInfoFromResolvedPos, @@ -165,6 +169,49 @@ const mergeBlocks = ( return true; }; +/** + * The block owning the compartment that the block at `beforePos` is the first + * child of - a callout, for the first block of its body. `undefined` when the + * block isn't the first child of a compartment. + */ +export const compartmentOwnerInfo = (doc: Node, beforePos: number) => { + const $pos = doc.resolve(beforePos); + if ($pos.index() !== 0 || $pos.depth < 2) { + return undefined; + } + // The body's own node is the compartment (a column), or it is a `blockGroup` + // and the compartment is the block holding it. + const ownerDepth = isContainerNode($pos.node().type) + ? $pos.depth + : $pos.depth - 1; + const owner = $pos.node(ownerDepth); + if (ownerDepth < 1 || !isCompartment(owner)) { + return undefined; + } + return getBlockInfoFromResolvedPos(doc.resolve($pos.before(ownerDepth))); +}; + +/** + * Merges `nextBlockInfo` into `prevBlockInfo`, when both hold inline content. + * Unlike {@link mergeBlocksCommand} the two blocks are given rather than + * derived from a position, so blocks that aren't siblings can be merged - a + * compartment's first child into the block that owns it. + */ +export const mergeBlockPairCommand = + (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => + ({ + state, + dispatch, + }: { + state: EditorState; + dispatch: ((args?: any) => any) | undefined; + }) => { + if (!canMerge(prevBlockInfo, nextBlockInfo)) { + return false; + } + return mergeBlocks(state, dispatch, prevBlockInfo, nextBlockInfo); + }; + export const mergeBlocksCommand = (posBetweenBlocks: number) => ({ diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 71598b7d69..d3c8406b9e 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -14,6 +14,11 @@ import { getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; +import { + holdsBlocks, + isContainerNode, + isContainerOnly, +} from "../../../../schema/blocks/containers.js"; import { insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; @@ -131,14 +136,17 @@ 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( +// Replaces blocks that only exist inside a container (a `column`) with their +// children, as they aren't valid anywhere else. Other blocks are returned +// as-is. +function flattenContainerOnlyBlocks( + editor: BlockNoteEditor, blocks: Block[], ): Block[] { - return blocks.flatMap((block) => - block.type === "column" ? block.children : [block], - ); + return blocks.flatMap((block) => { + const nodeType = editor.pmSchema.nodes[block.type]; + return nodeType && isContainerOnly(nodeType) ? block.children : [block]; + }); } /** @@ -169,10 +177,10 @@ 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), + flattenContainerOnlyBlocks(editor, blocks), referenceBlock, placement, ); @@ -207,12 +215,18 @@ 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 block is in a valid place after being moved: a container that +// only takes containers as children (a `columnList`, which holds `column`s) +// can't hold the moved block directly. +function checkPlacementIsValid( + editor: BlockNoteEditor, + parentBlock?: Block, +): boolean { + if (!parentBlock) { + return true; + } + const nodeType = editor.pmSchema.nodes[parentBlock.type]; + return !nodeType || !isContainerNode(nodeType) || holdsBlocks(nodeType); } // Gets the placement for moving a block up. This has 3 cases: @@ -254,7 +268,7 @@ function getMoveUpPlacement( } const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlockParent)) { return getMoveUpPlacement( editor, placement === "after" @@ -306,7 +320,7 @@ function getMoveDownPlacement( } const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlockParent)) { 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..b744bd75c1 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -4,6 +4,10 @@ import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js"; +import { + holdsBlocks, + isCompartment, +} from "../../../../schema/blocks/containers.js"; /** * Modified version of prosemirror-schema-list's sinkItem. @@ -19,9 +23,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 && holdsBlocks(node.type), // change 1 ); if (!range) { return false; @@ -163,15 +165,16 @@ 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 && holdsBlocks(node.type), // change 1 ); if (!range) { return false; } - if ($from.node(range.depth - 1).type === itemType) { + const parent = $from.node(range.depth - 1); + // A compartment's body belongs to the block that owns it, so unnesting stops + // at its edge rather than lifting the block out of it. + if (parent.type === itemType && !isCompartment(parent)) { // Inside a parent node return liftToOuterList(tr, itemType, groupType, range); // change 2 } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d9e1e72981..51f1504072 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -11,7 +11,10 @@ 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 { + containerAncestorIds, + fixContainersById, +} from "../../containers/fixContainer.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +25,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixContainers?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,7 +46,7 @@ export function removeAndInsertBlocks< ), ); const removedBlocks: Block[] = []; - const columnListPositions = new Set(); + const containerIds = new Set(); const idOfFirstBlock = typeof blocksToRemove[0] === "string" @@ -84,10 +87,12 @@ 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()); + // Every container the removed block sits in may be left needing repair + // (an emptied column, a column list down to one column). Collected as ids + // because the repair runs once every removal is done, by which time these + // positions have moved. + for (const id of containerAncestorIds(tr.doc, $pos.pos)) { + containerIds.add(id); } if ( @@ -119,11 +124,11 @@ 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 removal emptied out. 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, containerIds); } // Converts the nodes created from `blocksToInsert` into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts deleted file mode 100644 index 3097851f47..0000000000 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Slice, type Node } from "prosemirror-model"; -import { type Transaction } from "prosemirror-state"; -import { ReplaceAroundStep } from "prosemirror-transform"; - -/** - * Checks if a `column` node is empty, i.e. if it has only a single empty - * paragraph. - * @param column The column to check. - * @returns Whether the column is empty. - */ -export function isEmptyColumn(column: Node) { - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - const blockContainer = column.firstChild; - if (!blockContainer) { - throw new Error("Invalid column: does not have child node."); - } - - const blockContent = blockContainer.firstChild; - if (!blockContent) { - throw new Error("Invalid blockContainer: does not have child node."); - } - - return ( - column.childCount === 1 && - blockContainer.childCount === 1 && - blockContent.type.name === "paragraph" && - blockContent.content.content.length === 0 - ); -} - -/** - * Removes all empty `column` nodes in a `columnList`. A `column` node is empty - * if it has only a single empty block. If, however, removing the `column`s - * leaves the `columnList` that has fewer than two, ProseMirror will re-add - * empty columns. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos The position just before the `columnList` node. - */ -export function removeEmptyColumns(tr: Transaction, columnListPos: number) { - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - for ( - let columnIndex = columnList.childCount - 1; - columnIndex >= 0; - columnIndex-- - ) { - const columnPos = tr.doc - .resolve($columnListPos.pos + 1) - .posAtIndex(columnIndex); - const $columnPos = tr.doc.resolve(columnPos); - const column = $columnPos.nodeAfter; - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - if (isEmptyColumn(column)) { - tr.delete(columnPos, columnPos + column.nodeSize); - } - } -} - -/** - * Fixes potential issues in a `columnList` node after a - * `blockContainer`/`column` node is (re)moved from it: - * - * - Removes all empty `column` nodes. A `column` node is empty if it has only - * a single empty block. - * - If all but one `column` nodes are empty, replaces the `columnList` with - * the content of the non-empty `column`. - * - If all `column` nodes are empty, removes the `columnList` entirely. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos - * @returns The position just before the `columnList` node. - */ -export function fixColumnList(tr: Transaction, columnListPos: number) { - removeEmptyColumns(tr, columnListPos); - - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - if (columnList.childCount > 2) { - // Do nothing if the `columnList` has more than two non-empty `column`s. In - // the case that the `columnList` has exactly two columns, we may need to - // still remove it, as it's possible that one or both columns are empty. - // This is because after `removeEmptyColumns` is called, if the - // `columnList` has fewer than two `column`s, ProseMirror will re-add empty - // `column`s until there are two total, in order to fit the schema. - return; - } - - if (columnList.childCount < 2) { - // Throw an error if the `columnList` has fewer than two columns. After - // `removeEmptyColumns` is called, if the `columnList` has fewer than two - // `column`s, ProseMirror will re-add empty `column`s until there are two - // total, in order to fit the schema. So if there are fewer than two here, - // either the schema, or ProseMirror's internals, must have changed. - throw new Error("Invalid columnList: contains fewer than two children."); - } - - const firstColumnBeforePos = columnListPos + 1; - const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); - const firstColumn = $firstColumnBeforePos.nodeAfter; - - const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; - const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); - const lastColumn = $lastColumnAfterPos.nodeBefore; - - if (!firstColumn || !lastColumn) { - throw new Error("Invalid columnList: does not contain children."); - } - - const firstColumnEmpty = isEmptyColumn(firstColumn); - const lastColumnEmpty = isEmptyColumn(lastColumn); - - if (firstColumnEmpty && lastColumnEmpty) { - // Removes `columnList` - tr.delete(columnListPos, columnListPos + columnList.nodeSize); - - return; - } - - if (firstColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of last `column`. - lastColumnAfterPos - lastColumn.nodeSize + 1, - lastColumnAfterPos - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } - - if (lastColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of first `column`. - firstColumnBeforePos + 1, - firstColumnBeforePos + firstColumn.nodeSize - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } -} 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..1ade4c97eb --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,171 @@ +import { Slice, type Node } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; + +import { + containerDissolves, + isContainerNode, + isContainerOnly, + minChildren, +} from "../../../schema/blocks/containers.js"; +import { getNodeById } from "../../nodeUtil.js"; + +/** + * Whether `node` is a container child the user has emptied out: a container + * (a column, a cell) holding nothing but one empty paragraph. + */ +export function isEmptyContainerChild(node: Node): boolean { + if (node.type.name === "blockContainer") { + const content = node.firstChild; + return ( + node.childCount === 1 && + !!content && + content.type.name === "paragraph" && + content.childCount === 0 + ); + } + if (isContainerNode(node.type)) { + return node.childCount === 1 && isEmptyContainerChild(node.firstChild!); + } + return false; +} + +/** + * Deletes every emptied *container* child of the container at `containerPos` + * (an emptied column disappears rather than lingering). Regular blocks are + * left alone: an empty paragraph is content the user typed into, not + * structure. Dropping below the container's minimum is fine - ProseMirror pads + * it back and {@link fixContainer} then decides whether the container survives. + * + * @param containerPos The position just before the container node. + */ +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.", + ); + } + + // Collected before deleting anything, then applied back to front so the + // earlier positions stay valid. + const emptied: { from: number; to: number }[] = []; + container.forEach((child, offset) => { + if (isContainerNode(child.type) && isEmptyContainerChild(child)) { + const from = containerPos + 1 + offset; + emptied.push({ from, to: from + child.nodeSize }); + } + }); + + for (let i = emptied.length - 1; i >= 0; i--) { + tr.delete(emptied[i].from, emptied[i].to); + } +} + +/** + * Repairs the container at `containerPos` after children were (re)moved from + * it: drops the ones the user emptied, and dissolves the container when too + * few are left for it to mean anything (a column list with one column is just + * that column's blocks). + * + * A container that only exists inside another container (a column) is left to + * its parent, which is the thing that decides whether it still belongs. + * + * @param containerPos The position just before the container node. + */ +export function fixContainer(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.", + ); + } + if (!containerDissolves(container.type)) { + return; + } + + removeEmptyChildren(tr, containerPos); + + const fixed = tr.doc.resolve(containerPos).nodeAfter; + if (!fixed || fixed.type !== container.type) { + return; + } + + // Deleting the emptied children can take the container below its minimum, in + // which case ProseMirror has already padded it back up with empty ones. So + // "still needed" is decided on the children that carry content, not on the + // child count. + const survivors: { node: Node; offset: number }[] = []; + fixed.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + survivors.push({ node: child, offset }); + } + }); + + if (survivors.length >= minChildren(fixed.type)) { + return; + } + + const containerEnd = containerPos + fixed.nodeSize; + + if (survivors.length === 0) { + tr.delete(containerPos, containerEnd); + return; + } + + // Too few children left for the container to mean anything, so it is + // replaced by the one that still has content. + const { node: survivor, offset } = survivors[0]; + const survivorStart = containerPos + 1 + offset; + + if (isContainerOnly(survivor.type)) { + // The survivor can't stand on its own either (a column only exists inside + // a column list), so what it holds is what's left. + tr.step( + new ReplaceAroundStep( + containerPos, + containerEnd, + survivorStart + 1, + survivorStart + survivor.nodeSize - 1, + Slice.empty, + 0, + false, + ), + ); + return; + } + + tr.replaceWith(containerPos, containerEnd, survivor); +} + +/** + * The container blocks `pos` sits in, innermost first, as ids: repairs happen + * after the change that prompted them, by which time positions have moved but + * ids still name the same blocks. + */ +export function containerAncestorIds(doc: Node, pos: number): string[] { + const $pos = doc.resolve(pos); + const ids: string[] = []; + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if (isContainerNode(ancestor.type) && ancestor.attrs.id) { + ids.push(ancestor.attrs.id); + } + } + return ids; +} + +/** + * Repairs each of the given containers, looked up by id in the transaction's + * current document. Innermost first, so an inner container emptying out is + * seen by the outer one; containers an earlier repair already removed are + * skipped. + */ +export function fixContainersById(tr: Transaction, ids: Iterable) { + for (const id of ids) { + const target = getNodeById(id, tr.doc); + if (target && isContainerNode(target.node.type)) { + fixContainer(tr, target.posBeforeNode); + } + } +} diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index e150af1309..dbff24821d 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -50,7 +50,7 @@ function fragmentToExternalHTML< (child) => child.type.isInGroup("bnBlock") || child.type.name === "blockGroup" || - child.type.spec.group === "blockContent", + child.type.isInGroup("blockContent"), ) === undefined; if (isWithinBlockContent) { selectedFragment = fragmentWithoutParents; @@ -118,7 +118,7 @@ export function selectedFragmentToHTML< // selected, e.g. an image block. if ( "node" in view.state.selection && - (view.state.selection.node as Node).type.spec.group === "blockContent" + (view.state.selection.node as Node).type.isInGroup("blockContent") ) { editor.transact((tr) => tr.setSelection( diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 04ed789c98..7befa26983 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -188,7 +188,7 @@ export function getBlockInfoWithManualOffset( let blockGroup: SingleBlockInfo | undefined; bnBlockNode.forEach((node, offset) => { - if (node.type.spec.group === "blockContent") { + if (node.type.isInGroup("blockContent")) { // console.log(beforePos, offset); const blockContentNode = node; const blockContentBeforePos = bnBlockBeforePos + offset + 1; diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..5a7bbfa732 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -5,6 +5,11 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + containerDissolves, + isContainerOnly, + minChildren, +} from "../../schema/blocks/containers.js"; import { nodeToBlock } from "./nodeToBlock.js"; /** @@ -44,10 +49,21 @@ export function fragmentToBlocks< } } - 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)); + if ( + containerDissolves(node.type) && + node.childCount < minChildren(node.type) + ) { + // Only part of the container was selected (a single column of a column + // list), so it can't stand on its own: what was selected inside it is + // what comes out. + node.forEach((child) => { + if (isContainerOnly(child.type)) { + child.forEach((grandChild) => + blocks.push(nodeToBlock(grandChild, node)), + ); + } else { + blocks.push(nodeToBlock(child, node)); + } }); return false; } diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..36c47e5612 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -61,8 +61,15 @@ export function getDefaultTiptapExtensions( Gapcursor, UniqueID.configure({ - // everything from bnBlock group (nodes that represent a BlockNote block should have an id) - types: ["blockContainer", "columnList", "column"], + // Everything in the `bnBlock` group: the shared node every regular block + // is wrapped in, plus each container block's own node, which is the node + // that carries its id. + types: [ + "blockContainer", + ...Object.entries(options.schema?.blockSpecs ?? {}) + .filter(([, spec]) => (spec as any)?.config?.children !== undefined) + .map(([type]) => type), + ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, }), diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index 4f0515df95..11ebd6d3df 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -145,7 +145,7 @@ export function transformPasted(slice: Slice, view: EditorView) { } for (let i = 0; i < f.childCount; i++) { - if (f.child(i).type.spec.group === "blockContent") { + if (f.child(i).type.isInGroup("blockContent")) { const content = [f.child(i)]; // when there is a blockGroup with lists, it should be nested in the new blockcontainer diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index fddd2712e9..36e734beb5 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -1,3 +1,7 @@ +import { + containerNodeSelector, + holdsBlocks, +} from "../../schema/blocks/containers.js"; import { DOMParser, Slice } from "@tiptap/pm/model"; import { EditorState, @@ -47,7 +51,14 @@ function getBlockFromCoords( continue; } if (adjustForColumns) { - const column = element.closest("[data-node-type=columnList]"); + // A container that holds containers rather than blocks lays its children + // out side by side (a column list and its columns), so the coordinates + // hit the wrong child without an offset. + const selector = containerNodeSelector( + view.state.schema, + (type) => !holdsBlocks(type), + ); + const column = selector ? element.closest(selector) : null; if (column) { return getBlockFromCoords( view, diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index f8ba326538..e59bc4f87c 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -39,10 +39,14 @@ function blockPositionsFromSelection(selection: Selection, doc: Node) { // the same blocks again. If this happens, the anchor & head move out of the block content node they were originally // in. If the anchor should update but the head shouldn't and vice versa, it means the user selection is outside a // block content node, which should never happen. - const selectionStartInBlockContent = - doc.resolve(selection.from).node().type.spec.group === "blockContent"; - const selectionEndInBlockContent = - doc.resolve(selection.to).node().type.spec.group === "blockContent"; + const selectionStartInBlockContent = doc + .resolve(selection.from) + .node() + .type.isInGroup("blockContent"); + const selectionEndInBlockContent = doc + .resolve(selection.to) + .node() + .type.isInGroup("blockContent"); // Ensures that entire outermost nodes are selected if the selection spans multiple nesting levels. const minDepth = Math.min(selection.$anchor.depth, selection.$head.depth); diff --git a/packages/core/src/extensions/TrailingNode/TrailingNode.ts b/packages/core/src/extensions/TrailingNode/TrailingNode.ts index ea8bc47cd5..a2d1b9aa5b 100644 --- a/packages/core/src/extensions/TrailingNode/TrailingNode.ts +++ b/packages/core/src/extensions/TrailingNode/TrailingNode.ts @@ -1,3 +1,7 @@ +import { + holdsBlocks, + isContainerNode, +} from "../../schema/blocks/containers.js"; import type { Node as PMNode } from "prosemirror-model"; import { Plugin, @@ -31,9 +35,9 @@ function containerNeedsTrailingWidget(container: PMNode): boolean { // package. Nested blockGroups (a block's children) are excluded, as they have // no empty space below them for a widget to occupy. function getTrailingWidgetPositions(doc: PMNode): number[] { - // When the schema has no columns, the root blockGroup is the only possible - // container, so traversing the doc to find others can be skipped. - if (!doc.type.schema.nodes["column"]) { + // When the schema has no container blocks, the root blockGroup is the only + // possible container, so traversing the doc to find others can be skipped. + if (!Object.values(doc.type.schema.nodes).some(isContainerNode)) { const rootGroup = doc.lastChild; return rootGroup && containerNeedsTrailingWidget(rootGroup) ? [doc.content.size - 1] @@ -48,7 +52,7 @@ function getTrailingWidgetPositions(doc: PMNode): number[] { } const isContainer = - node.type.name === "column" || + (isContainerNode(node.type) && holdsBlocks(node.type)) || (node.type.name === "blockGroup" && parent?.type.name === "doc"); if (isContainer && containerNeedsTrailingWidget(node)) { diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..d817bd6180 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -7,6 +7,8 @@ import { getNextBlockInfo, getParentBlockInfo, getPrevBlockInfo, + compartmentOwnerInfo, + mergeBlockPairCommand, mergeBlocksCommand, } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; import { @@ -14,7 +16,17 @@ import { nestBlock, unnestBlock, } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; -import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +import { + containerAncestorIds, + fixContainersById, +} from "../../../api/blockManipulation/containers/fixContainer.js"; +import { + ascendToInsertablePos, + compartmentBody, + descendToBlockPos, + isCompartment, + isContainerNode, +} from "../../../schema/blocks/containers.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { @@ -97,10 +109,16 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const { bnBlock: blockContainer, blockContent } = blockInfo; - const prevBlockInfo = getPrevBlockInfo( + // The block before this one: its previous sibling, or - for the + // first block of a compartment - the block that owns it, so a + // callout's first body block merges into its title. + const prevSibling = getPrevBlockInfo( state.doc, blockInfo.bnBlock.beforePos, ); + const prevBlockInfo = + prevSibling ?? + compartmentOwnerInfo(state.doc, blockInfo.bnBlock.beforePos); // If the previous block has no inline content, it can't be merged. // It's instead deleted, which is done later in the chan, so we // return early here. @@ -112,6 +130,19 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } + // The sibling before this one owns a compartment, so this block + // moves into it whole rather than having its text merged across + // the compartment's edge. Handled by the branch below. + if ( + prevSibling && + compartmentBody( + prevSibling.bnBlock.node, + prevSibling.bnBlock.beforePos, + ) + ) { + return false; + } + const selectionAtBlockStart = state.selection.from === blockContent.beforePos + 1; const selectionEmpty = state.selection.empty; @@ -120,15 +151,20 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (selectionAtBlockStart && selectionEmpty) { return chain() - .command(mergeBlocksCommand(posBetweenBlocks)) + .command( + prevSibling + ? mergeBlocksCommand(posBetweenBlocks) + : mergeBlockPairCommand(prevBlockInfo, blockInfo), + ) .scrollIntoView() .run(); } 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, moves the current block into + // it, at the end of the last of its children that holds blocks (the + // last column of a column list). () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -146,21 +182,33 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!prevBlockInfo || prevBlockInfo.isBlockContainer) { + if ( + !prevBlockInfo || + !compartmentBody( + prevBlockInfo.bnBlock.node, + prevBlockInfo.bnBlock.beforePos, + ) + ) { return false; } - if (dispatch) { - const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1; - const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1); + const insertPos = descendToBlockPos( + state.doc, + prevBlockInfo.bnBlock.beforePos, + "end", + ); + if (insertPos === undefined) { + return false; + } + if (dispatch) { tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node); + tr.insert(insertPos, blockInfo.bnBlock.node); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)), + TextSelection.near(tr.doc.resolve(insertPos + 1)), ); return true; @@ -168,9 +216,9 @@ 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 one in a container, moves it out: into the + // end of the container's previous sibling (the previous column), or + // above the container it sits in when there is none. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -192,32 +240,50 @@ 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(); + const $parentPos = tr.doc.resolve($pos.before()); + const grandParent = $parentPos.node(); + // Where the block lands: the end of the container's previous + // sibling if it has one, otherwise just before the outermost + // container it is leaving. + const outerPos = isContainerNode(grandParent.type) + ? $parentPos.before() + : undefined; + const isFirstChild = + outerPos === undefined || $parentPos.pos === outerPos + 1; + const repairId = parentBlock.attrs.id; if (dispatch) { 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)), + if (repairId) { + fixContainersById(tr, [repairId]); + } + if (outerPos !== undefined) { + fixContainersById( + tr, + containerAncestorIds( + tr.doc, + Math.min(outerPos + 1, tr.doc.content.size), + ), ); } + + const insertPos = isFirstChild + ? (outerPos ?? $parentPos.pos) + : $parentPos.pos - 1; + + tr.insert(insertPos, blockInfo.bnBlock.node); + tr.setSelection( + TextSelection.near( + tr.doc.resolve(isFirstChild ? insertPos : insertPos + 1), + ), + ); } return true; @@ -468,8 +534,9 @@ 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, moves the first block out of it (the + // first block of a column list's first column) to after the current + // block. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -491,18 +558,31 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - if (dispatch) { - const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1; - const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); + const firstBlockPos = descendToBlockPos( + state.doc, + nextBlockInfo.bnBlock.beforePos, + "start", + ); + if (firstBlockPos === undefined) { + return false; + } + const $firstBlockPos = tr.doc.resolve(firstBlockPos); + const firstBlock = $firstBlockPos.nodeAfter; + if (!firstBlock) { + return false; + } - tr.delete( - $blockBeforePos.pos, - $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, - ); - fixColumnList(tr, nextBlockInfo.bnBlock.beforePos); - tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!); + if (dispatch) { + const containerId = nextBlockInfo.bnBlock.node.attrs.id; + + tr.delete(firstBlockPos, firstBlockPos + firstBlock.nodeSize); + fixContainersById(tr, [ + ...containerAncestorIds(tr.doc, firstBlockPos), + ...(containerId ? [containerId] : []), + ]); + tr.insert(blockInfo.bnBlock.afterPos, firstBlock); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), + TextSelection.near(tr.doc.resolve(blockInfo.bnBlock.afterPos)), ); return true; @@ -510,9 +590,9 @@ 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 one in a container, pulls in the block that + // follows the container: the first block of the next sibling (the next + // column), or the block after the container it sits in. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -534,36 +614,45 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { return false; } const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos); - const $columnEndPos = tr.doc.resolve($blockEndPos.after()); - const columnListEndPos = $columnEndPos.after(); + const $parentEndPos = tr.doc.resolve($blockEndPos.after()); + const grandParent = $parentEndPos.node(); + const outerEndPos = isContainerNode(grandParent.type) + ? $parentEndPos.after() + : undefined; + // The block after the container: the start of its next sibling, or + // the first block past the outermost container it is in. + const isLastChild = + outerEndPos === undefined || + $parentEndPos.pos === outerEndPos - 1; + const nextBlockBeforePos = isLastChild + ? (outerEndPos ?? $parentEndPos.pos) + : $parentEndPos.pos + 1; + if (nextBlockBeforePos >= tr.doc.content.size) { + 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 repairIds = containerAncestorIds( + tr.doc, + nextBlockInfo.bnBlock.beforePos, + ); tr.delete( nextBlockInfo.bnBlock.beforePos, nextBlockInfo.bnBlock.afterPos, ); - fixColumnList( - tr, - columnListEndPos - $columnEndPos.node().nodeSize, - ); + fixContainersById(tr, repairIds); tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node); tr.setSelection( - TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), + TextSelection.near(tr.doc.resolve($blockEndPos.pos)), ); } @@ -859,6 +948,64 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // Leaves a compartment: an empty block at the end of a callout's body + // (or a column's) moves out below it, so a second Enter gets you out + // the way it gets you out of a list. The first block of a body stays + // put - it is where the body begins, not a way out of it. + () => + commands.command(({ state, dispatch, tr }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isBlockContainer) { + return false; + } + const { bnBlock, blockContent } = blockInfo; + + if (blockContent.node.childCount !== 0 || !state.selection.empty) { + return false; + } + if (blockInfo.childContainer) { + return false; + } + + const $pos = state.doc.resolve(bnBlock.beforePos); + if ($pos.depth < 1) { + return false; + } + const body = $pos.node(); + const isLast = $pos.index() === body.childCount - 1; + const hasPrevious = $pos.index() > 0; + if (!isLast || !hasPrevious) { + return false; + } + + const ownerDepth = isContainerNode(body.type) + ? $pos.depth + : $pos.depth - 1; + if (ownerDepth < 1 || !isCompartment($pos.node(ownerDepth))) { + return false; + } + + if (dispatch) { + const afterOwner = $pos.after(ownerDepth); + tr.delete(bnBlock.beforePos, bnBlock.afterPos); + + const insertPos = ascendToInsertablePos( + tr.doc, + tr.mapping.map(afterOwner), + bnBlock.node.type, + ); + if (insertPos === undefined) { + return false; + } + + tr.insert(insertPos, bnBlock.node); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertPos + 1)), + ).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. () => @@ -915,6 +1062,71 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // Enter in a compartment's own content (a callout's title) starts its + // body rather than splitting the block in two: whatever follows the + // cursor becomes the body's first block, and the body the callout + // already had stays where it is. + () => + commands.command(({ state, dispatch, tr }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isBlockContainer) { + return false; + } + const { bnBlock, blockContent } = blockInfo; + + if (!isCompartment(bnBlock.node)) { + return false; + } + if (!state.selection.empty) { + return false; + } + const contentEnd = blockContent.afterPos - 1; + if ( + state.selection.from < blockContent.beforePos + 1 || + state.selection.from > contentEnd + ) { + return false; + } + + if (dispatch) { + // Everything after the cursor moves into the new block, so + // splitting the title mid-way puts its tail at the top of the + // body instead of handing the body to a new sibling. + const tail = blockContent.node.cut( + state.selection.from - (blockContent.beforePos + 1), + ); + const newBlock = state.schema.nodes["blockContainer"].create( + undefined, + state.schema.nodes["paragraph"].create(undefined, tail.content), + ); + + tr.delete(state.selection.from, contentEnd); + + const body = compartmentBody( + tr.doc.resolve(bnBlock.beforePos).nodeAfter!, + bnBlock.beforePos, + ); + // Without a body yet, one is created around the new block. + const insertPos = body + ? body.beforePos + 1 + : tr.mapping.map(blockContent.afterPos); + tr.insert( + insertPos, + body + ? newBlock + : state.schema.nodes["blockGroup"].create( + undefined, + newBlock, + ), + ) + .setSelection( + new TextSelection(tr.doc.resolve(insertPos + (body ? 2 : 3))), + ) + .scrollIntoView(); + } + + return true; + }), // Splits the current block, moving content inside that's after the cursor to a new text block below. Also // deletes the selection beforehand, if it's not empty. () => diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4f220e1e2..d6700be7f9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js"; export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; -export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +export * from "./api/blockManipulation/containers/fixContainer.js"; +export * from "./schema/blocks/containers.js"; export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index 86bd2ccb15..b288f8407b 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -1,6 +1,13 @@ -import { Node } from "@tiptap/core"; +import { + Node, + type NodeViewRenderer, + type NodeViewRendererProps, +} from "@tiptap/core"; +import type { Node as PMNode } from "@tiptap/pm/model"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; + +import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js"; import { BlockNoteDOMAttributes } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; import { suggestionMarks } from "./suggestionMarks.js"; @@ -88,4 +95,75 @@ export const BlockContainer = Node.create<{ contentDOM: block, }; }, + + addNodeView() { + // Cast: this returns a plain ProseMirror node view, which tiptap's + // `NodeViewRenderer` type doesn't model. + return ((props: NodeViewRendererProps) => { + const editor = this.options.editor; + const contentType = props.node.firstChild?.type.name; + const renderFrame = contentType + ? editor?.blockImplementations?.[contentType]?.implementation + ?.renderFrame + : undefined; + + const { dom, contentDOM } = this.type.spec.toDOM!(props.node) as { + dom: HTMLElement; + contentDOM: HTMLElement; + }; + // REVIEW: no React version of renderFrame.. + // A block type can frame its whole block - its content and its nested + // children together - with markup of its own. Everything renders into + // the frame's slot, so the frame surrounds both. + const frame = renderFrame + ? renderFrame( + // The node view is on the `blockContainer` itself, so the block is + // read from its own node rather than resolved through `getPos()` + // (which can't be trusted mid-reconciliation anyway). + nodeToBlock(props.node, props.view.state.doc) as any, + editor as any, + ) + : undefined; + if (frame) { + // REVIEW: desired, or not use `toDOM` at all for the nodeview path? + contentDOM.appendChild(frame.dom); + } + + let current = props.node; + + return { + dom, + contentDOM: frame ? frame.slot : contentDOM, + // Whether a block is framed, and by what, follows from the type of its + // content node - which this node's own markup says nothing about. So a + // node view is rebuilt whenever that type changes, as well as when + // ProseMirror would have rebuilt it anyway (a change of markup). + // Otherwise the frame is told to update itself. + update: (node: PMNode) => { + if ( + !node.sameMarkup(current) || + node.firstChild?.type.name !== contentType + ) { + return false; + } + current = node; + frame?.update?.(nodeToBlock(node, props.view.state.doc) as any); + return true; + }, + // The frame's own chrome (a button, a menu) is the author's, not the + // editor's: ProseMirror would otherwise treat a click on it as a click + // in the document and swallow it. Everything in the slot stays the + // editor's. + stopEvent: (event: Event) => { + const target = event.target as globalThis.Node | null; + return ( + !!frame && + !!target && + frame.dom.contains(target) && + !frame.slot.contains(target) + ); + }, + }; + }) as unknown as NodeViewRenderer; + }, }); diff --git a/packages/core/src/schema/blocks/compartments.test.ts b/packages/core/src/schema/blocks/compartments.test.ts new file mode 100644 index 0000000000..65c1d167ec --- /dev/null +++ b/packages/core/src/schema/blocks/compartments.test.ts @@ -0,0 +1,353 @@ +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { 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"; + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// A callout with a title: an ordinary block whose own rich text is the title, +// declaring that its children are a compartment (a body), and framing both. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: {}, + content: "inline" as const, + children: { allow: "any" as const, min: 0 }, + }, + { + render: renderDiv, + renderFrame: () => { + const dom = document.createElement("div"); + const slot = document.createElement("div"); + dom.append(slot); + return { dom, slot }; + }, + }, +)(); + +// A callout without a title: a container block, its own element is the box. +const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none" as const, + children: { allow: "any" as const }, + }, + { render: renderDiv }, +)(); + +// A toggle: it frames itself, but declares no `children`, so its children are +// ordinary nesting rather than a body that belongs to it. +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: {}, + content: "inline" as const, + }, + { + render: renderDiv, + renderFrame: () => { + const dom = document.createElement("div"); + dom.className = "toggle-frame"; + const slot = document.createElement("div"); + dom.append(slot); + return { dom, slot }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + box: Box, + toggle: Toggle, + } as const, +}); + +function press(editor: any, key: string, mods: string[] = []) { + const view = editor._tiptapEditor.view; + const codes: Record = { Enter: 13, Backspace: 8, Tab: 9 }; + const event = new KeyboardEvent("keydown", { + key, + code: key, + keyCode: codes[key], + bubbles: true, + shiftKey: mods.includes("Shift"), + } as any); + return !!view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + +function shape(blocks: any[]): string { + return blocks + .map((block) => { + const text = Array.isArray(block.content) + ? block.content.map((c: any) => c.text ?? "").join("") + : ""; + const children = block.children?.length + ? `[${shape(block.children)}]` + : ""; + return `${block.type}"${text}"${children}`; + }) + .join(", "); +} + +function getBlockPos(doc: any, id: string): number { + let pos = -1; + doc.descendants((node: any, at: number) => { + if (pos < 0 && node.attrs?.id === id) { + pos = at; + } + return pos < 0; + }); + return pos; +} + +function editorWith(initialContent: any[]) { + const editor = BlockNoteEditor.create({ schema, initialContent } as any); + editor.mount(document.createElement("div")); + return editor; +} + +const before = { id: "pre", type: "paragraph" as const, content: "Before" }; +const after = { id: "post", type: "paragraph" as const, content: "After" }; +const body = [ + { id: "b1", type: "paragraph" as const, content: "One" }, + { id: "b2", type: "paragraph" as const, content: "Two" }, +]; + +const withCallout = (children: any[] = body) => [ + before, + { id: "w", type: "callout" as const, content: "Title", children }, + after, +]; +const withBox = (children: any[] = body) => [ + before, + { id: "w", type: "box" as const, children }, + after, +]; + +describe("a compartment's keyboard behaviour", () => { + describe("callout (a title plus a body)", () => { + it("Enter at the end of the title starts the body, keeping it", () => { + const editor = editorWith(withCallout()); + editor.setTextCursorPosition("w", "end"); + press(editor, "Enter"); + + // The new block belongs to the callout, and the body is still the + // callout's - not carried off by a new sibling. + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"Title"[paragraph"", paragraph"One", paragraph"Two"], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("Enter in the middle of the title keeps the body on the callout", () => { + // The bug behind the toggle-block reports (#2020, #2378): splitting a + // block handed its children to the new block, so a callout's body ended + // up under whatever the split created. + const editor = editorWith(withCallout()); + editor.setTextCursorPosition("w", "start"); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, tr.selection.from + 2)), + ); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"Ti"[paragraph"tle", paragraph"One", paragraph"Two"], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("Enter in an empty last body block leaves the callout", () => { + const editor = editorWith( + withCallout([body[0], { id: "b2", type: "paragraph", content: "" }]), + ); + editor.setTextCursorPosition("b2", "start"); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"Title"[paragraph"One"], paragraph"", paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("Enter in an empty body block that is the only one stays put", () => { + // Nothing to escape from yet: the block is where a new callout's body + // starts, and leaving would dissolve the callout the user just made. + const editor = editorWith( + withCallout([{ id: "b1", type: "paragraph", content: "" }]), + ); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"Title"[paragraph"", paragraph""], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("Backspace at the start of the first body block merges into the title", () => { + const editor = editorWith(withCallout()); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Backspace"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"TitleOne"[paragraph"Two"], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("Shift-Tab in the body does not escape the callout", () => { + const editor = editorWith(withCallout()); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Tab", ["Shift"]); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"Title"[paragraph"One", paragraph"Two"], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("Backspace in the block after moves it into the body, whole", () => { + const editor = editorWith(withCallout()); + editor.setTextCursorPosition("post", "start"); + press(editor, "Backspace"); + + // Moved in as its own block: text never merges across the edge. + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"Title"[paragraph"One", paragraph"Two", paragraph"After"]', + ); + editor._tiptapEditor.destroy(); + }); + + it("Tab still nests inside the body", () => { + const editor = editorWith(withCallout()); + editor.setTextCursorPosition("b2", "start"); + press(editor, "Tab"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout"Title"[paragraph"One"[paragraph"Two"]], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + }); + + describe("box (a body with no title)", () => { + it("Enter in an empty last child leaves the box", () => { + const editor = editorWith( + withBox([body[0], { id: "b2", type: "paragraph", content: "" }]), + ); + editor.setTextCursorPosition("b2", "start"); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", box""[paragraph"One"], paragraph"", paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("Enter in an empty child that is the only one stays put", () => { + const editor = editorWith( + withBox([{ id: "b1", type: "paragraph", content: "" }]), + ); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", box""[paragraph"", paragraph""], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + }); + + describe("a compartment is still an ordinary block", () => { + it("round-trips through HTML, keeping its type, title and body", () => { + // Dragging a block, and copying one, both go through this: a compartment + // that serialized like a container would come back as a paragraph. + const editor = editorWith(withCallout()); + + const html = editor.blocksToFullHTML(editor.document as any); + const parsed = editor.tryParseHTMLToBlocks(html); + + expect(shape(parsed)).toBe( + 'paragraph"Before", callout"Title"[paragraph"One", paragraph"Two"], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("survives the clipboard round-trip that copy and drag use", () => { + // Dragging a block inside the editor re-parses it from the HTML + // ProseMirror serializes the dragged slice to, so a compartment whose + // parse rules don't match that HTML comes back as a paragraph. + const editor = editorWith(withCallout()); + const view = editor._tiptapEditor.view; + + editor.transact((tr) => + tr.setSelection(NodeSelection.create(tr.doc, getBlockPos(tr.doc, "w"))), + ); + const html = view.serializeForClipboard(view.state.selection.content()) + .dom.innerHTML; + + expect(shape(editor.tryParseHTMLToBlocks(html))).toBe( + 'callout"Title"[paragraph"One", paragraph"Two"]', + ); + editor._tiptapEditor.destroy(); + }); + }); + + describe("blocks that declare no compartment are untouched", () => { + it("keeps ordinary nesting behaviour for a nested paragraph", () => { + const editor = editorWith([ + before, + { id: "w", type: "paragraph", content: "Title", children: body }, + after, + ]); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Tab", ["Shift"]); + + // Shift-Tab lifts it out, as it always has. + expect(shape(editor.document)).toBe( + 'paragraph"Before", paragraph"Title", paragraph"One"[paragraph"Two"], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("a block that frames itself still nests ordinarily", () => { + // A toggle wants the frame but not the compartment: Shift-Tab takes a + // child out of it, the way it does for any nested block. Declaring + // `children` is what makes the gestures treat them as a body instead. + const editor = editorWith([ + before, + { id: "w", type: "toggle", content: "Title", children: body }, + after, + ]); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Tab", ["Shift"]); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", toggle"Title", paragraph"One"[paragraph"Two"], paragraph"After"', + ); + editor._tiptapEditor.destroy(); + }); + + it("a frame without a compartment still wraps the children it nests", () => { + const editor = editorWith([ + { id: "w", type: "toggle", content: "Title", children: body }, + ]); + const frame = editor.domElement!.querySelector(".toggle-frame"); + + expect(frame).not.toBeNull(); + expect( + frame!.querySelectorAll(".bn-block-group .bn-block-outer").length, + ).toBe(2); + editor._tiptapEditor.destroy(); + }); + }); +}); diff --git a/packages/core/src/schema/blocks/containers.test.ts b/packages/core/src/schema/blocks/containers.test.ts new file mode 100644 index 0000000000..709a2e40c1 --- /dev/null +++ b/packages/core/src/schema/blocks/containers.test.ts @@ -0,0 +1,207 @@ +import { 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"; + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// A grid of cells: the same shape as a column list and its columns. +const Cell = createBlockSpec( + { + type: "cell" as const, + propSchema: {}, + content: "none" as const, + children: { allow: "any" as const }, + placement: "containerOnly" as const, + }, + { render: renderDiv }, +)(); + +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: { tone: { default: "plain" } }, + content: "none" as const, + children: { allow: ["cell"] as const, min: 2 }, + }, + { render: renderDiv }, +)(); + +// A container placeable anywhere, holding blocks directly. +const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none" as const, + children: { allow: "any" as const }, + }, + { render: renderDiv }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + cell: Cell, + grid: Grid, + box: Box, + } as const, +}); + +function shape(blocks: any[]): string { + return blocks + .map((block) => { + const text = Array.isArray(block.content) + ? block.content.map((c: any) => c.text ?? "").join("") + : ""; + const children = block.children?.length + ? `[${shape(block.children)}]` + : ""; + return `${block.type}${text ? `"${text}"` : ""}${children}`; + }) + .join(", "); +} + +function editorWith(initialContent: any[]) { + const editor = BlockNoteEditor.create({ schema, initialContent } as any); + editor.mount(document.createElement("div")); + return editor; +} + +const grid = (...cells: string[][]) => ({ + id: "g", + type: "grid" as const, + children: cells.map((paragraphs, i) => ({ + id: `c${i}`, + type: "cell" as const, + children: paragraphs.map((content, j) => ({ + id: `c${i}p${j}`, + type: "paragraph" as const, + content, + })), + })), +}); + +describe("container blocks", () => { + it("builds a node that holds its children directly", () => { + const editor = editorWith([grid(["A"], ["B"])]); + expect(shape(editor.document)).toBe( + 'grid[cell[paragraph"A"], cell[paragraph"B"]]', + ); + expect(editor.pmSchema.nodes["grid"].isInGroup("bnBlock")).toBe(true); + expect(editor.pmSchema.nodes["grid"].isInGroup("childContainer")).toBe( + true, + ); + // A `containerOnly` block stays out of the group regular blocks live in, + // so it can only ever appear inside a container that names it. + expect(editor.pmSchema.nodes["cell"].isInGroup("blockGroupChild")).toBe( + false, + ); + expect(editor.pmSchema.nodes["box"].isInGroup("blockGroupChild")).toBe( + true, + ); + editor._tiptapEditor.destroy(); + }); + + it("rejects a container inserted without the children it requires", () => { + const editor = editorWith([{ id: "p", type: "paragraph", content: "P" }]); + expect(() => + editor.insertBlocks([{ type: "grid" } as any], "p", "after"), + ).toThrow(); + editor._tiptapEditor.destroy(); + }); + + it("rejects children a container doesn't allow", () => { + const editor = editorWith([grid(["A"], ["B"])]); + expect(() => + editor.insertBlocks( + [{ type: "paragraph", content: "nope" } as any], + "c0", + "before", + ), + ).toThrow(); + editor._tiptapEditor.destroy(); + }); + + it("dissolves into the surviving child when emptied below its minimum", () => { + const editor = editorWith([grid(["A"], ["B"])]); + editor.removeBlocks(["c1p0"]); + expect(shape(editor.document)).toBe('paragraph"A"'); + editor._tiptapEditor.destroy(); + }); + + it("keeps a container that still has enough children", () => { + const editor = editorWith([grid(["A"], ["B"], ["C"])]); + editor.removeBlocks(["c2p0"]); + expect(shape(editor.document)).toBe( + 'grid[cell[paragraph"A"], cell[paragraph"B"]]', + ); + editor._tiptapEditor.destroy(); + }); + + it("dissolves a container the user emptied out", () => { + const editor = editorWith([ + { + id: "b", + type: "box", + children: [ + { id: "b1", type: "paragraph", content: "" }, + { id: "b2", type: "paragraph", content: "Kept" }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + editor.removeBlocks(["b2"]); + expect(shape(editor.document)).toBe('paragraph"After"'); + editor._tiptapEditor.destroy(); + }); + + it("keeps a container that still holds something", () => { + const editor = editorWith([ + { + id: "b", + type: "box", + children: [ + { id: "b1", type: "paragraph", content: "" }, + { id: "b2", type: "paragraph", content: "Kept" }, + ], + }, + ]); + editor.removeBlocks(["b1"]); + expect(shape(editor.document)).toBe('box[paragraph"Kept"]'); + editor._tiptapEditor.destroy(); + }); + + it("round-trips through internal HTML", () => { + const editor = editorWith([grid(["A"], ["B"])]); + const html = editor.blocksToFullHTML(editor.document as any); + expect(html).toContain('data-node-type="grid"'); + expect(shape(editor.tryParseHTMLToBlocks(html))).toBe( + 'grid[cell[paragraph"A"], cell[paragraph"B"]]', + ); + editor._tiptapEditor.destroy(); + }); + + it("round-trips a non-default prop", () => { + const editor = editorWith([ + { ...grid(["A"], ["B"]), props: { tone: "loud" } }, + ]); + const html = editor.blocksToFullHTML(editor.document as any); + expect(html).toContain('data-tone="loud"'); + expect(editor.tryParseHTMLToBlocks(html)[0].props).toMatchObject({ + tone: "loud", + }); + editor._tiptapEditor.destroy(); + }); + + it("gives every container block an id", () => { + const editor = editorWith([grid(["A"], ["B"])]); + expect(editor.getBlock("g")).not.toBeUndefined(); + expect(editor.getBlock("c0")).not.toBeUndefined(); + editor._tiptapEditor.destroy(); + }); +}); diff --git a/packages/core/src/schema/blocks/containers.ts b/packages/core/src/schema/blocks/containers.ts new file mode 100644 index 0000000000..27c04c893a --- /dev/null +++ b/packages/core/src/schema/blocks/containers.ts @@ -0,0 +1,229 @@ +import { + Fragment, + type Node, + type NodeType, + type Schema, +} from "prosemirror-model"; + +import type { ChildrenConfig } from "./types.js"; + +/** + * Group of every node that holds child blocks directly: the `blockGroup` + * nesting regular blocks' children, and container blocks. + */ +export const CHILD_CONTAINER_GROUP = "childContainer"; + +/** + * The schema priority every container block's node registers at. Below + * `blockContainer`'s 50, so that when ProseMirror fills a `blockGroup` it + * reaches for a regular block rather than nesting containers inside each other + * forever. + */ +export const CONTAINER_NODE_PRIORITY = 40; + +/** + * Group of every node that may sit where a regular block goes: `blockContainer` + * and container blocks placeable anywhere. + */ +export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; + +/** + * Joined by the content node of a block whose `children` are a *compartment*: + * a body that belongs to the block, like a callout's. Editing gestures move + * blocks in and out of it deliberately instead of treating it as ordinary + * indentation. + */ +export const COMPARTMENT_GROUP = "compartment"; + +/** + * Whether a block config declares a *container block*: one whose own node + * holds its children. A block that has content of its own keeps its ordinary + * shape, and its `children` declare a compartment instead. + */ +export function isContainerConfig(config: { + content: string; + children?: unknown; +}): boolean { + return config.children !== undefined && config.content === "none"; +} + +/** + * Whether `node` is a block whose children are a compartment: a container + * block, or a `blockContainer` whose block declares `children`. + */ +export function isCompartment(node: Node): boolean { + return ( + isContainerNode(node.type) || + (node.type.name === "blockContainer" && + !!node.firstChild?.type.isInGroup(COMPARTMENT_GROUP)) + ); +} + +/** + * The node holding a compartment's children, and the position just before it: + * a container block holds them itself, a `blockContainer` in its `blockGroup`. + * `undefined` when the block isn't a compartment, or has no children yet. + */ +export function compartmentBody( + node: Node, + beforePos: number, +): { node: Node; beforePos: number } | undefined { + if (!isCompartment(node)) { + return undefined; + } + if (isContainerNode(node.type)) { + return { node, beforePos }; + } + const group = node.lastChild; + if (!group || group.type.name !== "blockGroup") { + return undefined; + } + return { + node: group, + beforePos: beforePos + node.nodeSize - 1 - group.nodeSize, + }; +} + +/** + * Whether `type` is a container block: a block whose node holds its children + * directly. `blockGroup` also holds children but is not a block. + */ +export function isContainerNode(type: NodeType): boolean { + return type.isInGroup("bnBlock") && type.isInGroup(CHILD_CONTAINER_GROUP); +} + +/** + * Whether `type` is a container block that may only live inside another + * container (`placement: "containerOnly"`), i.e. one the schema keeps out of + * `blockGroup`. + */ +export function isContainerOnly(type: NodeType): boolean { + return isContainerNode(type) && !type.isInGroup(BLOCK_GROUP_CHILD_GROUP); +} + +/** + * Whether a container that lost its children dissolves (is removed, or + * replaced by whatever survives) rather than being kept and padded. A + * container placeable anywhere dissolves; one that only exists inside another + * container is that container's concern and is kept. + */ +export function containerDissolves(type: NodeType): boolean { + return isContainerNode(type) && !isContainerOnly(type); +} + +/** + * Whether `type` holds regular blocks (`blockContainer` nodes) directly. + * `blockGroup` and a column do; a column list, which holds only columns, does + * not. + */ +export function holdsBlocks(type: NodeType): boolean { + const blockContainer = type.schema.nodes["blockContainer"]; + return type.contentMatch.matchType(blockContainer) !== null; +} + +/** + * The fewest children the schema lets `type` hold: the size of the fill + * ProseMirror would generate for an empty node of that type. + */ +export function minChildren(type: NodeType): number { + return type.contentMatch.fillBefore(Fragment.empty, true)?.childCount ?? 0; +} + +/** + * A CSS selector matching the elements of every container block type in + * `schema`, or `null` when the schema has none. + */ +export function containerNodeSelector( + schema: Schema, + filter: (type: NodeType) => boolean = () => true, +): string | null { + const types = Object.values(schema.nodes).filter( + (type) => isContainerNode(type) && filter(type), + ); + if (types.length === 0) { + return null; + } + return types.map((type) => `[data-node-type="${type.name}"]`).join(","); +} + +/** + * Compiles a `children` config into the container node's content expression. + */ +export function childrenContentExpression(children: ChildrenConfig): string { + const min = children.min ?? 1; + let allowed: string; + if (children.allow === "any") { + allowed = BLOCK_GROUP_CHILD_GROUP; + } else { + if (children.allow.length === 0) { + throw new Error( + "Container `allow` permits nothing. A container must accept at least one block type; drop `children` for a block that holds none.", + ); + } + allowed = + children.allow.length === 1 + ? children.allow[0] + : `(${children.allow.join(" | ")})`; + } + return allowed + (min === 0 ? "*" : min === 1 ? "+" : `{${min},}`); +} + +/** + * Walks into a container to the position where a block moved *into* it from + * the outside should land: the end of its last block-holding descendant (the + * end of a column list's last column), or its start-side counterpart. + * + * Returns `undefined` when nothing on that edge holds blocks. + */ +export function descendToBlockPos( + doc: Node, + containerBeforePos: number, + edge: "start" | "end", +): number | undefined { + let body = (() => { + const node = doc.resolve(containerBeforePos).nodeAfter; + return node ? compartmentBody(node, containerBeforePos) : undefined; + })(); + + while (body) { + if (holdsBlocks(body.node.type)) { + return edge === "start" + ? body.beforePos + 1 + : body.beforePos + body.node.nodeSize - 1; + } + const child = edge === "start" ? body.node.firstChild : body.node.lastChild; + if (!child) { + return undefined; + } + const childBefore = + edge === "start" + ? body.beforePos + 1 + : body.beforePos + body.node.nodeSize - 1 - child.nodeSize; + body = compartmentBody(child, childBefore); + } + + return undefined; +} + +/** + * Walks outwards from `pos` to the first position where `nodeType` fits: a + * block leaving a column ends up below the whole column list, since a column + * list holds only columns. `undefined` when nowhere on the way out takes it. + */ +export function ascendToInsertablePos( + doc: Node, + pos: number, + nodeType: NodeType, +): number | undefined { + for (;;) { + const $pos = doc.resolve(pos); + const parent = $pos.node(); + if (parent.canReplaceWith($pos.index(), $pos.index(), nodeType)) { + return pos; + } + if ($pos.depth === 0) { + return undefined; + } + pos = $pos.after(); + } +} diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index b1e54d640a..6093daa15e 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -7,11 +7,14 @@ import { } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { Extension, ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; +import { camelToDataKebab } from "../../util/string.js"; import { nonFormattingMarks } from "../markGroups.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { PropSchema } from "../propTypes.js"; import { @@ -19,6 +22,14 @@ import { propsToAttributes, wrapInBlockStructure, } from "./internal.js"; +import { + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + COMPARTMENT_GROUP, + CONTAINER_NODE_PRIORITY, + childrenContentExpression, + isContainerConfig, +} from "./containers.js"; import { BlockConfig, BlockConfigOrCreator, @@ -56,12 +67,17 @@ export function getParseRules< config: BlockConfig, implementation: BlockImplementation, ) { - const rules: TagParseRule[] = [ - { - tag: "[data-content-type=" + config.type + "]", - contentElement: ".bn-inline-content", - }, - ]; + // A container block owns its outer element, so its own marker is + // `data-node-type` (like every other block node) rather than the + // `data-content-type` of a regular block's content element. + const rules: TagParseRule[] = isContainerConfig(config) + ? [{ tag: `[data-node-type="${config.type}"]` }] + : [ + { + tag: "[data-content-type=" + config.type + "]", + contentElement: ".bn-inline-content", + }, + ]; if (implementation.parse) { rules.push({ @@ -167,6 +183,137 @@ export function getParseRules< return rules; } +/** + * Builds the ProseMirror node for a container block: a `bnBlock` node holding + * its children directly, with the content expression compiled from `children`. + */ +function buildContainerNode< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, +): Node { + const children = blockConfig.children!; + + const groups = ["bnBlock", CHILD_CONTAINER_GROUP]; + if (blockConfig.placement !== "containerOnly") { + // Placeable where a regular block goes, so `blockGroup` accepts it. + groups.push(BLOCK_GROUP_CHILD_GROUP); + } + + return Node.create({ + name: blockConfig.type, + group: groups.join(" "), + content: childrenContentExpression(children), + // Fixed, and below `blockContainer`'s 50: see CONTAINER_NODE_PRIORITY. A + // container's place in the schema is decided by this, not by the + // dependency order regular blocks are sorted into. + priority: CONTAINER_NODE_PRIORITY, + defining: true, + selectable: blockImplementation.meta?.selectable ?? true, + marks() { + return suggestionMarks(this.editor); + }, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + parseHTML() { + return getParseRules(blockConfig, blockImplementation); + }, + renderHTML({ HTMLAttributes }) { + // Like a regular block's `renderHTML`, this is a placeholder: it carries + // the attributes the parse rules read, which is all copy & paste needs. + // The block's own markup comes from its `render`, in the node view and + // in the HTML serializers. + const dom = document.createElement("div"); + applyContainerAttributes(dom, blockConfig.type, HTMLAttributes); + return { dom, contentDOM: dom }; + }, + addNodeView() { + return (props) => { + const editor = this.options.editor; + // A container block's own node is the block node, so it converts + // directly rather than resolving a parent through `getPos()`. + const block = nodeToBlock(props.node, props.view.state.doc); + const rendered = blockImplementation.render.call( + { + blockContentDOMAttributes: + this.options.domAttributes?.blockContent || {}, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + editor as any, + ) as { dom: HTMLElement; contentDOM?: HTMLElement }; + + // A container block's node view element *is* the block's element, so + // it carries the marker and props itself - there is no `blockContent` + // wrapper to put them on. Marking it here also covers renders that + // build their own element (React), which BlockNote can't wrap. + applyContainerAttributes( + rendered.dom, + blockConfig.type, + containerDOMAttributes(block.props, blockConfig.propSchema, block.id), + ); + + const nodeView = rendered as unknown as NodeView; + ignoreNonContentMutations(nodeView); + return nodeView; + }; + }, + }); +} + +/** + * Writes the attributes a container block's element carries: the type marker + * every block node has, and its non-default props as `data-*`, the same + * convention `propsToAttributes` parses back. + * + * A container block owns its outer element, so unlike a regular block's these + * can't be applied by wrapping the render's output - they go on the element + * the block's author returned. + */ +export function applyContainerAttributes( + element: HTMLElement, + blockType: string, + attributes: Record, +) { + for (const [attr, value] of Object.entries(attributes)) { + if (value === undefined || value === null) { + continue; + } + element.setAttribute(attr, `${value}`); + } + // After the props, so a prop can never overwrite the marker. + element.setAttribute("data-node-type", blockType); +} + +/** + * The DOM attributes for a container block outside a node view (serialization), + * where ProseMirror hasn't rendered the node's attributes for us: its id, and + * each non-default prop in the same `data-*` form `propsToAttributes` emits. + */ +export function containerDOMAttributes( + props: Record | undefined, + propSchema: PropSchema, + id: string | undefined, +): Record { + const attributes: Record = {}; + for (const [name, spec] of Object.entries(propSchema)) { + const value = props?.[name]; + if (value !== undefined && value !== spec.default) { + attributes[camelToDataKebab(name)] = value; + } + } + if (id !== undefined) { + attributes["data-id"] = id; + } + return attributes; +} + // 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< @@ -181,6 +328,12 @@ export function addNodeAndExtensionsToSpec< ): LooseBlockSpec { const node = ((blockImplementation as any).node as Node) || + // `children` on a block with no content of its own makes it a container + // block: one node holding the children. On a block that *has* content it + // makes the children a compartment, and the block keeps its regular shape. + (isContainerConfig(blockConfig) + ? buildContainerNode(blockConfig, blockImplementation) + : undefined) || Node.create({ name: blockConfig.type, content: (blockConfig.content === "inline" @@ -205,7 +358,9 @@ export function addNodeAndExtensionsToSpec< ? nonFormattingMarks(this.editor) : undefined; }, - group: "blockContent", + group: blockConfig.children + ? `blockContent ${COMPARTMENT_GROUP}` + : "blockContent", selectable: blockImplementation.meta?.selectable ?? true, isolating: blockImplementation.meta?.isolating ?? true, code: blockImplementation.meta?.code ?? false, @@ -470,6 +625,21 @@ export function createBlockSpec< return undefined; } + // A container block's element *is* the block's element, so it isn't + // wrapped; it carries the attributes itself. + if (isContainerConfig(blockConfig)) { + applyContainerAttributes( + output.dom as HTMLElement, + block.type, + containerDOMAttributes( + block.props, + this.propSchema ?? blockConfig.propSchema, + undefined, + ), + ); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -489,6 +659,21 @@ export function createBlockSpec< editor as any, ); + // A container block's element *is* the block's element, so it isn't + // wrapped; it carries the attributes itself. + if (isContainerConfig(blockConfig)) { + applyContainerAttributes( + output.dom as HTMLElement, + block.type, + containerDOMAttributes( + block.props, + this.propSchema ?? blockConfig.propSchema, + block.id, + ), + ); + return output; + } + const nodeView = wrapInBlockStructure( output, block.type, diff --git a/packages/core/src/schema/blocks/renderFrame.test.ts b/packages/core/src/schema/blocks/renderFrame.test.ts new file mode 100644 index 0000000000..d613969f8c --- /dev/null +++ b/packages/core/src/schema/blocks/renderFrame.test.ts @@ -0,0 +1,166 @@ +import { 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"; + +// A callout: an ordinary block with rich text of its own (the title), whose +// nested children are its body. The frame puts both inside the author's box. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { default: "info", values: ["info", "warning"] as const }, + }, + content: "inline" as const, + }, + { + render: () => { + const dom = document.createElement("div"); + dom.className = "callout-title"; + return { dom, contentDOM: dom }; + }, + renderFrame: (block) => { + const dom = document.createElement("aside"); + dom.className = "callout"; + const icon = document.createElement("span"); + icon.className = "callout-icon"; + const slot = document.createElement("div"); + slot.className = "callout-inner"; + dom.append(icon, slot); + + const paint = (flavor: string) => { + dom.setAttribute("data-flavor", flavor); + icon.textContent = flavor === "warning" ? "!" : "i"; + }; + paint(block.props.flavor); + + return { dom, slot, update: (b: any) => paint(b.props.flavor) }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { ...defaultBlockSpecs, callout: Callout } as const, +}); + +function editorWith(initialContent: any[]) { + const editor = BlockNoteEditor.create({ schema, initialContent } as any); + const div = document.createElement("div"); + editor.mount(div); + return { editor, div }; +} + +const calloutDoc = [ + { + id: "c", + type: "callout" as const, + props: { flavor: "warning" as const }, + content: "Careful!", + children: [ + { id: "b1", type: "paragraph" as const, content: "Body one" }, + { id: "b2", type: "paragraph" as const, content: "Body two" }, + ], + }, + { id: "after", type: "paragraph" as const, content: "After" }, +]; + +describe("renderFrame", () => { + it("renders the block's content and children inside the frame's slot", () => { + const { editor, div } = editorWith(calloutDoc); + const block = div.querySelector('[data-id="c"]')!; + + expect( + block.querySelector(".callout > .callout-inner > .bn-block-content"), + ).not.toBeNull(); + expect( + block.querySelector(".callout > .callout-inner > .bn-block-group"), + ).not.toBeNull(); + // The block keeps the structure every other block has around the frame. + expect(block.classList.contains("bn-block-outer")).toBe(true); + expect(block.querySelector(".bn-block > .callout")).not.toBeNull(); + + editor._tiptapEditor.destroy(); + }); + + it("keeps the title editable and the children as real blocks", () => { + const { editor } = editorWith(calloutDoc); + + expect(editor.getBlock("c")!.content).toEqual([ + { type: "text", text: "Careful!", styles: {} }, + ]); + expect(editor.getBlock("c")!.children.map((c: any) => c.id)).toEqual([ + "b1", + "b2", + ]); + + editor._tiptapEditor.destroy(); + }); + + it("updates the frame when the block's props change", () => { + const { editor, div } = editorWith(calloutDoc); + const icon = () => div.querySelector('[data-id="c"] .callout-icon')!; + + expect(icon().textContent).toBe("!"); + editor.updateBlock("c", { props: { flavor: "info" } } as any); + expect(icon().textContent).toBe("i"); + + editor._tiptapEditor.destroy(); + }); + + it("leaves blocks without a frame exactly as they were", () => { + const { editor, div } = editorWith(calloutDoc); + const paragraph = div.querySelector('[data-id="after"]')!; + + expect(paragraph.outerHTML).toBe( + '
' + + '
' + + '
' + + '

After

' + + "
", + ); + + editor._tiptapEditor.destroy(); + }); + + it("frames a block that becomes a callout in place", () => { + const { editor, div } = editorWith(calloutDoc); + + editor.updateBlock("after", { type: "callout" } as any); + + // Whether a block is framed follows from its type, so converting one into + // a callout has to start framing it. + expect(div.querySelectorAll(".callout").length).toBe(2); + editor._tiptapEditor.destroy(); + }); + + it("frames the block again when the conversion is undone", () => { + const { editor, div } = editorWith(calloutDoc); + + editor.updateBlock("c", { type: "paragraph" } as any); + expect(div.querySelectorAll(".callout").length).toBe(0); + + editor.undo(); + expect(editor.getBlock("c")!.type).toBe("callout"); + expect(div.querySelectorAll(".callout").length).toBe(1); + editor._tiptapEditor.destroy(); + }); + + it("round-trips through HTML like any other block", () => { + const { editor } = editorWith(calloutDoc); + + const html = editor.blocksToFullHTML(editor.document as any); + const parsed = editor.tryParseHTMLToBlocks(html); + + expect(parsed[0].type).toBe("callout"); + expect((parsed[0] as any).props.flavor).toBe("warning"); + expect((parsed[0] as any).content[0].text).toBe("Careful!"); + expect(parsed[0].children.map((c: any) => c.type)).toEqual([ + "paragraph", + "paragraph", + ]); + + editor._tiptapEditor.destroy(); + }); +}); diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 8d7e203e61..97a2221445 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -80,6 +80,36 @@ export interface BlockConfigMeta< hasPreview?: boolean; } +/** + * Declares that a block's body is other blocks. + * + * On a block with `content: "none"` this makes it a *container block*: one + * whose own node holds the children (a column, a column list). `allow` and + * `min` become the node's content expression, so the schema enforces them. + * + * On a block that has content of its own it makes those children a + * *compartment*: the block keeps its ordinary shape, its content is its title + * and its children are its body, and editing gestures treat the two as one + * unit instead of as indentation. `allow` and `min` are not enforced there + * yet - every regular block shares one node type, so there is nothing for the + * schema to hold them to. + */ +export type ChildrenConfig = { + /** + * What may appear as a child: `"any"` for any block, or the names of the + * container block types that may (a `columnList` allows `["column"]`). + * Regular blocks can only be allowed as a whole, via `"any"`, since they all + * share one ProseMirror node type. + */ + allow: "any" | readonly string[]; + /** + * The fewest children the container may hold. Compiled into the schema, so + * ProseMirror keeps the container filled up to it. + * @default 1 + */ + min?: number; +}; + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -106,8 +136,17 @@ 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. See {@link ChildrenConfig}. + */ + children?: ChildrenConfig; + /** + * Where a container block may be placed. `"containerOnly"` restricts it to + * containers that name it in their `children.allow` (a `column` only ever + * lives in a `columnList`). Only meaningful together with `children`. + * @default "anywhere" + */ + placement?: "anywhere" | "containerOnly"; } /** @@ -212,7 +251,7 @@ export type LooseBlockSpec< config: BlockConfig; implementation: Omit< BlockImplementation, - "render" | "toExternalHTML" + "render" | "toExternalHTML" | "renderFrame" > & { // purposefully stub the types for render and toExternalHTML since they reference the block render: ( @@ -244,6 +283,14 @@ export type LooseBlockSpec< childrenDOM?: HTMLElement; } | undefined; + renderFrame?: ( + block: any, + editor: BlockNoteEditor, + ) => { + dom: HTMLElement; + slot: HTMLElement; + update?: (block: any) => void; + }; node: Node; }; @@ -271,7 +318,7 @@ export type BlockSpecs = { config: BlockSpec["config"]; implementation: Omit< BlockSpec["implementation"], - "render" | "toExternalHTML" + "render" | "toExternalHTML" | "renderFrame" > & { // purposefully stub the types for render and toExternalHTML since they reference the block render: ( @@ -303,6 +350,14 @@ export type BlockSpecs = { childrenDOM?: HTMLElement; } | undefined; + renderFrame?: ( + block: any, + editor: BlockNoteEditor, + ) => { + dom: HTMLElement; + slot: HTMLElement; + update?: (block: any) => void; + }; }; extensions?: BlockSpec["extensions"]; }; @@ -629,6 +684,33 @@ export type BlockImplementation< } | undefined; + /** + * Renders the element that frames the whole block: its content (from + * `render`) followed by its nested children. Return the frame's `dom` and + * the `slot` element inside it where BlockNote places both. + * + * Without a frame, a block's nested children render below its content in + * BlockNote's default nesting element. With one, they render wherever the + * slot is, inside markup the block's author owns: a callout can draw its + * box around its title and its body. + * + * Rendered once per node view. When the block changes (e.g. its props), the + * returned `update` is called with the new block so the frame can update + * itself in place; without one the frame is re-rendered from scratch. + */ + renderFrame?( + block: BlockFromConfig, any, any>, + editor: BlockNoteEditor< + Record> + >, + ): { + dom: HTMLElement; + slot: HTMLElement; + update?: ( + block: BlockFromConfig, any, any>, + ) => void; + }; + /** * Parses an external HTML element into a block of this type when it returns the block props object, otherwise undefined */ diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 5311d4e37d..2070c752d8 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -1,3 +1,9 @@ +import { + applyContainerAttributes, + type ChildrenConfig, + containerDOMAttributes, + isContainerConfig, +} from "@blocknote/core"; import { BlockConfig, BlockConfigOrCreator, @@ -34,10 +40,16 @@ export type ReactCustomBlockRenderProps< block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; } & (Config["content"] extends "inline" | "plain" - ? { - contentRef: (node: HTMLElement | null) => void; - } - : object); + ? ContentRef + : // A container block has no content of its own, but its children still + // render somewhere: the ref marks the slot that holds them. + undefined extends Config["children"] + ? object + : ContentRef); + +type ContentRef = { + contentRef: (node: HTMLElement | null) => void; +}; // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< @@ -74,6 +86,43 @@ export type ReactCustomBlockSpec< // Function that wraps the React component returned from 'blockConfig.render' in // a `NodeViewWrapper` which also acts as a `blockContent` div. It contains the // block type and props as HTML attributes. +// Renders a container block outside a node view (serialization). Its element +// *is* the block's element, so it isn't wrapped in a `blockContent` div - it +// carries the marker and props itself. +function renderContainerToDOM( + BlockContent: FC, + block: any, + editor: any, + propSchema: B["propSchema"], + id: string | undefined, + context?: any, +) { + const output = renderToDOMSpec( + (refCB) => ( + + ), + editor, + ); + applyContainerAttributes( + output.dom as HTMLElement, + block.type, + containerDOMAttributes(block.props, propSchema, id), + ); + return output; +} + +// Wraps a container block's React component. A container block's node holds +// its children directly, so its element isn't a `blockContent` div: the marker +// and props go on the node view's own element, which the block core stamps. +export function ContainerWrapper(props: { children: ReactNode }) { + return {props.children}; +} + export function BlockContentWrapper< BType extends string, PSchema extends PropSchema, @@ -131,19 +180,27 @@ export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, const TContent extends "inline" | "none" | "plain", + // Carried alongside the three headline types so that what the config + // declares about its children reaches the implementation: a container + // block has no content of its own, but its render still places them. + const TChildren extends ChildrenConfig | undefined = undefined, const TOptions extends Record | undefined = undefined, >( - blockConfigOrCreator: BlockConfig, + blockConfigOrCreator: BlockConfig & { + children?: TChildren; + }, blockImplementationOrCreator: - | ReactCustomBlockImplementation> + | ReactCustomBlockImplementation< + BlockConfig & { children: TChildren } + > | (TOptions extends undefined ? () => ReactCustomBlockImplementation< - BlockConfig + BlockConfig & { children: TChildren } > : ( options: Partial, ) => ReactCustomBlockImplementation< - BlockConfig + BlockConfig & { children: TChildren } >), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] @@ -189,17 +246,11 @@ export function createReactBlockSpec< const TOptions extends Record | undefined = undefined, >( blockConfigOrCreator: BlockConfigOrCreator, + // The overloads above carry the precise types; this signature only has to + // admit all of them. blockImplementationOrCreator: - | ReactCustomBlockImplementation> - | (TOptions extends undefined - ? () => ReactCustomBlockImplementation< - BlockConfig - > - : ( - options: Partial, - ) => ReactCustomBlockImplementation< - BlockConfig - >), + | ReactCustomBlockImplementation + | ((options: Partial) => ReactCustomBlockImplementation), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] | (TOptions extends undefined @@ -232,6 +283,16 @@ export function createReactBlockSpec< toExternalHTML(block, editor, context) { const BlockContent = blockImplementation.toExternalHTML || blockImplementation.render; + if (isContainerConfig(blockConfig)) { + return renderContainerToDOM( + BlockContent as FC, + block, + editor, + blockConfig.propSchema, + undefined, + context, + ); + } const output = renderToDOMSpec((refCB) => { return ( ( + + {children} + + ); return ( - + { ref(element); if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); + // A container block's slot holds blocks, not inline + // content, so it isn't marked as the latter. + if (!isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } element.dataset.nodeViewContent = ""; } }} /> - + ); }, { @@ -317,6 +392,15 @@ export function createReactBlockSpec< )(this.props!) as ReturnType; } else { const BlockContent = blockImplementation.render; + if (isContainerConfig(blockConfig)) { + return renderContainerToDOM( + BlockContent as FC, + block, + editor, + blockConfig.propSchema, + block.id, + ); + } const output = renderToDOMSpec((refCB) => { return ( { + const dom = document.createElement("div"); + dom.className = "bn-block-column"; + dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT); + + return { dom, contentDOM: dom }; }, }, - [MultiColumnDropHandlerExtension()], -); + [ColumnResizeExtension(), MultiColumnDropHandlerExtension()], +)(); -export const ColumnListBlock = createBlockSpecFromTiptapNode( +export const ColumnListBlock = createBlockSpec( { - node: ColumnList, - type: "columnList", - content: "none", + type: "columnList" as const, + propSchema: {}, + content: "none" as const, + // A column list is made of columns, and needs at least two of them to be + // a layout at all - with one left it dissolves into that column's blocks. + children: { allow: ["column"] as const, min: 2 }, + }, + { + render: () => { + const dom = document.createElement("div"); + dom.className = "bn-block-column-list"; + dom.style.display = "flex"; + + return { dom, contentDOM: dom }; + }, }, - {}, -); +)(); diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts index 5713466a6d..2a6374c3e6 100644 --- a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts +++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts @@ -1,6 +1,5 @@ -import { BlockNoteEditor, getNodeById } from "@blocknote/core"; +import { BlockNoteEditor, createExtension, getNodeById } from "@blocknote/core"; import { SideMenuExtension } from "@blocknote/core/extensions"; -import { Extension } from "@tiptap/core"; import { Node } from "prosemirror-model"; import { Plugin, PluginKey, PluginView } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; @@ -438,12 +437,7 @@ const createColumnResizePlugin = (editor: BlockNoteEditor) => view: (view) => new ColumnResizePluginView(editor, view), }); -export const createColumnResizeExtension = ( - editor: BlockNoteEditor, -) => - Extension.create({ - name: "columnResize", - addProseMirrorPlugins() { - return [createColumnResizePlugin(editor)]; - }, - }); +export const ColumnResizeExtension = createExtension(({ editor }) => ({ + key: "columnResize", + prosemirrorPlugins: [createColumnResizePlugin(editor)], +})); diff --git a/packages/xl-multi-column/src/pm-nodes/Column.ts b/packages/xl-multi-column/src/pm-nodes/Column.ts deleted file mode 100644 index dccf60c74b..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/Column.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { suggestionMarks } from "@blocknote/core"; -import { Node } from "@tiptap/core"; - -import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js"; - -export const Column = Node.create({ - name: "column", - group: "bnBlock childContainer", - // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "blockContainer+", - priority: 40, - defining: true, - marks() { - return suggestionMarks(this.editor); - }, - addAttributes() { - return { - width: { - // Why does each column have a default width of 1, i.e. 100%? Because - // when creating a new column, we want to make sure that existing - // column widths are preserved, while the new one also has a sensible - // width. If we'd set it so all column widths must add up to 100% - // instead, then each time a new column is created, we'd have to assign - // it a width depending on the total number of columns and also adjust - // the widths of the other columns. The same can be said for using px - // instead of percent widths and making them add to the editor width. So - // using this method is both simpler and computationally cheaper. This - // is possible because we can set the `flex-grow` property to the width - // value, which handles all the resizing for us, instead of manually - // having to set the `width` property of each column. - default: 1, - parseHTML: (element) => { - const attr = element.getAttribute("data-width"); - if (attr === null) { - return null; - } - - const parsed = parseFloat(attr); - if (isFinite(parsed)) { - return parsed; - } - - return null; - }, - renderHTML: (attributes) => { - return { - "data-width": (attributes.width as number).toString(), - style: `flex-grow: ${attributes.width as number};`, - }; - }, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === this.name) { - return {}; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const column = document.createElement("div"); - column.className = "bn-block-column"; - column.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - column.setAttribute(attribute, value as any); // TODO as any - } - - return { - dom: column, - contentDOM: column, - }; - }, - - addExtensions() { - return [createColumnResizeExtension(this.options.editor)]; - }, -}); diff --git a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts deleted file mode 100644 index eeb06f4d4e..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { suggestionMarks } from "@blocknote/core"; -import { Node } from "@tiptap/core"; - -export const ColumnList = Node.create({ - name: "columnList", - group: "childContainer bnBlock blockGroupChild", - // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "column column+", // min two columns - priority: 40, // should be below blockContainer - defining: true, - marks() { - return suggestionMarks(this.editor); - }, - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === this.name) { - return {}; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const columnList = document.createElement("div"); - columnList.className = "bn-block-column-list"; - columnList.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - columnList.setAttribute(attribute, value as any); // TODO as any - } - columnList.style.display = "flex"; - - return { - dom: columnList, - contentDOM: columnList, - }; - }, -}); diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap similarity index 94% rename from packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap rename to packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap index 87b5f2e588..22e76d68b0 100644 --- a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test fixColumnList > First of two columns empty 1`] = ` +exports[`Test fixContainer, on a column list > First of two columns empty 1`] = ` { "content": [ { @@ -35,7 +35,7 @@ exports[`Test fixColumnList > First of two columns empty 1`] = ` } `; -exports[`Test fixColumnList > Last of two columns empty 1`] = ` +exports[`Test fixContainer, on a column list > Last of two columns empty 1`] = ` { "content": [ { @@ -70,7 +70,7 @@ exports[`Test fixColumnList > Last of two columns empty 1`] = ` } `; -exports[`Test fixColumnList > Two empty columns 1`] = ` +exports[`Test fixContainer, on a column list > Two empty columns 1`] = ` { "content": [ { @@ -99,7 +99,7 @@ exports[`Test fixColumnList > Two empty columns 1`] = ` } `; -exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` +exports[`Test removeEmptyChildren, on a column list > First of two columns empty 1`] = ` { "content": [ { @@ -176,7 +176,7 @@ exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` +exports[`Test removeEmptyChildren, on a column list > Last of two columns empty 1`] = ` { "content": [ { @@ -253,7 +253,7 @@ exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` +exports[`Test removeEmptyChildren, on a column list > Start and end columns empty 1`] = ` { "content": [ { @@ -336,7 +336,7 @@ exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Two empty columns 1`] = ` +exports[`Test removeEmptyChildren, on a column list > Two empty columns 1`] = ` { "content": [ { diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts similarity index 91% rename from packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts rename to packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts index b5bd190c6d..d93da3e62d 100644 --- a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts +++ b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from "vite-plus/test"; import { setupTestEnv } from "../../setupTestEnv.js"; import { - fixColumnList, - isEmptyColumn, - removeEmptyColumns, + fixContainer, + isEmptyContainerChild, + removeEmptyChildren, } from "@blocknote/core"; const getEditor = setupTestEnv(); -describe("Test isEmptyColumn", () => { +describe("Test isEmptyContainerChild, on a column", () => { it("Empty blocks", () => { const schema = getEditor()._tiptapEditor.schema; @@ -19,7 +19,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeTruthy(); + expect(isEmptyContainerChild(column)).toBeTruthy(); }); it("Multiple blocks", () => { @@ -34,7 +34,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Block with children", () => { @@ -51,7 +51,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Block with text", () => { @@ -65,7 +65,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Non-text block", () => { @@ -77,11 +77,11 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); }); -describe("Test removeEmptyColumns", () => { +describe("Test removeEmptyChildren, on a column list", () => { it("Start and end columns empty", () => { const editor = getEditor(); const schema = editor._tiptapEditor.schema; @@ -116,7 +116,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -143,7 +143,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -170,7 +170,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -195,13 +195,13 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); }); -describe("Test fixColumnList", () => { +describe("Test fixContainer, on a column list", () => { it("First of two columns empty", () => { const editor = getEditor(); const schema = editor._tiptapEditor.schema; @@ -224,7 +224,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -251,7 +251,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -276,7 +276,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html index 2237513b6b..7feb68b6f0 100644 --- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html +++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html @@ -1 +1 @@ -

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file +

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html index 5876b3bd03..700c06ecb9 100644 --- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html +++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html @@ -1 +1 @@ -

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file +

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 2d32e8c524..a56a91d638 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1566,6 +1566,25 @@ export const examples = { readme: 'In this example, we build custom blocks on the source-with-preview pattern — the same building blocks behind BlockNote\'s math and diagram blocks. A custom "CSV table" block renders its comma-separated source as a table, and a custom "color" inline content renders a CSS color as a swatch. Both show the rendered preview in place, while the source is edited in a popup.\n\n**Try it out:** Click the table or a color chip to edit its source!\n\n**Relevant Docs:**\n\n- [Source with Preview Blocks](/docs/features/custom-schemas/source-with-preview)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content)', }, + { + projectSlug: "callout-block", + fullSlug: "custom-schema/callout-block", + pathFromRoot: "examples/06-custom-schema/13-callout-block", + config: { + playground: true, + docs: true, + author: "yousefed", + tags: ["Intermediate", "Blocks", "Custom Schemas", "Nesting"], + dependencies: {} as any, + }, + title: "Callout Block with a Title and a Body", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + "A callout is one block with two editable regions: a **title**, which is the\nblock's own rich text, and a **body**, which is the blocks nested under it.\n\nBoth are ordinary BlockNote content, so everything already works on them:\nEnter splits the title, Tab indents inside the body, blocks can be dragged in\nand out, and the whole thing serializes and pastes like any other block.\n\nWhat makes them look like one box is `renderFrame`: the block returns the\nmarkup that frames it, plus the `slot` element that BlockNote renders the\ntitle and the body into.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)", + }, { projectSlug: "draggable-inline-content", fullSlug: "custom-schema/draggable-inline-content", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4aeb2f0e..f677b607b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3661,6 +3661,49 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/13-callout-block: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': diff --git a/tests/src/unit/react/reactContainer.test.tsx b/tests/src/unit/react/reactContainer.test.tsx new file mode 100644 index 0000000000..2a940efc6b --- /dev/null +++ b/tests/src/unit/react/reactContainer.test.tsx @@ -0,0 +1,109 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + defaultBlockSpecs, +} from "@blocknote/core"; +import { BlockNoteViewRaw, createReactBlockSpec } from "@blocknote/react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +/** + * A container block written in React: no content of its own, children only. + * The same thing `xl-multi-column` does in plain ProseMirror, but declared + * with `createReactBlockSpec`. + */ +const Box = createReactBlockSpec( + { + type: "box" as const, + propSchema: { tone: { default: "plain" } }, + content: "none" as const, + children: { allow: "any" as const }, + }, + { + render: (props) => ( +
+
+
+ ), + }, +); + +const schema = BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, box: Box() }, +}); + +const initialContent = [ + { + id: "b", + type: "box" as const, + props: { tone: "loud" as const }, + children: [ + { id: "c1", type: "paragraph" as const, content: "One" }, + { id: "c2", type: "paragraph" as const, content: "Two" }, + ], + }, +]; + +describe("a container block written in React", () => { + let editor: BlockNoteEditor; + let root: Root; + let div: HTMLDivElement; + + beforeEach(() => { + div = document.createElement("div"); + document.body.append(div); + editor = BlockNoteEditor.create({ + schema, + initialContent, + trailingBlock: false, + }); + root = createRoot(div); + flushSync(() => { + root.render(); + }); + }); + + afterEach(() => { + root.unmount(); + editor._tiptapEditor.destroy(); + div.remove(); + }); + + it("holds its children in the document", () => { + const box = editor.document[0]; + expect(box.type).toBe("box"); + expect(box.props).toMatchObject({ tone: "loud" }); + expect(box.children.map((c) => c.type)).toEqual(["paragraph", "paragraph"]); + }); + + it("renders the author's markup, with the children inside its slot", () => { + const rendered = div.querySelector(".rbox"); + expect(rendered).not.toBeNull(); + expect(rendered!.getAttribute("data-tone")).toBe("loud"); + // `@tiptap/react` puts its own element between the slot and the content, + // so the children are inside the author's slot rather than under it. + expect( + div.querySelectorAll(".rbox .rbox-body .bn-block-outer").length, + ).toBe(2); + }); + + it("carries the attributes its parse rules read, on one element", () => { + const nodes = div.querySelectorAll('[data-node-type="box"]'); + expect(nodes.length).toBe(1); + expect(nodes[0].getAttribute("data-tone")).toBe("loud"); + // What the side menu and drag handles resolve a block by. + expect(nodes[0].getAttribute("data-id")).toBe("b"); + }); + + it("round-trips through HTML", () => { + const html = editor.blocksToFullHTML(editor.document); + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed.map((b: any) => b.type)).toEqual(["box"]); + expect((parsed[0] as any).props.tone).toBe("loud"); + expect((parsed[0] as any).children.map((c: any) => c.type)).toEqual([ + "paragraph", + "paragraph", + ]); + }); +}); diff --git a/tests/src/unit/react/reactFrame.test.tsx b/tests/src/unit/react/reactFrame.test.tsx new file mode 100644 index 0000000000..9b15c1ea50 --- /dev/null +++ b/tests/src/unit/react/reactFrame.test.tsx @@ -0,0 +1,73 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + defaultBlockSpecs, +} from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; +import { describe, expect, it } from "vite-plus/test"; + +// A React callout: the title is a React component with rich text, the body is +// the block's nested children, and the frame is the box around both. +const Callout = createReactBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { default: "info", values: ["info", "warning"] as const }, + }, + content: "inline" as const, + }, + { + render: (props) =>
, + renderFrame: (block: any) => { + const dom = document.createElement("aside"); + dom.className = "callout"; + dom.setAttribute("data-flavor", block.props.flavor); + const slot = document.createElement("div"); + slot.className = "callout-inner"; + dom.append(slot); + return { + dom, + slot, + update: (b: any) => dom.setAttribute("data-flavor", b.props.flavor), + }; + }, + }, +); + +const schema = BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, callout: Callout() }, +}); + +describe("renderFrame with a React block spec", () => { + it("frames the React title and the children together", () => { + const editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { + id: "c", + type: "callout", + props: { flavor: "warning" }, + content: "Careful!", + children: [{ id: "b", type: "paragraph", content: "Body" }], + }, + ], + } as any); + const div = document.createElement("div"); + editor.mount(div); + + const block = div.querySelector('[data-id="c"]')!; + expect( + block.querySelector('.callout[data-flavor="warning"]'), + ).not.toBeNull(); + expect( + block.querySelector(".callout > .callout-inner > .bn-block-content"), + ).not.toBeNull(); + expect( + block.querySelector(".callout > .callout-inner > .bn-block-group"), + ).not.toBeNull(); + + expect(editor.getBlock("c")!.children.map((c: any) => c.id)).toEqual(["b"]); + + editor._tiptapEditor.destroy(); + }); +}); From bdfef7a627f2bd885a50aab29eac92b4a0e86f09 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 4 Sep 2026 14:43:11 +0200 Subject: [PATCH 2/2] feat(core): let a block decide from its props whether it is framed A toggle heading is a heading either way - it is only a toggle when `props.isToggleable` says so - so `renderFrame` may now decline by returning `undefined`. Whether a block is framed is decided when the frame is built, so a block whose type frames itself now rebuilds its node view when its props change, the way an unframed block already does. `frame.update` still handles content changes, which ProseMirror would not rebuild for. --- packages/core/src/pm-nodes/BlockContainer.ts | 23 +++++-- .../src/schema/blocks/renderFrame.test.ts | 67 ++++++++++++++++++- packages/core/src/schema/blocks/types.ts | 46 ++++++++----- 3 files changed, 111 insertions(+), 25 deletions(-) diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index b288f8407b..5ab516f0eb 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -134,15 +134,24 @@ export const BlockContainer = Node.create<{ return { dom, contentDOM: frame ? frame.slot : contentDOM, - // Whether a block is framed, and by what, follows from the type of its - // content node - which this node's own markup says nothing about. So a - // node view is rebuilt whenever that type changes, as well as when - // ProseMirror would have rebuilt it anyway (a change of markup). - // Otherwise the frame is told to update itself. + // Whether a block is framed, and by what, follows from the type and + // props of its content node - which this node's own markup says + // nothing about. So a node view is rebuilt whenever those change, as + // well as when ProseMirror would have rebuilt it anyway (a change of + // markup). A frame can be decided by props - a heading is only a + // toggle when it says so - and that decision is made when the frame is + // built, so a framed block rebuilds on a prop change the way an + // unframed one already does. Otherwise the frame updates itself. update: (node: PMNode) => { + const content = node.firstChild; + if (!node.sameMarkup(current) || content?.type.name !== contentType) { + return false; + } if ( - !node.sameMarkup(current) || - node.firstChild?.type.name !== contentType + renderFrame && + content && + current.firstChild && + !content.sameMarkup(current.firstChild) ) { return false; } diff --git a/packages/core/src/schema/blocks/renderFrame.test.ts b/packages/core/src/schema/blocks/renderFrame.test.ts index d613969f8c..2c8720e22f 100644 --- a/packages/core/src/schema/blocks/renderFrame.test.ts +++ b/packages/core/src/schema/blocks/renderFrame.test.ts @@ -41,8 +41,39 @@ const Callout = createBlockSpec( }, )(); +// A toggle heading: the same block type whether or not it is a toggle, so +// whether it draws a frame at all depends on its props. +const Togglable = createBlockSpec( + { + type: "togglable" as const, + propSchema: { isToggleable: { default: false } }, + content: "inline" as const, + }, + { + render: () => { + const dom = document.createElement("h2"); + return { dom, contentDOM: dom }; + }, + renderFrame: (block) => { + if (!block.props.isToggleable) { + return undefined; + } + const dom = document.createElement("div"); + dom.className = "toggle"; + const slot = document.createElement("div"); + slot.className = "toggle-inner"; + dom.append(slot); + return { dom, slot }; + }, + }, +)(); + const schema = BlockNoteSchema.create().extend({ - blockSpecs: { ...defaultBlockSpecs, callout: Callout } as const, + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + togglable: Togglable, + } as const, }); function editorWith(initialContent: any[]) { @@ -163,4 +194,38 @@ describe("renderFrame", () => { editor._tiptapEditor.destroy(); }); + + it("lets a block decide from its props whether it is framed at all", () => { + // A toggle heading is a heading either way: the frame appears and + // disappears with the prop, without the block changing type. + const editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { + id: "h", + type: "togglable", + content: "Heading", + children: [{ id: "c", type: "paragraph", content: "Under it" }], + }, + ], + } as any); + editor.mount(document.createElement("div")); + const dom = editor.domElement!; + + expect(dom.querySelector(".toggle")).toBeNull(); + + editor.updateBlock("h", { props: { isToggleable: true } } as any); + expect(dom.querySelector(".toggle")).not.toBeNull(); + expect( + dom.querySelectorAll( + ".toggle .toggle-inner .bn-block-group .bn-block-outer", + ).length, + ).toBe(1); + + editor.updateBlock("h", { props: { isToggleable: false } } as any); + expect(dom.querySelector(".toggle")).toBeNull(); + expect(dom.querySelectorAll(".bn-block-outer[data-id='c']").length).toBe(1); + + editor._tiptapEditor.destroy(); + }); }); diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 97a2221445..cc9c283acd 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -286,11 +286,13 @@ export type LooseBlockSpec< renderFrame?: ( block: any, editor: BlockNoteEditor, - ) => { - dom: HTMLElement; - slot: HTMLElement; - update?: (block: any) => void; - }; + ) => + | { + dom: HTMLElement; + slot: HTMLElement; + update?: (block: any) => void; + } + | undefined; node: Node; }; @@ -353,11 +355,13 @@ export type BlockSpecs = { renderFrame?: ( block: any, editor: BlockNoteEditor, - ) => { - dom: HTMLElement; - slot: HTMLElement; - update?: (block: any) => void; - }; + ) => + | { + dom: HTMLElement; + slot: HTMLElement; + update?: (block: any) => void; + } + | undefined; }; extensions?: BlockSpec["extensions"]; }; @@ -703,13 +707,21 @@ export type BlockImplementation< editor: BlockNoteEditor< Record> >, - ): { - dom: HTMLElement; - slot: HTMLElement; - update?: ( - block: BlockFromConfig, any, any>, - ) => void; - }; + ): + | { + dom: HTMLElement; + slot: HTMLElement; + update?: ( + block: BlockFromConfig< + BlockConfig, + any, + any + >, + ) => void; + } + // A block type decides from the block itself whether it frames it: a + // heading is only a toggle when its props say so. + | undefined; /** * Parses an external HTML element into a block of this type when it returns the block props object, otherwise undefined