diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx new file mode 100644 index 0000000000..02b2c44124 --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,119 @@ +--- +title: Container Blocks +description: Learn how to create custom blocks that hold other blocks as their body +--- + +# Container Blocks + +A *container block* is a custom block that holds other blocks as its body — like a Notion-style callout wrapping a paragraph and a code block, or a multi-column layout. BlockNote's built-in multi-column blocks (`columnList` / `column`) are implemented with this same mechanism. + +Take a look at the demo below, in which we add a custom callout block that can contain any other blocks: + + + +## Declaring a Container Block + +Add the `childBlocks` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The block must declare `content: "none"` — its body is made of child blocks, not inline content: + +```typescript +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + // The callout has no inline content of its own; it defers to its children. + content: "none", + // Marks the block as a container of other blocks. + childBlocks: { + // At least one child block is required. + min: 1, + // Seeded when the block is inserted without explicit children. + defaultChildren: [{ type: "paragraph" }], + }, + }, + { + render: (props) => ( + + {/* Child blocks are rendered into the element you attach contentRef to. */} +
+ + ), + }, +); +``` + +At runtime, the contained blocks live on `block.children` — the same field used for indented (nested) blocks: + +```json +{ + "id": "callout-1", + "type": "callout", + "props": { "flavor": "tip" }, + "content": undefined, + "children": [ + { + "id": "para-1", + "type": "paragraph", + "content": [{ "type": "text", "text": "Hello", "styles": {} }], + "children": [] + } + ] +} +``` + +### `ChildBlocksWrapper` (React) + +Container blocks own their entire outer DOM — BlockNote doesn't wrap them in the usual block element. Your `render` should return a `ChildBlocksWrapper` (exported from `@blocknote/react`) as the root element: it automatically applies the attributes BlockNote relies on for HTML parsing and UI positioning (`data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute). Any other props (`className`, event handlers) are passed through. + +For vanilla JS blocks (`createBlockSpec`), return a DOM element with `contentDOM` pointing to where children mount. BlockNote fills in the missing `data-*` attributes when serializing to HTML, but it's good practice to set `data-node-type` and `data-id` yourself so UI features (side menu positioning, drag & drop) work on the live editor DOM. + +## `childBlocks` options + +| Option | Default | Description | +| --- | --- | --- | +| `allowedBlocks` | any block | Block types allowed as direct children. Container types are enforced exactly by the schema; regular block types collapse to "any regular block" (they all share one node type internally). | +| `min` / `max` | `1` / unbounded | How many children are allowed. Enforced by the editor schema. | +| `defaultChildren` | — | Partial blocks seeded when the container is inserted without children. Validated against `allowedBlocks`/`min`/`max` when the schema is created. | +| `topLevel` | `true` | Whether the block can appear anywhere a regular block goes. Set `false` for blocks that only make sense inside a specific parent (like a `column` inside a `columnList`). | +| `collapseWhenEmptied` | `false` | Structural cleanup after children are removed: drops emptied children, and unwraps the container (replacing it with its remaining children, or removing it when none are left) once fewer than `min` non-empty children remain. Column lists set this to `true`. | + +Behavioral options live in the block implementation's `meta` instead, since they don't affect the document schema: + +| Meta option | Default | Description | +| --- | --- | --- | +| `exitOnEnter` | `true` | Pressing Enter on an empty last child moves it out of the container, list-style. Disable to keep the cursor inside (columns do this). | +| `draggable` | `true` | Whether the container itself gets a side menu drag handle. | + +### Restricting children: a columnList-style pair + +`allowedBlocks` + `topLevel: false` let you build tightly-coupled structures. This is exactly how the multi-column blocks are defined: + +```typescript +// The outer container: only accepts columns, at least two of them. +childBlocks: { + allowedBlocks: ["column"], + min: 2, + collapseWhenEmptied: true, +} + +// The column: holds any blocks, but can only live inside a columnList. +childBlocks: { topLevel: false } +``` + +The same pattern works for table-like structures (a "grid" of "cells"), FAQ lists, and so on. Configurations are validated when the schema is created — unknown `allowedBlocks` entries, impossible `defaultChildren`, and container cycles that could never be auto-filled all fail up front with a clear error. + +## Editable fields that aren't document content + +A container can only have one "hole" for child blocks and no inline content of its own. If your block needs an extra editable field — like the callout's title — store it as a **string prop** and render a regular `` inside the block (in a `contentEditable={false}` wrapper), committing the value with `editor.updateBlock`. See the demo above for a full implementation. + +This is the right tool when the field doesn't need rich text formatting, comments, or multiplayer cursors — it's plain data on the block, not document content. + +## Interop behavior + +- **HTML**: containers serialize to a `
` with their children nested inside and non-default props as `data-*` attributes, and parse back losslessly. +- **Markdown**: containers are flattened — their children are exported in order, and Markdown import never produces containers. +- **Exporters** (`@blocknote/xl-docx-exporter`, `xl-pdf-exporter`, `xl-odt-exporter`, `xl-email-exporter`): container blocks require an explicit block mapping that places their children; a missing mapping throws a clear error. diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx index 60bacafe68..a8ae04da4e 100644 --- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx @@ -68,6 +68,12 @@ type BlockConfig = { we set `content` to `"inline"`._ + + _Blocks with `content: "none"` can instead hold **other blocks** as their + body by declaring the `childBlocks` option — see [Container + Blocks](/docs/features/custom-schemas/container-blocks)._ + + `propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior. ```typescript diff --git a/examples/06-custom-schema/09-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json new file mode 100644 index 0000000000..3de7330631 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "nickthesick", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md new file mode 100644 index 0000000000..4a285c69b8 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/README.md @@ -0,0 +1,21 @@ +# Container Block + +In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks. + +The block uses the new `childBlocks` config on `BlockConfig`. Setting `childBlocks: { defaultChildren: [{ type: "paragraph" }] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested block children directly — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime. + +The callout also has an editable **title**, demonstrating the complementary "string prop slot" pattern: content that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. + +We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks. + +**Try it out:** + +- Press the "/" key inside the callout's body and add a code block, heading, or list — anything goes. +- Type a title into the title field — it's stored on `block.props.title`, not as document content. +- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`. +- Insert a new callout via the Slash Menu (search "callout"). + +**Relevant Docs:** + +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html new file mode 100644 index 0000000000..19321f77b5 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/index.html @@ -0,0 +1,14 @@ + + + + + Container Block + + + +
+ + + diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/09-container-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/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json new file mode 100644 index 0000000000..d92c915975 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp 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", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "^0.1.24" + } +} diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx new file mode 100644 index 0000000000..3f3255bbba --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/App.tsx @@ -0,0 +1,118 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + filterSuggestionItems, + insertOrUpdateBlockForSlashMenu, +} from "@blocknote/core/extensions"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { useEffect, useState } from "react"; +import { RiChatQuoteLine } from "react-icons/ri"; + +import { createCallout } from "./Callout"; +import "./styles.css"; + +// Schema with the default blocks plus our custom Callout container block. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: createCallout(), + }, +}); + +// Slash menu item to insert a Callout. Because Callout is a container block, +// inserting one with no children causes BlockNote to seed it with the block's +// configured `defaultChildren` (a single paragraph here). +const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Callout", + subtext: "Container block that wraps other blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "callout", + }), + aliases: ["callout", "container", "alert", "note", "tip", "info"], + group: "Basic blocks", + icon: , +}); + +type AppBlock = (typeof schema.BlockNoteEditor)["document"][number]; + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "Welcome — this demo shows the new container block kind.", + }, + { + type: "callout", + props: { flavor: "tip" }, + children: [ + { + type: "paragraph", + content: "Callouts can hold any block as their body.", + }, + { + type: "paragraph", + content: + "Try pressing '/' inside this callout to add a heading or code block.", + }, + ], + }, + { + type: "paragraph", + content: "Press '/' anywhere to insert a new Callout.", + }, + { + type: "paragraph", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ { + setBlocks(editor.document); + }} + > + { + const defaultItems = getDefaultReactSlashMenuItems(editor); + const lastBasicBlockIndex = defaultItems.findLastIndex( + (item) => item.group === "Basic blocks", + ); + defaultItems.splice( + lastBasicBlockIndex + 1, + 0, + insertCallout(editor), + ); + return filterSuggestionItems(defaultItems, query); + }} + /> + +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx new file mode 100644 index 0000000000..1c64a69af6 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -0,0 +1,102 @@ +import { ChildBlocksWrapper, createReactBlockSpec } from "@blocknote/react"; +import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md"; + +import "./styles.css"; + +// The flavors of callout the user can switch between. +export const calloutTypes = [ + { value: "tip", title: "Tip", icon: MdLightbulb }, + { value: "info", title: "Info", icon: MdInfo }, + { value: "warning", title: "Warning", icon: MdWarning }, + { value: "success", title: "Success", icon: MdCheckCircle }, +] as const; + +// The Callout block. Declared with `content: "none"` plus the new +// `childBlocks` config — the block hosts arbitrary child blocks in its body, +// exposed at runtime as `block.children`. +// +// The callout's title demonstrates the complementary "string prop slot" +// pattern: content that shouldn't be part of the rich-text document (no +// formatting, comments, or multiplayer cursors needed) can live in a plain +// string prop, edited through a regular rendered inside the block. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + title: { + default: "", + }, + }, + content: "none", + childBlocks: { + min: 1, + defaultChildren: [{ type: "paragraph" }], + }, + }, + { + render: (props) => { + const flavor = + calloutTypes.find((c) => c.value === props.block.props.flavor) ?? + calloutTypes[0]; + const Icon = flavor.icon; + + const cycleFlavor = () => { + const idx = calloutTypes.findIndex( + (c) => c.value === props.block.props.flavor, + ); + const next = calloutTypes[(idx + 1) % calloutTypes.length]; + props.editor.updateBlock(props.block, { + type: "callout", + props: { flavor: next.value }, + }); + }; + + const commitTitle = (title: string) => { + if (title !== props.block.props.title) { + props.editor.updateBlock(props.block, { + type: "callout", + props: { title }, + }); + } + }; + + return ( + + +
+ {/* The title lives in a string prop, not in document content — + it's edited via a plain input. `contentEditable={false}` keeps + ProseMirror from treating typing here as document input. */} +
+ commitTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> +
+
+
+ + ); + }, + }, +); diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css new file mode 100644 index 0000000000..19dce1d861 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/styles.css @@ -0,0 +1,118 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.item { + border-radius: 0.5rem; + flex: 1; + overflow: hidden; +} + +.item.bordered { + border: 1px solid gray; +} + +.item pre { + border-radius: 0.5rem; + height: 100%; + overflow: auto; + padding-block: 1rem; + padding-inline: 54px; + width: 100%; + white-space: pre-wrap; +} + +.callout { + display: flex; + align-items: flex-start; + gap: 12px; + flex-grow: 1; + border-radius: 6px; + padding: 12px 16px; + border-left: 4px solid var(--callout-accent, #888); + background-color: var(--callout-bg, #f3f4f6); +} + +.callout[data-flavor="tip"] { + --callout-accent: #d97706; + --callout-bg: #fff7ed; +} + +.callout[data-flavor="info"] { + --callout-accent: #507aff; + --callout-bg: #e6ebff; +} + +.callout[data-flavor="warning"] { + --callout-accent: #b91c1c; + --callout-bg: #fef2f2; +} + +.callout[data-flavor="success"] { + --callout-accent: #16a34a; + --callout-bg: #ecfdf5; +} + +[data-color-scheme="dark"] .callout[data-flavor="tip"] { + --callout-bg: #432e0e; +} + +[data-color-scheme="dark"] .callout[data-flavor="info"] { + --callout-bg: #1e2a5c; +} + +[data-color-scheme="dark"] .callout[data-flavor="warning"] { + --callout-bg: #4a1212; +} + +[data-color-scheme="dark"] .callout[data-flavor="success"] { + --callout-bg: #0d3b21; +} + +.callout-icon-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--callout-accent, #888); + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; +} + +.callout-icon-button:hover { + opacity: 0.75; +} + +.callout-main { + flex-grow: 1; + min-width: 0; +} + +.callout-title-wrapper { + margin-bottom: 4px; +} + +.callout-title-input { + width: 100%; + border: none; + background: none; + outline: none; + font-weight: 600; + font-size: 1rem; + color: inherit; + padding: 0; +} + +.callout-title-input::placeholder { + color: var(--callout-accent, #888); + opacity: 0.5; +} + +.callout-body { + flex-grow: 1; + min-width: 0; +} diff --git a/examples/06-custom-schema/09-container-block/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json new file mode 100644 index 0000000000..93fa81bee8 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__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 + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/09-container-block/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/09-container-block/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite.config.ts @@ -0,0 +1,31 @@ +// 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-plus"; +// 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")) + ? {} + : ({ + // 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/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json index 1893df45b9..ca1451aaa8 100644 --- a/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json +++ b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json @@ -5,6 +5,6 @@ "tags": ["Extension"], "pro": true, "dependencies": { - "@tiptap/core": "^3.13.0" + "@tiptap/core": "^3.29.2" } } diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index ce1a9455db..012f322ff7 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -153,7 +153,11 @@ const mergeBlocks = ( ); } - // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v. + // Merging into or out of container blocks (columnLists, callouts, ...) + // is intentionally unsupported — `canMerge` refuses it above. The + // container-boundary Backspace/Delete branches in + // `KeyboardShortcutsExtension` handle those cases by moving blocks + // across the boundary instead of merging their content. dispatch( state.tr.delete( prevBlockInfo.blockContent.afterPos - 1, diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 71598b7d69..3e92da9127 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -14,6 +14,10 @@ import { getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; +import { + flattenNonInsertableBlocks, + isContainerNode, +} from "../../containers/fixContainer.js"; import { insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; @@ -131,16 +135,6 @@ function updateBlockSelectionFromData( tr.setSelection(selection); } -// Replaces top-level `column` blocks with their children, as a `column` is not -// a valid block outside a `columnList`. Other blocks are returned as-is. -function flattenColumns( - blocks: Block[], -): Block[] { - return blocks.flatMap((block) => - block.type === "column" ? block.children : [block], - ); -} - /** * Removes the given blocks from the editor, then inserts them before/after a * reference block. @@ -169,10 +163,12 @@ export function moveBlocks( // // When the non-empty block is moved up, the column is seen as empty and // collapsed in the removal step, so the following insertion fails. - removeAndInsertBlocks(tr, blocks, [], { fixColumns: false }); + removeAndInsertBlocks(tr, blocks, [], { fixContainers: false }); insertBlocks( tr, - flattenColumns(blocks), + // Blocks that can't stand on their own outside their container (e.g. a + // `column` outside its `columnList`) are replaced by their children. + flattenNonInsertableBlocks(blocks, editor.pmSchema), referenceBlock, placement, ); @@ -207,12 +203,27 @@ export function moveSelectedBlocksAndSelection( }); } -// Checks if a block is in a valid place after being moved. This check is -// primitive at the moment and only returns false if the block's parent is a -// `columnList` block. This is because regular blocks cannot be direct children -// of `columnList` blocks. -function checkPlacementIsValid(parentBlock?: Block): boolean { - return !parentBlock || parentBlock.type !== "columnList"; +// Checks if a regular block is in a valid place after being moved, i.e. +// whether its would-be parent accepts a regular block as a direct child. +// Regular blocks nest under any non-container block (they go into its +// `blockGroup`), but a container block (e.g. a `columnList`) only accepts +// what its content expression allows. +function checkPlacementIsValid( + editor: BlockNoteEditor, + parentBlock?: Block, +): boolean { + if (!parentBlock) { + return true; + } + const parentNodeType = editor.pmSchema.nodes[parentBlock.type]; + if (!parentNodeType || !isContainerNode(parentNodeType)) { + return true; + } + return ( + parentNodeType.contentMatch.matchType( + editor.pmSchema.nodes["blockContainer"], + ) !== null + ); } // Gets the placement for moving a block up. This has 3 cases: @@ -254,7 +265,7 @@ function getMoveUpPlacement( } const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlockParent)) { return getMoveUpPlacement( editor, placement === "after" @@ -306,7 +317,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..a0a09d0099 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -19,9 +19,7 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -163,9 +161,7 @@ export function liftItem( const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -195,14 +191,36 @@ export function canNestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); - return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null; + // Mirrors `sinkItem`'s precondition: nesting is only possible under a + // previous sibling that is itself a `blockContainer`. (A previous sibling + // of another type — e.g. a container block — made this return true while + // `nestBlock` did nothing.) + return ( + tr.doc.resolve(blockContainer.beforePos).nodeBefore?.type === + editor.pmSchema.nodes["blockContainer"] + ); }); } export function canUnnestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); + const { $from, $to } = tr.selection; + + // Mirrors `liftItem`'s preconditions instead of approximating with + // depth — a block whose depth > 1 because it sits inside a container + // (e.g. a column) is not un-nestable, only a block nested under another + // `blockContainer` is. + const range = $from.blockRange( + $to, + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), + ); + if (!range) { + return false; + } - return tr.doc.resolve(blockContainer.beforePos).depth > 1; + return ( + $from.node(range.depth - 1).type === + editor.pmSchema.nodes["blockContainer"] + ); }); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d9e1e72981..84be8fa9fc 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -11,7 +11,8 @@ import type { import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getPmSchema } from "../../../pmUtil.js"; -import { fixColumnList } from "./util/fixColumnList.js"; +import { fixContainersById } from "../../containers/fixContainer.js"; +import { getAncestorContainers } from "../../containers/containerNav.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +23,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixContainers?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,7 +44,10 @@ export function removeAndInsertBlocks< ), ); const removedBlocks: Block[] = []; - const columnListPositions = new Set(); + // Ancestor containers of removed blocks, to repair afterwards. Tracked by + // node id (not position) since the removals — and earlier repairs — shift + // positions; recorded with their depth so repairs run deepest-first. + const containersToFix: { id: string; depth: number }[] = []; const idOfFirstBlock = typeof blocksToRemove[0] === "string" @@ -84,10 +88,10 @@ export function removeAndInsertBlocks< const $pos = tr.doc.resolve(pos - removedSize); - if ($pos.node().type.name === "column") { - columnListPositions.add($pos.before(-1)); - } else if ($pos.node().type.name === "columnList") { - columnListPositions.add($pos.before()); + for (const container of getAncestorContainers($pos.doc, $pos.pos)) { + if (!containersToFix.some((c) => c.id === container.id)) { + containersToFix.push(container); + } } if ( @@ -119,11 +123,12 @@ export function removeAndInsertBlocks< ); } - // Collapses empty columns/columnLists. Callers where the removal isn't a - // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere - // and deliberately leaves emptied columns as-is. - if (options.fixColumns !== false) { - columnListPositions.forEach((pos) => fixColumnList(tr, pos)); + // Repairs the containers the removed blocks lived in (e.g. collapses + // emptied columns/columnLists), deepest-first. Callers where the removal + // isn't a deletion can opt out - e.g. `moveBlocks` re-inserts the blocks + // elsewhere and deliberately leaves emptied containers as-is. + if (options.fixContainers !== false) { + fixContainersById(tr, containersToFix); } // Converts the nodes created from `blocksToInsert` into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/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/__snapshots__/containers.test.ts.snap b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap new file mode 100644 index 0000000000..29dc87ff63 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap @@ -0,0 +1,318 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`childBlocks keyboard handling > Backspace at the start of a block after a container moves it inside 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "In callout", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "After", + "type": "text", + }, + ], + "id": "after", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Backspace at the start of a container's first child moves it out 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Before", + "type": "text", + }, + ], + "id": "before", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "First", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Second", + "type": "text", + }, + ], + "id": "c-p-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Delete at the end of a block before a container pulls its first child out 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Before", + "type": "text", + }, + ], + "id": "before", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "First", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Second", + "type": "text", + }, + ], + "id": "c-p-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Delete at the end of a container's last child pulls the next block in 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "In callout", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "After", + "type": "text", + }, + ], + "id": "after", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Enter on an empty last child escapes the container 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Hello", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, + { + "children": [], + "content": [], + "id": "c-p-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "trailing", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`childBlocks repair > unwraps a repair-configured container when only one non-empty child remains 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "cell-b-p", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "trailing", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts new file mode 100644 index 0000000000..4513bcfe23 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -0,0 +1,134 @@ +import type { Node, NodeType } from "prosemirror-model"; + +import { isContainerNode } from "./fixContainer.js"; + +/** + * Position-based helpers for navigating container blocks. These generalize + * what the keyboard handlers used to hard-code for the exact + * `columnList > column > blockContainer` shape: they recurse through + * arbitrarily nested containers and consult the schema's content matches + * instead of assuming two levels. + */ + +/** + * Finds the deepest position inside `container` where a node of `nodeType` + * can be appended at the end, descending through trailing nested containers + * (e.g. into the last `column` of a `columnList`, which itself doesn't accept + * `blockContainer` children). Returns null if no level accepts the node. + * @param container The container node. + * @param containerBeforePos The position just before `container`. + * @param nodeType The node type to find an insertion position for. + */ +export function descendToLastInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + const endPos = containerBeforePos + 1 + container.content.size; + if (container.contentMatchAt(container.childCount).matchType(nodeType)) { + return endPos; + } + const lastChild = container.lastChild; + if (lastChild && isContainerNode(lastChild.type)) { + return descendToLastInsertionPos( + lastChild, + endPos - lastChild.nodeSize, + nodeType, + ); + } + return null; +} + +/** + * Mirror of `descendToLastInsertionPos`: the deepest position inside + * `container` where a node of `nodeType` can be prepended at the start. + */ +export function descendToFirstInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + const startPos = containerBeforePos + 1; + if (container.contentMatchAt(0).matchType(nodeType)) { + return startPos; + } + const firstChild = container.firstChild; + if (firstChild && isContainerNode(firstChild.type)) { + return descendToFirstInsertionPos(firstChild, startPos, nodeType); + } + return null; +} + +/** + * Descends through leading nested containers to the first non-container child + * (e.g. the first `blockContainer` inside the first `column` of a + * `columnList`). Returns null for a container whose leading chain has no + * such child. + * @param container The container node. + * @param containerBeforePos The position just before `container`. + */ +export function getFirstLeafBlock( + container: Node, + containerBeforePos: number, +): { node: Node; beforePos: number } | null { + const firstChild = container.firstChild; + if (!firstChild) { + return null; + } + const firstChildBeforePos = containerBeforePos + 1; + if (isContainerNode(firstChild.type)) { + return getFirstLeafBlock(firstChild, firstChildBeforePos); + } + return { node: firstChild, beforePos: firstChildBeforePos }; +} + +/** + * Climbs upward from a position until one is found where a node of + * `nodeType` may be inserted, moving to just before each enclosing container + * in turn (e.g. from before a first `column` — where only `column` nodes are + * allowed — to before the enclosing `columnList`). Returns null when an + * enclosing non-container parent still doesn't accept the node. + * @param doc The document to resolve positions in. + * @param pos The position to start climbing from. + * @param nodeType The node type to find an insertion position for. + */ +export function ascendToInsertablePos( + doc: Node, + pos: number, + nodeType: NodeType, +): number | null { + for (;;) { + const $pos = doc.resolve(pos); + const parent = $pos.node(); + if (parent.contentMatchAt($pos.index()).matchType(nodeType)) { + return pos; + } + if (isContainerNode(parent.type) && $pos.depth > 0) { + pos = $pos.before(); + continue; + } + return null; + } +} + +/** + * Collects the chain of container-node ancestors at a position (deepest + * first) as `{ id, depth }` entries. Containers are re-located by id when + * repairing, since repairs shift positions. + * @param doc The document to resolve the position in. + * @param pos A position inside the containers of interest. + */ +export function getAncestorContainers( + doc: Node, + pos: number, +): { id: string; depth: number }[] { + const $pos = doc.resolve(pos); + const containers: { id: string; depth: number }[] = []; + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if (isContainerNode(ancestor.type) && ancestor.attrs.id) { + containers.push({ id: ancestor.attrs.id, depth }); + } + } + return containers; +} diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts new file mode 100644 index 0000000000..713be21404 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts @@ -0,0 +1,46 @@ +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { isContainerType } from "../../../schema/blocks/childBlocks.js"; + +export type ContainerUIInfo = { + containerTypes: ReadonlySet; + draggableContainerTypes: ReadonlySet; + containerSelector: string | null; +}; + +function buildSelector(types: ReadonlySet): string | null { + if (types.size === 0) { + return null; + } + return [...types].map((type) => `[data-node-type="${type}"]`).join(","); +} + +export function getContainerUIInfo( + editor: Pick, "schema">, +): ContainerUIInfo { + const containerTypes = new Set(); + const draggableContainerTypes = new Set(); + + for (const [type, spec] of Object.entries( + editor.schema.blockSpecs as Record< + string, + { + config: any; + implementation?: { meta?: { draggable?: boolean } }; + } + >, + )) { + if (!isContainerType(spec.config)) { + continue; + } + containerTypes.add(type); + if (spec.implementation?.meta?.draggable !== false) { + draggableContainerTypes.add(type); + } + } + + return { + containerTypes, + draggableContainerTypes, + containerSelector: buildSelector(containerTypes), + }; +} diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts new file mode 100644 index 0000000000..fd2ebdc3ca --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,532 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +// A vanilla (non-React) container block accepting any children, with +// defaultChildren seeding — the callout from the container-block example. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + childBlocks: { + min: 1, + defaultChildren: [{ type: "paragraph" }], + }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.className = "callout"; + dom.setAttribute("data-node-type", "callout"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +// A container that never exits on Enter (like columns). +const LockedBox = createBlockSpec( + { + type: "lockedBox" as const, + propSchema: {}, + content: "none", + childBlocks: { min: 1 }, + }, + { + meta: { + exitOnEnter: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "lockedBox"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +// A columnList-like pair: a horizontal container restricted to `gridCell` +// children (min 2) with column-style repair, and a non-top-level cell. +// Proves the "custom table-like structure" story is buildable. +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + childBlocks: { + allowedBlocks: ["gridCell"], + min: 2, + collapseWhenEmptied: true, + }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "grid"); + dom.setAttribute("data-id", block.id); + dom.style.display = "flex"; + return { dom, contentDOM: dom }; + }, + }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + childBlocks: { topLevel: false }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "gridCell"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + lockedBox: LockedBox, + grid: Grid, + gridCell: GridCell, + } as const, +}); + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +const div = document.createElement("div"); + +beforeAll(() => { + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +function pressKey(key: string, keyCode: number) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key, + code: key, + keyCode, + bubbles: true, + }); + view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + +describe("childBlocks insertion & seeding", () => { + it("seeds defaultChildren when inserted without children", () => { + editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"); + + const callout = editor.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + }); + + it("seeds defaultChildren when converting a block via updateBlock", () => { + editor.updateBlock("p-1", { type: "callout" }); + + const callout = editor.document[1]; + expect(callout.type).toBe("callout"); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + }); + + it("accepts arbitrary block children, including nested containers", () => { + editor.insertBlocks( + [ + { + type: "callout", + id: "c-0", + children: [ + { type: "heading", content: "In callout" }, + { + type: "callout", + id: "c-1", + children: [{ type: "paragraph", content: "Nested" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.type)).toEqual([ + "heading", + "callout", + ]); + expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph"); + }); + + it("rejects non-allowed children for a restricted container", () => { + expect(() => + editor.insertBlocks( + [ + { + type: "grid", + children: [ + { type: "paragraph", content: "not a cell" }, + { type: "paragraph", content: "not a cell" }, + ], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); + + it("accepts allowed children for a restricted container", () => { + editor.insertBlocks( + [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + children: [{ type: "paragraph", content: "Cell A" }], + }, + { + type: "gridCell", + children: [{ type: "paragraph", content: "Cell B" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const grid = editor.getBlock("g-0")!; + expect(grid.children.map((child) => child.type)).toEqual([ + "gridCell", + "gridCell", + ]); + }); + + it("rejects inserting a topLevel: false container at the document root", () => { + expect(() => + editor.insertBlocks( + [{ type: "gridCell", children: [{ type: "paragraph" }] }], + "p-1", + "after", + ), + ).toThrow(); + }); +}); + +describe("childBlocks keyboard handling", () => { + it("Enter on an empty last child escapes the container", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "Hello" }, + { id: "c-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + editor.setTextCursorPosition("c-p-1", "end"); + + pressKey("Enter", 13); + + expect(editor.document).toMatchSnapshot(); + // The empty block has moved out of the callout. + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]); + expect(editor.document.map((block) => block.type)).toEqual([ + "callout", + "paragraph", + "paragraph", + ]); + }); + + it("Enter does not escape a container with meta.exitOnEnter: false", () => { + editor.replaceBlocks(editor.document, [ + { + type: "lockedBox", + id: "l-0", + children: [ + { id: "l-p-0", type: "paragraph", content: "Hello" }, + { id: "l-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + editor.setTextCursorPosition("l-p-1", "end"); + + pressKey("Enter", 13); + + // Still exactly one top-level lockedBox followed by the trailing + // paragraph; the new block was created inside the container. + expect(editor.document.map((block) => block.type)).toEqual([ + "lockedBox", + "paragraph", + ]); + expect(editor.getBlock("l-0")!.children.length).toBeGreaterThanOrEqual(2); + }); + + it("Backspace at the start of a container's first child moves it out", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setTextCursorPosition("c-p-0", "start"); + + pressKey("Backspace", 8); + + expect(editor.document).toMatchSnapshot(); + // The first child has moved out, above the callout. + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + expect(editor.document.map((block) => block.id)[1]).toBe("c-p-0"); + }); + + it("Backspace at the start of a block after a container moves it inside", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + editor.setTextCursorPosition("after", "start"); + + pressKey("Backspace", 8); + + expect(editor.document).toMatchSnapshot(); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + }); + + it("Delete at the end of a block before a container pulls its first child out", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setTextCursorPosition("before", "end"); + + pressKey("Delete", 46); + + expect(editor.document).toMatchSnapshot(); + expect(editor.document.map((block) => block.id).slice(0, 2)).toEqual([ + "before", + "c-p-0", + ]); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + }); + + it("Delete at the end of a container's last child pulls the next block in", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + editor.setTextCursorPosition("c-p-0", "end"); + + pressKey("Delete", 46); + + expect(editor.document).toMatchSnapshot(); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + }); +}); + +describe("childBlocks repair", () => { + it("keeps a default container when its only child is removed (refilled)", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["c-p-0"]); + + const callout = editor.getBlock("c-0")!; + expect(callout).toBeDefined(); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].content).toEqual([]); + }); + + it("unwraps a repair-configured container when only one non-empty child remains", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "cell-a", + children: [{ id: "cell-a-p", type: "paragraph", content: "A" }], + }, + { + type: "gridCell", + id: "cell-b", + children: [{ id: "cell-b-p", type: "paragraph", content: "B" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["cell-a-p"]); + + expect(editor.document).toMatchSnapshot(); + // The grid has been unwrapped: cell B's content replaced it. + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "cell-b-p", + "trailing", + ]); + }); +}); + +describe("childBlocks selection & conversion", () => { + it("getSelectionCutBlocks handles selections reaching into a container", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "c-p-0"); + + // Previously threw "unexpected" for any partial selection touching a + // container (breaking comments/AI selection handling). + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.length).toBeGreaterThanOrEqual(1); + expect(result.blocks.map((block) => block.id)).toContain("before"); + }); + + it("round-trips a container through full (internal) HTML", async () => { + const blocks = [ + { + type: "callout" as const, + id: "c-0", + props: { flavor: "warning" as const }, + children: [ + { id: "c-p-0", type: "paragraph" as const, content: "In callout" }, + ], + }, + ]; + editor.replaceBlocks(editor.document, blocks); + + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain('data-node-type="callout"'); + + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed[0].type).toBe("callout"); + expect((parsed[0].props as any).flavor).toBe("warning"); + expect(parsed[0].children).toHaveLength(1); + expect(parsed[0].children[0].type).toBe("paragraph"); + }); + + it("exports containers to external HTML with type + prop attributes", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + props: { flavor: "warning" }, + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + ]); + + const html = editor.blocksToHTMLLossy(editor.document); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain('data-flavor="warning"'); + // Container output is not wrapped in a blockContent div. + expect(html).not.toContain("bn-block-content"); + }); + + it("flattens containers to their children in markdown export", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "In callout" }, + { id: "c-p-1", type: "heading", content: "Heading in callout" }, + ], + }, + ]); + + const markdown = editor.blocksToMarkdownLossy(editor.document); + expect(markdown).toContain("In callout"); + expect(markdown).toContain("# Heading in callout"); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts new file mode 100644 index 0000000000..2a1e273de9 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,194 @@ +import { Fragment, Slice, type Node, type NodeType } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; +import type { Schema } from "prosemirror-model"; + +import { + getChildBlocksConfig, + getMinChildren, +} from "../../../schema/blocks/childBlocks.js"; +import { getNodeById } from "../../nodeUtil.js"; +import { getBlockSchema, getPmSchema } from "../../pmUtil.js"; + +export function isContainerNode(type: NodeType): boolean { + return type.isInGroup("childContainer") && type.name !== "blockGroup"; +} + +/** + * A container child is "empty" when it contributes no meaningful content: + * a blockContainer with just an empty paragraph, or a nested container + * whose only child is itself empty (recursively). + * + * Multi-child nodes are never empty — collapsing user-built structure would + * be destructive. + */ +export function isEmptyContainerChild(node: Node): boolean { + if (node.type.name === "blockContainer") { + const blockContent = node.firstChild; + return ( + node.childCount === 1 && + !!blockContent && + blockContent.type.name === "paragraph" && + blockContent.childCount === 0 + ); + } + if (isContainerNode(node.type)) { + return node.childCount === 1 && isEmptyContainerChild(node.firstChild!); + } + return false; +} + +export function removeEmptyChildren(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + for ( + let childIndex = container.childCount - 1; + childIndex >= 0; + childIndex-- + ) { + const childPos = tr.doc.resolve(containerPos + 1).posAtIndex(childIndex); + const child = tr.doc.resolve(childPos).nodeAfter; + if (!child) { + throw new Error("Invalid childPos: does not point to a child node."); + } + + if (isEmptyContainerChild(child)) { + tr.delete(childPos, childPos + child.nodeSize); + } + } +} + +function isInsertableChild(node: Node): boolean { + return ( + node.type.name === "blockContainer" || + node.type.isInGroup("blockGroupChild") + ); +} + +/** + * Repairs a container after children were removed, per `collapseWhenEmptied`: + * drops empty children, then unwraps the container if fewer than `min` + * non-empty children remain. + */ +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.", + ); + } + + const blockConfig = getBlockSchema(getPmSchema(tr))[container.type.name]; + const config = blockConfig ? getChildBlocksConfig(blockConfig) : undefined; + + if (!config?.collapseWhenEmptied) { + return; + } + + removeEmptyChildren(tr, containerPos); + + const refreshed = tr.doc.resolve(containerPos).nodeAfter; + if (!refreshed || refreshed.type !== container.type) { + return; + } + + const min = getMinChildren(config); + + const nonEmptyChildren: { child: Node; offset: number }[] = []; + refreshed.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + nonEmptyChildren.push({ child, offset }); + } + }); + + if (nonEmptyChildren.length >= min) { + return; + } + + if (nonEmptyChildren.length === 0) { + tr.delete(containerPos, containerPos + refreshed.nodeSize); + return; + } + + // Unwrap: replace the container with its remaining non-empty children. + if (nonEmptyChildren.length === 1) { + const { child, offset } = nonEmptyChildren[0]; + const childStart = containerPos + 1 + offset; + + const [gapFrom, gapTo] = isInsertableChild(child) + ? [childStart, childStart + child.nodeSize] + : [childStart + 1, childStart + child.nodeSize - 1]; + + tr.step( + new ReplaceAroundStep( + containerPos, + containerPos + refreshed.nodeSize, + gapFrom, + gapTo, + Slice.empty, + 0, + false, + ), + ); + return; + } + + // Several survivors but still below `min`: rebuild replacement content. + const replacement: Node[] = []; + for (const { child } of nonEmptyChildren) { + if (isInsertableChild(child)) { + replacement.push(child); + } else { + child.forEach((grandChild) => replacement.push(grandChild)); + } + } + tr.replaceWith( + containerPos, + containerPos + refreshed.nodeSize, + Fragment.from(replacement), + ); +} + +/** + * Runs `fixContainer` on containers by id, deepest first. + * Re-locates each container before repair since positions shift. + */ +export function fixContainersById( + tr: Transaction, + containers: { id: string; depth: number }[], +) { + [...containers] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (!target) { + return; + } + fixContainer(tr, target.posBeforeNode); + }); +} + +/** + * Flattens blocks that can't be inserted at the top level (e.g. `column`) + * into their children, recursively. + */ +export function flattenNonInsertableBlocks< + T extends { type?: string; children?: T[] }, +>(blocks: T[], pmSchema: Schema): T[] { + return blocks.flatMap((block) => { + const nodeType = block.type ? pmSchema.nodes[block.type] : undefined; + if ( + nodeType && + nodeType.isInGroup("bnBlock") && + !nodeType.isInGroup("blockGroupChild") + ) { + return flattenNonInsertableBlocks(block.children ?? [], pmSchema); + } + return [block]; + }); +} diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index e2274140f7..d7f0bdbf2c 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -5,10 +5,12 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { BlockImplementation, BlockSchema, + fillContainerAttributes, InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; +import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js"; import { inlineContentToNodes, tableContentToNodes, @@ -270,6 +272,20 @@ function serializeBlock< } elementFragment.append(...Array.from(ret.dom.childNodes)); } else { + const blockNodeType = editor.pmSchema.nodes[block.type as any]; + if (blockNodeType && isContainerNode(blockNodeType)) { + // Container blocks own their outer DOM. Make sure the attributes + // needed to parse the HTML back (the type marker and non-default + // props, in the same `data-*` convention `propsToAttributes` reads) + // are present even when the block's render didn't add them. + // Author-set attributes win. + fillContainerAttributes( + ret.dom as HTMLElement, + block.type!, + props, + editor.schema.blockSchema[block.type as any].propSchema, + ); + } elementFragment.append(ret.dom); if (nestingLevel > 0) { (ret.dom as HTMLElement).setAttribute( diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..ba98b8206d 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -4,10 +4,12 @@ import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { BlockSchema, + fillContainerAttributes, InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; +import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js"; import { inlineContentToNodes, tableContentToNodes, @@ -172,7 +174,18 @@ function serializeBlock< const pmType = editor.pmSchema.nodes[block.type as any]; - if (pmType.isInGroup("bnBlock")) { + if (isContainerNode(pmType)) { + // Container blocks own their outer DOM. Internal HTML must round-trip + // losslessly, so make sure the attributes the generated parse rules read + // (the type marker and non-default props as `data-*`) are present even + // when the block's render didn't add them. Author-set attributes win. + fillContainerAttributes( + ret.dom as HTMLElement, + block.type!, + props, + editor.schema.blockSchema[block.type as any].propSchema, + ); + if (block.children && block.children.length > 0) { const fragment = serializeBlocks( editor, diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 61bc44d68a..9ea73a1594 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -16,10 +16,19 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +import { + getChildBlocksConfig, + getMinChildren, +} from "../../schema/blocks/childBlocks.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; import { getColspan, isPartialTableCell } from "../../util/table.js"; import { UnreachableCaseError } from "../../util/typescript.js"; import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js"; -import { getStyleSchema, isPlainContentNodeType } from "../pmUtil.js"; +import { + getBlockSchema, + getStyleSchema, + isPlainContentNodeType, +} from "../pmUtil.js"; /** * Convert a StyledText inline element to a @@ -330,6 +339,57 @@ function blockOrInlineContentToContentNode( return contentNode; } +const EMPTY_SEEDING: ReadonlySet = new Set(); + +/** + * Produces child nodes from a container's `defaultChildren` config, guarding + * against self-referential cycles. Returns `undefined` when seeding is not + * needed (no defaults, `min: 0`, or the caller already supplied children). + */ +function seedDefaultChildren( + blockType: string, + schema: Schema, + styleSchema: StyleSchema, + seedingTypes: ReadonlySet, +): Node[] | undefined { + const blockSchemaConfig = getBlockSchema(schema)[blockType]; + const childBlocksConfig = blockSchemaConfig + ? getChildBlocksConfig(blockSchemaConfig) + : undefined; + + if (!childBlocksConfig) { + return undefined; + } + + const defaultChildren = childBlocksConfig.defaultChildren; + if (!defaultChildren || defaultChildren.length === 0) { + return undefined; + } + + // A `min: 0` container is allowed to be empty — don't re-seed it on + // round-trip (`nodeToBlock` emits `children: []` for childless containers). + if (getMinChildren(childBlocksConfig) === 0) { + return undefined; + } + + if (seedingTypes.has(blockType)) { + throw new Error( + `Seeding "${blockType}" ends up seeding it again (${[...seedingTypes, blockType].join(" -> ")}). ` + + "Give the cyclic default explicit children, or remove the self-reference.", + ); + } + + const nextSeeding = new Set(seedingTypes).add(blockType); + return defaultChildren.map((child) => + blockToNode( + child as PartialBlock, + schema, + styleSchema, + nextSeeding, + ), + ); +} + /** * Converts a BlockNote block to a Prosemirror node. */ @@ -337,6 +397,7 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + seedingTypes: ReadonlySet = EMPTY_SEEDING, ) { let id = block.id; @@ -348,7 +409,7 @@ export function blockToNode( if (block.children) { for (const child of block.children) { - children.push(blockToNode(child, schema, styleSchema)); + children.push(blockToNode(child, schema, styleSchema, seedingTypes)); } } @@ -357,8 +418,6 @@ export function blockToNode( schema.nodes[block.type].isInGroup("blockContent"); if (isBlockContent) { - // Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer - const contentNode = blockOrInlineContentToContentNode( block, schema, @@ -377,7 +436,12 @@ export function blockToNode( }, groupNode ? [contentNode, groupNode] : contentNode, ); - } else if (schema.nodes[block.type].isInGroup("bnBlock")) { + } else if (isContainerNode(schema.nodes[block.type])) { + const effectiveChildren = + children.length === 0 + ? seedDefaultChildren(block.type, schema, styleSchema, seedingTypes) + : undefined; + // `create` (not `createChecked`) so partial container blocks pass through; // callers that mutate the doc validate via `node.check()` before inserting. return schema.nodes[block.type].create( @@ -385,7 +449,7 @@ export function blockToNode( id: id, ...block.props, }, - children, + effectiveChildren ?? children, ); } else { throw new Error( diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..40085fe417 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,12 +1,41 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + getChildBlocksConfig, + getMinChildren, + isTopLevelContainer, +} from "../../schema/blocks/childBlocks.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; +import { getBlockSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; +/** + * Whether a container node is "self-contained" — it has enough children to + * stand on its own and is allowed at the top level. Containers that aren't + * (e.g. a single selected column of a columnList) are flattened into their + * children. + */ +function isSelfContainedContainer(node: Node): boolean { + if (!isContainerNode(node.type)) { + return false; + } + const childBlocks = getChildBlocksConfig( + getBlockSchema(node.type.schema)[node.type.name] ?? {}, + ); + if (!childBlocks) { + return false; + } + return ( + isTopLevelContainer(childBlocks) && + node.childCount >= getMinChildren(childBlocks) + ); +} + /** * Converts all Blocks within a fragment to BlockNote blocks. */ @@ -15,46 +44,28 @@ export function fragmentToBlocks< I extends InlineContentSchema, S extends StyleSchema, >(fragment: Fragment) { - // first convert selection to blocknote-style blocks, and then - // pass these to the exporter const blocks: BlockNoDefaults[] = []; + + const pushFlattened = (node: Node, root: Node) => { + if (isContainerNode(node.type) && !isSelfContainedContainer(node)) { + node.forEach((child) => pushFlattened(child, root)); + return; + } + blocks.push(nodeToBlock(node, root)); + }; + fragment.descendants((node) => { if (node.type.name === "blockContainer") { if (node.firstChild?.type.name === "blockGroup") { - // selection started within a block group - // in this case the fragment starts with: - // - // - // - // - // - // - // - // instead of: - // - // - // - // - // - // - // - // - // so we don't need to serialize this block, just descend into the children of the blockGroup + // Selection started within a block group — the fragment wraps the + // children in a blockContainer > blockGroup without a blockContent, + // so we descend into the blockGroup's children instead. return true; } } - if (node.type.name === "columnList" && node.childCount === 1) { - // column lists with a single column should be flattened (not the entire column list has been selected) - node.firstChild?.forEach((child) => { - blocks.push(nodeToBlock(child, node)); - }); - return false; - } - if (node.type.isInGroup("bnBlock")) { - blocks.push(nodeToBlock(node, node)); - // don't descend into children, as they're already included in the block returned by nodeToBlock + pushFlattened(node, node); return false; } return true; diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 6d3b7e23b2..67878115ce 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,5 +1,6 @@ import { Mark, Node, Slice } from "@tiptap/pm/model"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { BlockSchema, @@ -560,7 +561,9 @@ export function prosemirrorSliceToSlicedBlocks< blockCutAtStart: string | undefined; blockCutAtEnd: string | undefined; } { - if (node.type.name !== "blockGroup") { + // Both `blockGroup` and container nodes (columnList, column, callout, + // ...) hold bnBlock children directly, so both can be processed here. + if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) { throw new Error("unexpected"); } const blocks: Block[] = []; @@ -568,6 +571,44 @@ export function prosemirrorSliceToSlicedBlocks< let blockCutAtEnd: string | undefined; node.forEach((blockContainer, _offset, index) => { + const isFirstBlock = index === 0; + const isLastBlock = index === node.childCount - 1; + + if (isContainerNode(blockContainer.type)) { + // A container child. When the slice boundary is open inside it, the + // selection covers part of its children — skip the container wrapper + // and splice in the included children (mirroring the + // nested-blockGroup descent below). When fully enclosed, convert it + // wholesale. + const openAtStart = isFirstBlock && openStart > 0; + const openAtEnd = isLastBlock && openEnd > 0; + + if (openAtStart || openAtEnd) { + const ret = processNode( + blockContainer, + openAtStart ? Math.max(0, openStart - 1) : 0, + openAtEnd ? Math.max(0, openEnd - 1) : 0, + ); + if (openAtStart) { + blockCutAtStart = ret.blockCutAtStart; + } + if (openAtEnd) { + blockCutAtEnd = ret.blockCutAtEnd; + } + blocks.push(...ret.blocks); + return; + } + + blocks.push( + nodeToBlock(blockContainer, slice.content.firstChild!) as Block< + BSchema, + I, + S + >, + ); + return; + } + if (blockContainer.type.name !== "blockContainer") { throw new Error("unexpected"); } @@ -580,9 +621,6 @@ export function prosemirrorSliceToSlicedBlocks< ); } - const isFirstBlock = index === 0; - const isLastBlock = index === node.childCount - 1; - if (blockContainer.firstChild!.type.name === "blockGroup") { // this is the parent where a selection starts within one of its children, // e.g.: diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2592b25d2a..d50627c455 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -36,6 +36,7 @@ import { UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; +import { isContainerType } from "../../../schema/blocks/childBlocks.js"; import type { BlockNoteEditor, BlockNoteEditorOptions, @@ -59,7 +60,16 @@ export function getDefaultTiptapExtensions( UniqueID.configure({ // everything from bnBlock group (nodes that represent a BlockNote block should have an id) - types: ["blockContainer", "columnList", "column"], + types: [ + "blockContainer", + // Container block specs whose PM node is itself in the `bnBlock` group + // (column, columnList, callout, etc.) — i.e. the bnBlock node IS the + // block, so the id lives on its attrs rather than on a wrapping + // blockContainer. + ...Object.entries(editor.schema.blockSpecs) + .filter(([, spec]) => isContainerType((spec as any).config)) + .map(([type]) => type), + ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, }), diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index f42e89e6f4..2f58d32080 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -9,6 +9,7 @@ import { StyledText, Styles, } from "../schema/index.js"; +import { isContainerType } from "../schema/blocks/childBlocks.js"; import type { BlockMapping, @@ -44,15 +45,35 @@ export abstract class Exporter< RS, TS, > { + // Stored with erased generics: a generically-typed property would change + // the class's variance in B/I/S and break mapping inference at subclass + // construction sites (the schema param was previously inference-only). + private readonly blockNoteSchema: BlockNoteSchema; + public constructor( - _schema: BlockNoteSchema, // only used for type inference + schema: BlockNoteSchema, protected readonly mappings: { blockMapping: BlockMapping; inlineContentMapping: InlineContentMapping; styleMapping: StyleMapping; }, public readonly options: ExporterOptions, - ) {} + ) { + this.blockNoteSchema = schema; + } + + /** + * Whether a block type is a container block (declares `childBlocks`, e.g. + * `columnList`, `column`, or a custom callout). Container mappings own the + * placement of their children — exporters must not append the children + * after the container's own output. + */ + public isContainerBlock(blockType: string): boolean { + const spec = (this.blockNoteSchema.blockSpecs as Record)[ + blockType + ]; + return !!spec && isContainerType(spec.config); + } public async resolveFile(url: string) { if (!this.options?.resolveFileUrl) { @@ -92,12 +113,17 @@ export abstract class Exporter< numberedListIndex: number, children?: Array>, ) { - return this.mappings.blockMapping[block.type]( - block, - this, - nestingLevel, - numberedListIndex, - children, - ); + const mapping = this.mappings.blockMapping[block.type]; + if (!mapping) { + // Without this, a missing mapping surfaces as an opaque "is not a + // function" TypeError. Container blocks are called out explicitly: they + // have no sensible generic representation, so a mapping is required. + throw new Error( + this.isContainerBlock(block.type) + ? `No mapping found for container block type "${block.type}" — container blocks require an explicit block mapping that places their children.` + : `No mapping found for block type "${block.type}".`, + ); + } + return mapping(block, this, nestingLevel, numberedListIndex, children); } } diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index d53a8444cc..c6673dd7df 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -20,8 +20,16 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + ContainerUIInfo, + getContainerUIInfo, +} from "../../api/blockManipulation/containers/containerUI.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; import { dragStart, unsetDragImage } from "./dragging.js"; +import { + getContainerChildAtCursor, + hasHorizontalContainerAncestor, +} from "./sideMenuContainerGeometry.js"; export type SideMenuState< BSchema extends BlockSchema, @@ -37,7 +45,8 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250; function getBlockFromCoords( view: EditorView, coords: { left: number; top: number }, - adjustForColumns = true, + containerUIInfo: ContainerUIInfo, + adjustForHorizontalContainers = true, ) { const elements = view.root.elementsFromPoint(coords.left, coords.top); @@ -46,21 +55,32 @@ function getBlockFromCoords( // probably a ui overlay like formatting toolbar etc continue; } - if (adjustForColumns) { - const column = element.closest("[data-node-type=columnList]"); - if (column) { - return getBlockFromCoords( - view, - { - // TODO can we do better than this? - left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself - top: coords.top, - }, - false, - ); - } + if ( + adjustForHorizontalContainers && + containerUIInfo.containerSelector && + // Inside a container with side-by-side children (e.g. a columnList), + // the x position must be offset — the hovered coordinates land in the + // side menu's own gutter, which belongs to a different child. The + // horizontal container can be any ancestor (the element may sit inside + // a vertical child of it, like a block inside a column). + hasHorizontalContainerAncestor(element, containerUIInfo) + ) { + return getBlockFromCoords( + view, + { + // TODO can we do better than this? + left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself + top: coords.top, + }, + containerUIInfo, + false, + ); } - return getDraggableBlockFromElement(element, view); + return getDraggableBlockFromElement( + element, + view, + containerUIInfo.draggableContainerTypes, + ); } return undefined; } @@ -71,6 +91,7 @@ function getBlockFromMousePos( y: number; }, view: EditorView, + containerUIInfo: ContainerUIInfo, ): { node: HTMLElement; id: string } | undefined { // Editor itself may have padding or other styling which affects // size/position, so we get the boundingRect of the first child (i.e. the @@ -94,7 +115,7 @@ function getBlockFromMousePos( top: mousePos.y, }; - const referenceBlock = getBlockFromCoords(view, coords); + const referenceBlock = getBlockFromCoords(view, coords, containerUIInfo); if (!referenceBlock) { // could not find the reference block @@ -109,15 +130,26 @@ function getBlockFromMousePos( * ``` * Hovering at position x (left edge of BlockB) would return BlockA. * Instead, we check at position y (right edge of BlockA) to correctly identify BlockB. + * `elementsFromPoint` returns the deepest element at a point, so this single + * probe descends through any depth of regular nesting. + * + * When the reference block is a (draggable) container block, the probe is + * aimed at the direct child under the cursor instead of the container + * itself — the container's own padding can exceed the probe inset, which + * would keep resolving the container even though the cursor is aligned with + * one of its children (making the child's menu jump away as the cursor + * moves towards it). */ - const referenceBlocksBoundingBox = - referenceBlock.node.getBoundingClientRect(); + const probeTarget = + getContainerChildAtCursor(referenceBlock.node, mousePos, containerUIInfo) ?? + referenceBlock.node; return getBlockFromCoords( view, { - left: referenceBlocksBoundingBox.right - 10, + left: probeTarget.getBoundingClientRect().right - 10, top: mousePos.y, }, + containerUIInfo, false, ); } @@ -214,7 +246,11 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const block = getBlockFromMousePos( + this.mousePos, + this.pmView, + getContainerUIInfo(this.editor), + ); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -240,7 +276,15 @@ export class SideMenuView< // Shows or updates elements. if (this.editor.isEditable) { const blockContentBoundingBox = block.node.getBoundingClientRect(); - const column = block.node.closest("[data-node-type=column]"); + // The closest container ancestor (a column, callout, ...) — excluding + // the hovered block itself, which may be a draggable container. Blocks + // inside a container anchor the side menu to the container's block + // area rather than the editor's left edge, which would put the menu + // over unrelated content (or off-screen inside columns). + const containerUIInfo = getContainerUIInfo(this.editor); + const container = containerUIInfo.containerSelector + ? block.node.parentElement?.closest(containerUIInfo.containerSelector) + : undefined; const sideMenuBlock = this.editor.getBlock( this.hoveredBlock!.getAttribute("data-id")!, ); @@ -255,12 +299,16 @@ export class SideMenuView< this.state = { show: true, referencePos: new DOMRect( - column - ? // We take the first child as column elements have some default - // padding. This is a little weird since this child element will - // be the first block, but since it's always non-nested and we - // only take the x coordinate, it's ok. - column.firstElementChild!.getBoundingClientRect().x + container + ? // We anchor to the container's first block element (rather + // than the container itself, which may have padding or its own + // chrome around the block area). This is a little weird since + // this element is the first block, but since it's always + // non-nested and we only take the x coordinate, it's ok. + ( + container.querySelector('[data-node-type="blockOuter"]') ?? + container.firstElementChild! + ).getBoundingClientRect().x : ( this.pmView.dom.firstChild as HTMLElement ).getBoundingClientRect().x, diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts new file mode 100644 index 0000000000..d5da72e4b2 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; +import { + getContainerChildAtCursor, + getDirectChildBlocks, + hasHorizontalContainerAncestor, + isHorizontalContainer, +} from "./sideMenuContainerGeometry.js"; + +// jsdom does no layout, so `getBoundingClientRect` returns all-zero rects. +// These helpers are pure geometry over the DOM, so we build detached trees and +// stub each element's rect to simulate a laid-out editor. + +type Rect = { top: number; bottom: number; left: number; right: number }; + +function setRect(el: Element, rect: Rect) { + const full = { + ...rect, + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }; + (el as HTMLElement).getBoundingClientRect = () => + ({ ...full, toJSON: () => full }) as DOMRect; +} + +/** Creates a `[data-node-type]` element with an optional stubbed rect. */ +function el(nodeType: string, rect?: Rect): HTMLElement { + const node = document.createElement("div"); + node.setAttribute("data-node-type", nodeType); + if (rect) { + setRect(node, rect); + } + return node; +} + +/** Wraps a block content node in the `blockOuter > blockContainer` chrome that + * BlockNote renders around every regular block, returning the outer wrapper. */ +function regularChild(rect?: Rect): { + outer: HTMLElement; + blockContainer: HTMLElement; +} { + const outer = el("blockOuter"); + const blockContainer = el("blockContainer", rect); + outer.append(blockContainer); + return { outer, blockContainer }; +} + +function uiInfo(containerTypes: string[]): ContainerUIInfo { + const set = new Set(containerTypes); + return { + containerTypes: set, + draggableContainerTypes: set, + containerSelector: containerTypes.length + ? containerTypes.map((t) => `[data-node-type="${t}"]`).join(",") + : null, + }; +} + +/** + * Builds a columnList with two columns laid out side-by-side, each holding a + * single regular block. Returns the pieces so tests can probe them. + */ +function buildColumnList() { + const info = uiInfo(["columnList", "column"]); + + const columnList = el("columnList", { + top: 0, + bottom: 100, + left: 0, + right: 200, + }); + const columnA = el("column", { top: 0, bottom: 100, left: 0, right: 100 }); + const columnB = el("column", { top: 0, bottom: 100, left: 100, right: 200 }); + + const childA = regularChild({ top: 0, bottom: 40, left: 0, right: 100 }); + const childB = regularChild({ top: 0, bottom: 40, left: 100, right: 200 }); + columnA.append(childA.outer); + columnB.append(childB.outer); + columnList.append(columnA, columnB); + + return { info, columnList, columnA, columnB, childA, childB }; +} + +/** + * Builds a vertical container (callout-like) holding two stacked regular + * blocks. + */ +function buildVerticalContainer() { + const info = uiInfo(["callout"]); + + const callout = el("callout", { top: 0, bottom: 80, left: 0, right: 200 }); + const first = regularChild({ top: 0, bottom: 40, left: 0, right: 200 }); + const second = regularChild({ top: 40, bottom: 80, left: 0, right: 200 }); + callout.append(first.outer, second.outer); + + return { info, callout, first, second }; +} + +describe("getDirectChildBlocks", () => { + it("returns direct child blocks, skipping nested grandchildren", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + + const children = getDirectChildBlocks(columnList, info); + + expect(children).toEqual([columnA, columnB]); + }); + + it("sees through blockOuter wrappers to the blockContainer child", () => { + const { info, columnA, childA } = buildColumnList(); + + // The column's own direct child is the wrapped blockContainer, not the + // blockOuter chrome. + expect(getDirectChildBlocks(columnA, info)).toEqual([ + childA.blockContainer, + ]); + }); + + it("returns nothing for a container with no block children", () => { + const info = uiInfo(["callout"]); + const empty = el("callout"); + + expect(getDirectChildBlocks(empty, info)).toEqual([]); + }); +}); + +describe("isHorizontalContainer", () => { + it("is true when direct children overlap vertically (side-by-side)", () => { + const { info, columnList } = buildColumnList(); + + expect(isHorizontalContainer(columnList, info)).toBe(true); + }); + + it("is false when direct children are stacked", () => { + const { info, callout } = buildVerticalContainer(); + + expect(isHorizontalContainer(callout, info)).toBe(false); + }); + + it("is false for a single child", () => { + const { info, columnA } = buildColumnList(); + + expect(isHorizontalContainer(columnA, info)).toBe(false); + }); + + it("treats abutting (non-overlapping) children as vertical", () => { + // Second child's top exactly meets the first child's bottom — a stack + // with no gap must not be misread as horizontal. + const info = uiInfo(["callout"]); + const callout = el("callout"); + const a = regularChild({ top: 0, bottom: 40, left: 0, right: 200 }); + const b = regularChild({ top: 40, bottom: 80, left: 0, right: 200 }); + callout.append(a.outer, b.outer); + + expect(isHorizontalContainer(callout, info)).toBe(false); + }); +}); + +describe("hasHorizontalContainerAncestor", () => { + it("is true for a block nested inside a column of a columnList", () => { + const { info, childA } = buildColumnList(); + + // The block sits inside a (vertical) column, whose parent columnList is + // the horizontal one — the walk must climb past the column. + expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe( + true, + ); + }); + + it("is false for a block inside a purely vertical container", () => { + const { info, first } = buildVerticalContainer(); + + expect(hasHorizontalContainerAncestor(first.blockContainer, info)).toBe( + false, + ); + }); + + it("is false when there is no container ancestor", () => { + const info = uiInfo(["columnList", "column"]); + const loose = regularChild(); + + expect(hasHorizontalContainerAncestor(loose.blockContainer, info)).toBe( + false, + ); + }); + + it("is false when the schema declares no containers", () => { + const { childA } = buildColumnList(); + const info = uiInfo([]); + + expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe( + false, + ); + }); +}); + +describe("getContainerChildAtCursor", () => { + it("returns undefined for a non-container element", () => { + const { info, childA } = buildColumnList(); + + expect( + getContainerChildAtCursor(childA.blockContainer, { x: 10, y: 10 }, info), + ).toBeUndefined(); + }); + + it("returns the child whose x range contains the cursor (horizontal)", () => { + const { info, columnList, columnB } = buildColumnList(); + + // x=150 lands in the second column's horizontal range. + expect(getContainerChildAtCursor(columnList, { x: 150, y: 50 }, info)).toBe( + columnB, + ); + }); + + it("prefers the x match over the first vertical match", () => { + const { info, columnList, columnA } = buildColumnList(); + + // x=10 is within column A; both columns share the y range. + expect(getContainerChildAtCursor(columnList, { x: 10, y: 50 }, info)).toBe( + columnA, + ); + }); + + it("falls back to the first vertical match when x is in the gutter", () => { + const { info, callout, first } = buildVerticalContainer(); + + // A vertical container: the cursor y is in the first child's band but x + // is left of it (the side-menu gutter). The first vertical match wins. + expect(getContainerChildAtCursor(callout, { x: -20, y: 20 }, info)).toBe( + first.blockContainer, + ); + }); + + it("returns undefined when the cursor is below all children", () => { + const { info, callout } = buildVerticalContainer(); + + expect( + getContainerChildAtCursor(callout, { x: 10, y: 999 }, info), + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts new file mode 100644 index 0000000000..8ff8a69278 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts @@ -0,0 +1,121 @@ +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; + +/** + * DOM hit-testing helpers the side menu uses to resolve which block a cursor + * is over inside container blocks. They read live layout geometry + * (`getBoundingClientRect`) rather than any declared layout flag, so container + * blocks don't have to describe their own layout — a container whose children + * happen to sit side-by-side (like a column list) is detected as such. + */ + +/** + * The selector matching any block that can be a direct child of a container: + * a regular block (`blockContainer`) or a nested container element. + */ +function containerChildSelector(containerUIInfo: ContainerUIInfo): string { + return containerUIInfo.containerSelector + ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` + : `[data-node-type="blockContainer"]`; +} + +/** + * The direct child block elements of a container element (in the block + * sense): the closest block element above each match must be the container + * itself. Matches `blockContainer`s (regular children) and nested container + * elements. + */ +export function getDirectChildBlocks( + container: Element, + containerUIInfo: ContainerUIInfo, +): Element[] { + const childSelector = containerChildSelector(containerUIInfo); + + const children: Element[] = []; + for (const child of container.querySelectorAll(childSelector)) { + if (child.parentElement?.closest(childSelector) === container) { + children.push(child); + } + } + return children; +} + +/** + * Whether a container element lays its direct child blocks out side-by-side + * (like a column list), detected from geometry rather than a declared flag: + * two direct children that overlap along the vertical axis can only be + * sitting next to each other horizontally. + */ +export function isHorizontalContainer( + container: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + const rects = getDirectChildBlocks(container, containerUIInfo).map((child) => + child.getBoundingClientRect(), + ); + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + if (rects[i].top < rects[j].bottom && rects[j].top < rects[i].bottom) { + return true; + } + } + } + return false; +} + +/** + * Whether `element` sits inside a horizontal container at any depth (e.g. a + * block inside a column of a columnList — the columnList, not the column, is + * the horizontal one). Walks up the container ancestor chain. + */ +export function hasHorizontalContainerAncestor( + element: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + if (!containerUIInfo.containerSelector) { + return false; + } + let container = element.closest(containerUIInfo.containerSelector); + while (container) { + if (isHorizontalContainer(container, containerUIInfo)) { + return true; + } + container = + container.parentElement?.closest(containerUIInfo.containerSelector) ?? + null; + } + return false; +} + +/** + * If `element` is a container block's element, finds its direct child block + * whose vertical range contains the cursor. Hovering a container's own + * chrome (padding, a title bar, the side-menu gutter) next to a child + * should attach the side menu to that child — mirroring how hovering a + * parent block's gutter next to a nested block attaches to the nested + * block. The container's own menu stays reachable on rows occupied only by + * its chrome. When children sit side-by-side, a child containing the + * cursor's x position wins over the first vertical match. + */ +export function getContainerChildAtCursor( + element: Element, + mousePos: { x: number; y: number }, + containerUIInfo: ContainerUIInfo, +): Element | undefined { + const nodeType = element.getAttribute("data-node-type"); + if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) { + return undefined; + } + + let verticalMatch: Element | undefined = undefined; + for (const child of getDirectChildBlocks(element, containerUIInfo)) { + const rect = child.getBoundingClientRect(); + if (mousePos.y < rect.top || mousePos.y > rect.bottom) { + continue; + } + if (mousePos.x >= rect.left && mousePos.x <= rect.right) { + return child; + } + verticalMatch = verticalMatch ?? child; + } + return verticalMatch; +} diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts index abc6bd2906..4b4223ca82 100644 --- a/packages/core/src/extensions/getDraggableBlockFromElement.ts +++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts @@ -1,18 +1,37 @@ import { EditorView } from "prosemirror-view"; +const EMPTY_SET: ReadonlySet = new Set(); + +/** + * Walks up from `element` to the closest element that can host a side-menu + * drag handle: a regular block (`blockContainer`) or a container block whose + * type is in `draggableContainerTypes` (derived from each spec's + * `meta.draggable`). + */ export function getDraggableBlockFromElement( element: Element, view: EditorView, + draggableContainerTypes: ReadonlySet = EMPTY_SET, ) { + const isDraggable = (el: Element) => { + const nodeType = el.getAttribute?.("data-node-type"); + return ( + nodeType === "blockContainer" || + (nodeType !== null && + nodeType !== undefined && + draggableContainerTypes.has(nodeType)) + ); + }; + while ( element && element.parentElement && element.parentElement !== view.dom && - element.getAttribute?.("data-node-type") !== "blockContainer" + !isDraggable(element) ) { element = element.parentElement; } - if (element.getAttribute?.("data-node-type") !== "blockContainer") { + if (!isDraggable(element)) { return undefined; } return { node: element as HTMLElement, id: element.getAttribute("data-id")! }; diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index b3a0b62550..d29d3aa939 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -14,7 +14,16 @@ import { nestBlock, unnestBlock, } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; -import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +import { + fixContainersById, + isContainerNode, +} from "../../../api/blockManipulation/containers/fixContainer.js"; +import { + ascendToInsertablePos, + descendToLastInsertionPos, + getAncestorContainers, + getFirstLeafBlock, +} from "../../../api/blockManipulation/containers/containerNav.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { @@ -127,8 +136,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the previous block is a columnList, moves the current block to - // the end of the last column in it. + // If the previous block is a container (e.g. a columnList or a + // callout), moves the current block to its deepest trailing insertion + // slot — descending through nested containers, e.g. to the end of the + // last column. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -150,17 +161,23 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - if (dispatch) { - const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1; - const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1); + const insertionPos = descendToLastInsertionPos( + prevBlockInfo.bnBlock.node, + prevBlockInfo.bnBlock.beforePos, + state.schema.nodes["blockContainer"], + ); + if (insertionPos === null) { + return false; + } + if (dispatch) { tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node); + tr.insert(insertionPos, blockInfo.bnBlock.node); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)), + TextSelection.near(tr.doc.resolve(insertionPos + 1)), ); return true; @@ -168,9 +185,11 @@ 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 in a container (e.g. a column or a + // callout), moves it out: to the end of the previous sibling + // container if there is one (e.g. the previous column), otherwise to + // just before the closest enclosing boundary that accepts it (e.g. + // above the columnList / callout). () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -192,32 +211,55 @@ 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 blockContainerType = state.schema.nodes["blockContainer"]; + const containerBeforePos = $pos.before(); + const $containerPos = tr.doc.resolve(containerBeforePos); + + // A previous sibling inside an enclosing container (e.g. the + // previous column) is a target to descend into. A sibling at a + // regular block position is not — there the block moves out to + // before the container instead. + const prevSibling = + isContainerNode($containerPos.node().type) && + $containerPos.nodeBefore && + isContainerNode($containerPos.nodeBefore.type) + ? $containerPos.nodeBefore + : null; + + const insertionPos = prevSibling + ? descendToLastInsertionPos( + prevSibling, + containerBeforePos - prevSibling.nodeSize, + blockContainerType, + ) + : ascendToInsertablePos( + tr.doc, + containerBeforePos, + blockContainerType, + ); + if (insertionPos === null) { + return false; + } if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - fixColumnList(tr, columnListPos); - - if ($columnPos.pos === columnListPos + 1) { - tr.insert(columnListPos, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve(columnListPos)), - ); - } else { - tr.insert($columnPos.pos - 1, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve($columnPos.pos)), - ); - } + tr.insert(insertionPos, blockInfo.bnBlock.node); + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); } return true; @@ -468,8 +510,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the next block is a columnList, moves the first block from its - // first column to after the current block. + // If the next block is a container (e.g. a columnList or a callout), + // moves its first leaf block out, to after the current block. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -491,18 +533,28 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } + const firstLeaf = getFirstLeafBlock( + nextBlockInfo.bnBlock.node, + nextBlockInfo.bnBlock.beforePos, + ); + if (!firstLeaf) { + return false; + } + if (dispatch) { - const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1; - const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); + const containersToFix = getAncestorContainers( + tr.doc, + firstLeaf.beforePos, + ); tr.delete( - $blockBeforePos.pos, - $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, + firstLeaf.beforePos, + firstLeaf.beforePos + firstLeaf.node.nodeSize, ); - fixColumnList(tr, nextBlockInfo.bnBlock.beforePos); - tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!); + tr.insert(blockInfo.bnBlock.afterPos, firstLeaf.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), + TextSelection.near(tr.doc.resolve(firstLeaf.beforePos)), ); return true; @@ -510,9 +562,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is the last in a column, moves it to the start of the - // next column. If there is no next column, moves it below the - // columnList. + // If the block is the last in a container (e.g. a column or a + // callout), moves the next block — the first leaf of the next sibling + // container, or the block following the enclosing containers — to + // after it. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -534,36 +587,49 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // Climbs out of the containers the block is the last child of, + // to the first position with a following node. + let $boundary = $pos; + while ( + $boundary.nodeAfter === null && + $boundary.depth > 0 && + isContainerNode($boundary.node().type) + ) { + $boundary = tr.doc.resolve($boundary.after()); + } + + const nextNode = $boundary.nodeAfter; + if (!nextNode) { return false; } - const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos); - const $columnEndPos = tr.doc.resolve($blockEndPos.after()); - const columnListEndPos = $columnEndPos.after(); + // The block to pull in: the next node itself or — when it's a + // container — its first leaf block. + const target = isContainerNode(nextNode.type) + ? getFirstLeafBlock(nextNode, $boundary.pos) + : { node: nextNode, beforePos: $boundary.pos }; + if (!target) { + return false; + } if (dispatch) { - // Position before first block in next column, or first block - // after columnList if there is no next column. - const nextBlockBeforePos = - $columnEndPos.pos === columnListEndPos - 1 - ? columnListEndPos - : $columnEndPos.pos + 1; - const nextBlockInfo = getBlockInfoFromResolvedPos( - tr.doc.resolve(nextBlockBeforePos), + const containersToFix = getAncestorContainers( + tr.doc, + target.beforePos, ); tr.delete( - nextBlockInfo.bnBlock.beforePos, - nextBlockInfo.bnBlock.afterPos, - ); - fixColumnList( - tr, - columnListEndPos - $columnEndPos.node().nodeSize, + target.beforePos, + target.beforePos + target.node.nodeSize, ); - tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node); + tr.insert(blockInfo.bnBlock.afterPos, target.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), + TextSelection.near(tr.doc.resolve(target.beforePos)), ); } @@ -839,6 +905,68 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the block is empty and the last child of a container with + // `exitOnEnter` behavior (the default for containers), moves the + // block out to after the container — one level per press, list-style. + // Without this, Enter only ever creates new blocks *within* the + // container, so a trailing container could trap the cursor. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isBlockContainer) { + return false; + } + + const selectionEmpty = + state.selection.anchor === state.selection.head; + const blockEmpty = blockInfo.blockContent.node.childCount === 0; + if (!selectionEmpty || !blockEmpty) { + return false; + } + + const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const parentBlock = $pos.node(); + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // Only fires on the container's last child. + if (tr.doc.resolve(blockInfo.bnBlock.afterPos).nodeAfter !== null) { + return false; + } + + const exitOnEnter = + this.options.editor.schema.blockSpecs[parentBlock.type.name] + ?.implementation?.meta?.exitOnEnter ?? true; + if (!exitOnEnter) { + return false; + } + + const containerAfterPos = $pos.after(); + + if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + + tr.delete( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + ); + // The container's after-position, mapped through the deletion + // (and any schema-driven refill it triggered). + const insertionPos = tr.mapping.map(containerAfterPos); + tr.insert(insertionPos, blockInfo.bnBlock.node); + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); + tr.scrollIntoView(); + } + + return true; + }), // Creates a new block and moves the selection to it if the current one is empty, while the selection is also // empty & at the start of the block. () => diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 981ba21e48..bd7edb94f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,8 @@ 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 "./api/blockManipulation/containers/containerNav.js"; +export * from "./api/blockManipulation/containers/containerUI.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/README.md b/packages/core/src/pm-nodes/README.md index be57ead212..d0f7d408f7 100644 --- a/packages/core/src/pm-nodes/README.md +++ b/packages/core/src/pm-nodes/README.md @@ -16,7 +16,7 @@ In the BlockNote API, recall that blocks look like this: } ``` -`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a `column` or `columnList`. +`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a container block (a block declaring `childBlocks`, such as a `column`, `columnList`, or a custom callout). `content` is the block's Inline Content. Inline content doesn't have any `id`, it's "loose" content within the node. @@ -61,41 +61,62 @@ group: "blockContent", Blocks that are part of the `blockContent` group define the appearance / behaviour of the main element of the block (i.e.: headings, paragraphs, list items, etc.). These are only used for "regular" blocks that are represented as `blockContainer` nodes. -## Multi-column +## Container blocks -The `multi-column` package makes it possible to order blocks side by side in -columns. It adds the `columnList` and `column` nodes to the schema. +A block config can declare `childBlocks`, marking the block as a _container +block_: a block that holds other blocks directly as its body. Container +blocks emit their own ProseMirror node (built by `createSpec`'s +`buildContainerNode`) with this shape: + +```typescript +name: blockConfig.type, +group: "bnBlock childContainer blockGroupChild", // blockGroupChild is dropped for `topLevel: false` +// From `childBlocks.allowedBlocks`/`min`/`max`; defaults to `blockGroupChild+`. +// Allowed regular blocks collapse to one `blockContainer` term (ordered first, +// so ProseMirror auto-fill picks it); allowed container types appear verbatim. +content: "blockGroupChild{min,max}", +priority: 40, // below blockContainer (50), so `blockGroupChild` auto-fill never recurses into containers +``` + +Unlike regular blocks, a container block's PM node **is** the `bnBlock` — there +is no `blockContainer` wrapper and no `blockGroup` around the children; child +blocks sit directly inside the container node. The children are exposed as +`block.children` in the BlockNote API. + +The `xl-multi-column` package's blocks are the canonical containers: ### ColumnList ```typescript +// childBlocks: { allowedBlocks: ["column"], min: 2, collapseWhenEmptied: true } 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 +group: "bnBlock childContainer blockGroupChild", +content: "column{2,}", // min two columns ``` -The column list contains 2 or more columns. +The column list contains 2 or more columns. Its `collapseWhenEmptied` config +makes `fixContainer` drop emptied columns and unwrap the list when fewer than +two non-empty columns remain. ### Column ```typescript +// childBlocks: { topLevel: false } name: "column", -group: "bnBlock childContainer", -// A block always contains content, and optionally a blockGroup which contains nested blocks -content: "blockContainer+", +group: "bnBlock childContainer", // not blockGroupChild: only valid inside a columnList +content: "blockGroupChild+", ``` -The column contains 1 or more block containers. +The column contains 1 or more blocks. # Groups We use Prosemirror "groups" to help organize this schema. Here is a list of the different groups: - `blockContent`: described above (contain the content for blocks that are represented as `BlockContainer` nodes) -- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and `columnList` -- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`, but for columns, both `columnList` and `column` are considered to be `childContainer` nodes. -- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. Both `blockContainer`, `column` and `columnList` are part of this group. +- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and top-level container blocks (e.g. `columnList`) +- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`; every container block node (`columnList`, `column`, custom containers) is also a `childContainer`. +- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. `blockContainer` and every container block node are part of this group. _Note that the last two groups, `bnBlock` and `childContainer`, are not used anywhere in the schema. They are however helpful while programming. For example, we can check whether a node is a `bnBlock`, and then we know it corresponds to a BlockNote Block. Or, we can check whether a node is a `childContainer`, and then we know it's a container of a BlockNote Block's `children`. See `getBlockInfoFromPos` for an example of how this is used._ diff --git a/packages/core/src/schema/blocks/childBlocks.ts b/packages/core/src/schema/blocks/childBlocks.ts new file mode 100644 index 0000000000..4f3b9a82e3 --- /dev/null +++ b/packages/core/src/schema/blocks/childBlocks.ts @@ -0,0 +1,95 @@ +import type { ChildBlocksConfig } from "./types.js"; + +export const CHILD_CONTAINER_GROUP = "childContainer"; + +export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; + +// Below `blockContainer`'s priority (50): PM's `fillBefore` picks the first +// matching type in a group, and `blockContainer` must win so auto-fill doesn't +// recurse through nested containers. +export const CONTAINER_NODE_PRIORITY = 40; + +// Shared frozen instance so `childBlocks: true` configs always compare equal. +const EMPTY_CHILD_BLOCKS_CONFIG: ChildBlocksConfig = Object.freeze({}); + +// Normalizes the `childBlocks: true` shorthand at read time. Downstream code +// must use this instead of reading `config.childBlocks` directly — the user's +// config object is never mutated, so `blockSchema[type]` identity checks +// (e.g. `checkMultiColumnBlocksInSchema`) stay valid across schema instances. +export function getChildBlocksConfig(config: { + childBlocks?: true | ChildBlocksConfig; +}): ChildBlocksConfig | undefined { + if (!config.childBlocks) { + return undefined; + } + return config.childBlocks === true + ? EMPTY_CHILD_BLOCKS_CONFIG + : config.childBlocks; +} + +export function isContainerType(config: { + childBlocks?: true | ChildBlocksConfig; +}): boolean { + return getChildBlocksConfig(config) !== undefined; +} + +export function getMinChildren(childBlocks: ChildBlocksConfig): number { + return childBlocks.min ?? 1; +} + +export function isTopLevelContainer(childBlocks: ChildBlocksConfig): boolean { + return childBlocks.topLevel !== false; +} + +// Builds the ProseMirror content expression for a container block from its +// `childBlocks` config. +// +// `allowedBlocks` entries are BlockNote block types, but container children +// are PM *nodes*: container-type blocks are their own node type, while every +// regular block lives inside a `blockContainer` node. So container entries +// are kept verbatim and regular entries collapse to a single `blockContainer` +// term. `blockContainer` is deliberately ordered FIRST in the union — PM's +// `fillBefore` picks the first matching type when auto-filling a non-optional +// node, and filling with `blockContainer` (rather than another container) +// keeps auto-fill from recursing through nested containers. +export function childBlocksContentExpression( + childBlocks: ChildBlocksConfig, + isContainerBlockType: (blockType: string) => boolean, +): string { + const min = getMinChildren(childBlocks); + const { max, allowedBlocks } = childBlocks; + + let term: string; + if (!allowedBlocks) { + term = BLOCK_GROUP_CHILD_GROUP; + } else { + const terms: string[] = []; + if (allowedBlocks.some((blockType) => !isContainerBlockType(blockType))) { + terms.push("blockContainer"); + } + terms.push(...allowedBlocks.filter(isContainerBlockType)); + + if (terms.length === 0) { + throw new Error( + "`childBlocks.allowedBlocks` must not be empty. Omit it to allow any block.", + ); + } + + term = terms.length === 1 ? terms[0] : `(${terms.join(" | ")})`; + } + + return `${term}${childCountQuantifier(min, max)}`; +} + +function childCountQuantifier(min: number, max: number | undefined): string { + if (max === undefined) { + if (min === 0) { + return "*"; + } + if (min === 1) { + return "+"; + } + return `{${min},}`; + } + return `{${min},${max}}`; +} diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 5db7ff48eb..b5ba0c685c 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -6,6 +6,7 @@ import { TagParseRule, } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { @@ -13,8 +14,17 @@ import { ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + CONTAINER_NODE_PRIORITY, + childBlocksContentExpression, + getChildBlocksConfig, + isTopLevelContainer, +} from "./childBlocks.js"; +import { + applyContainerAttributes, + applyDOMAttributes, getBlockFromNodeView, propsToAttributes, wrapInBlockStructure, @@ -28,6 +38,23 @@ import { LooseBlockSpec, } from "./types.js"; +/** + * Full-schema context that node construction needs but a single block spec + * can't know on its own. + */ +export type BlockSchemaContext = { + isContainerBlockType: (blockType: string) => boolean; +}; + +const NO_SCHEMA_CONTEXT: BlockSchemaContext = { + isContainerBlockType: (blockType) => { + throw new Error( + `Cannot resolve whether "${blockType}" is a container block without full schema context. ` + + "Blocks that use `childBlocks.allowedBlocks` must be registered through `BlockNoteSchema.create`.", + ); + }, +}; + // Function that causes events within non-selectable blocks to be handled by the // browser instead of the editor. export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) { @@ -167,131 +194,275 @@ export function getParseRules< return rules; } -// A function to create custom block for API consumers -// we want to hide the tiptap node from API consumers and provide a simpler API surface instead -export function addNodeAndExtensionsToSpec< +function buildContainerNode( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + schemaContext: BlockSchemaContext, +) { + const childBlocks = getChildBlocksConfig(blockConfig)!; + + const groups = ["bnBlock", "childContainer"]; + if (isTopLevelContainer(childBlocks)) { + groups.push("blockGroupChild"); + } + + return Node.create({ + name: blockConfig.type, + content: childBlocksContentExpression( + childBlocks, + schemaContext.isContainerBlockType, + ), + group: groups.join(" "), + marks() { + return suggestionMarks(this.editor); + }, + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + defining: true, + priority: CONTAINER_NODE_PRIORITY, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return [ + { + tag: `[data-node-type=${blockConfig.type}]`, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", blockConfig.type); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + dom.setAttribute(attribute, value as string); + } + return { dom, contentDOM: dom }; + }, + + addNodeView() { + return (props) => { + const editor = this.options.editor; + + // A container's node *is* the block — convert directly rather than + // walking up to a parent (which would find the enclosing block). + const block = nodeToBlock(props.node, props.view.state.doc); + + const nodeView = blockImplementation.render.call( + { + blockContentDOMAttributes: + this.options.domAttributes?.blockContent || {}, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + editor as any, + ); + + applyContainerAttributes( + nodeView.dom, + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + + const typedNodeView = nodeView as unknown as NodeView; + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, this.editor); + } + + ignoreNonContentMutations(typedNodeView); + + // Unlike regular blocks, containers honor an `update` hook: + // recreating the node view would remount every child block, so a + // container patches its own DOM in place instead. + const update = typedNodeView.update?.bind(typedNodeView); + if (update) { + typedNodeView.update = (node, decorations, innerDecorations) => { + if (node.type.name !== blockConfig.type) { + return false; + } + if (update(node, decorations, innerDecorations) === false) { + return false; + } + applyContainerAttributes( + typedNodeView.dom, + blockConfig.type, + nodeToBlock(node, props.view.state.doc).props as any, + blockConfig.propSchema, + node.attrs.id, + ); + return true; + }; + } + + return typedNodeView; + }; + }, + }); +} + +function buildRegularNode< TName extends string, TProps extends PropSchema, TContent extends "inline" | "none" | "table" | "plain", >( blockConfig: BlockConfig, blockImplementation: BlockImplementation, - extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, -): LooseBlockSpec { - const node = - ((blockImplementation as any).node as Node) || - Node.create({ - name: blockConfig.type, - content: (blockConfig.content === "inline" - ? "inline*" - : blockConfig.content === "plain" - ? "text*" - : blockConfig.content === "none" - ? "" - : blockConfig.content) as TContent extends "inline" - ? "inline*" - : TContent extends "plain" - ? "text*" - : "", - // "plain" blocks hold unstyled text, so they disallow formatting marks. - // They still allow the non-formatting marks (comments and - // suggestions/diffs) — those annotate content without changing it and are - // ignored by the block model. `nonFormattingMarks` resolves the group only - // when at least one such mark is registered, so a plain block in an editor - // without any of them doesn't reference an empty (unknown) mark group. - marks() { - return blockConfig.content === "plain" - ? nonFormattingMarks(this.editor) - : undefined; - }, - group: "blockContent", - selectable: blockImplementation.meta?.selectable ?? true, - isolating: blockImplementation.meta?.isolating ?? true, - code: blockImplementation.meta?.code ?? false, - defining: blockImplementation.meta?.defining ?? true, - priority, - addAttributes() { - return propsToAttributes(blockConfig.propSchema); - }, +) { + return Node.create({ + name: blockConfig.type, + content: (blockConfig.content === "inline" + ? "inline*" + : blockConfig.content === "plain" + ? "text*" + : blockConfig.content === "none" + ? "" + : blockConfig.content) as TContent extends "inline" + ? "inline*" + : TContent extends "plain" + ? "text*" + : "", + // "plain" blocks hold unstyled text, so they disallow formatting marks. + // They still allow the non-formatting marks (comments and + // suggestions/diffs) — those annotate content without changing it and are + // ignored by the block model. `nonFormattingMarks` resolves the group only + // when at least one such mark is registered, so a plain block in an editor + // without any of them doesn't reference an empty (unknown) mark group. + marks() { + return blockConfig.content === "plain" + ? nonFormattingMarks(this.editor) + : undefined; + }, + group: "blockContent", + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + code: blockImplementation.meta?.code ?? false, + defining: blockImplementation.meta?.defining ?? true, + priority, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, - parseHTML() { - return getParseRules(blockConfig, blockImplementation); - }, + parseHTML() { + return getParseRules(blockConfig, blockImplementation); + }, + + renderHTML({ HTMLAttributes }) { + // renderHTML is used for copy/pasting content from the editor back into + // the editor, so we need to make sure the `blockContent` element is + // structured correctly as this is what's used for parsing blocks. We + // just render a placeholder div inside as the `blockContent` element + // already has all the information needed for proper parsing. + const div = document.createElement("div"); + return wrapInBlockStructure( + { + dom: div, + contentDOM: + blockConfig.content === "inline" || blockConfig.content === "plain" + ? div + : undefined, + }, + blockConfig.type, + {}, + blockConfig.propSchema, + blockImplementation.meta?.fileBlockAccept !== undefined, + HTMLAttributes, + ); + }, + + addNodeView() { + return (props) => { + // Gets the BlockNote editor instance + const editor = this.options.editor; + // Gets the block. Resolving this can't rely on `getPos()` alone — + // node views are constructed part-way through ProseMirror's + // reconciliation, where positions don't always line up with + // `view.state.doc` yet (see `getBlockFromNodeView`). + const block = getBlockFromNodeView( + props.getPos, + props.node, + props.view.state.doc, + ); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; - renderHTML({ HTMLAttributes }) { - // renderHTML is used for copy/pasting content from the editor back into - // the editor, so we need to make sure the `blockContent` element is - // structured correctly as this is what's used for parsing blocks. We - // just render a placeholder div inside as the `blockContent` element - // already has all the information needed for proper parsing. - const div = document.createElement("div"); - return wrapInBlockStructure( + const nodeView = blockImplementation.render.call( { - dom: div, - contentDOM: - blockConfig.content === "inline" || - blockConfig.content === "plain" - ? div - : undefined, + blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, }, - blockConfig.type, - {}, - blockConfig.propSchema, - blockImplementation.meta?.fileBlockAccept !== undefined, - HTMLAttributes, + block as any, + editor as any, ); - }, - addNodeView() { - return (props) => { - // Gets the BlockNote editor instance - const editor = this.options.editor; - // Gets the block. Resolving this can't rely on `getPos()` alone — - // node views are constructed part-way through ProseMirror's - // reconciliation, where positions don't always line up with - // `view.state.doc` yet (see `getBlockFromNodeView`). - const block = getBlockFromNodeView( - props.getPos, - props.node, - props.view.state.doc, - ); - // Gets the custom HTML attributes for `blockContent` nodes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; + // Cast needed because render returns `dom: HTMLElement | DocumentFragment` + // but tiptap's NodeView expects `dom: HTMLElement` + const typedNodeView = nodeView as unknown as NodeView; - const nodeView = blockImplementation.render.call( - { - blockContentDOMAttributes, - props, - renderType: "nodeView", - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ); + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, this.editor); + } - // Cast needed because render returns `dom: HTMLElement | DocumentFragment` - // but tiptap's NodeView expects `dom: HTMLElement` - const typedNodeView = nodeView as unknown as NodeView; + // Ignores DOM mutations that don't affect the block's content, so + // that browser extensions which rewrite the DOM (e.g. Dark Reader) + // can't trigger an infinite re-render loop that freezes the tab. + ignoreNonContentMutations(typedNodeView); + + // See explanation for why `update` is not implemented for NodeViews + // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 + // TODO: in a future version, we might want to implement updates so that + // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220) + return typedNodeView; + }; + }, + }); +} - if (blockImplementation.meta?.selectable === false) { - applyNonSelectableBlockFix(typedNodeView, this.editor); - } +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function addNodeAndExtensionsToSpec< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + extensions?: (ExtensionFactoryInstance | Extension)[], + priority?: number, + schemaContext: BlockSchemaContext = NO_SCHEMA_CONTEXT, +): LooseBlockSpec { + const childBlocksConfig = getChildBlocksConfig(blockConfig); - // Ignores DOM mutations that don't affect the block's content, so - // that browser extensions which rewrite the DOM (e.g. Dark Reader) - // can't trigger an infinite re-render loop that freezes the tab. - ignoreNonContentMutations(typedNodeView); - - // See explanation for why `update` is not implemented for NodeViews - // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 - // TODO: in a future version, we might want to implement updates so that - // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220) - return typedNodeView; - }; - }, - }); + if (childBlocksConfig && blockConfig.content !== "none") { + throw new Error( + `Block "${blockConfig.type}" sets \`childBlocks\` but its \`content\` is "${blockConfig.content}". Container blocks must declare \`content: "none"\`.`, + ); + } + + const isContainer = childBlocksConfig !== undefined; + + const node = + ((blockImplementation as any).node as Node) || + (childBlocksConfig + ? buildContainerNode( + blockConfig as unknown as BlockConfig, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "none" + >, + schemaContext, + ) + : buildRegularNode(blockConfig, blockImplementation, priority)); if (node.name !== blockConfig.type) { throw new Error( @@ -308,7 +479,7 @@ export function addNodeAndExtensionsToSpec< const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - return blockImplementation.render.call( + const output = blockImplementation.render.call( { blockContentDOMAttributes, props: undefined, @@ -318,6 +489,18 @@ export function addNodeAndExtensionsToSpec< block as any, editor as any, ); + + if (isContainer) { + applyContainerAttributes( + output.dom, + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + } + + return output; }, // TODO: this should not have wrapInBlockStructure and generally be a lot simpler // post-processing in externalHTMLExporter should not be necessary @@ -325,7 +508,7 @@ export function addNodeAndExtensionsToSpec< const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - return ( + const output = blockImplementation.toExternalHTML?.call( { blockContentDOMAttributes, propSchema: blockConfig.propSchema }, block as any, @@ -341,8 +524,19 @@ export function addNodeAndExtensionsToSpec< }, block as any, editor as any, - ) - ); + ); + + if (output && isContainer) { + applyContainerAttributes( + output.dom, + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + } + + return output; }, }, extensions, @@ -453,6 +647,8 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + const isContainer = getChildBlocksConfig(blockConfig) !== undefined; + return { config: blockConfig, implementation: { @@ -471,6 +667,11 @@ export function createBlockSpec< return undefined; } + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -490,6 +691,11 @@ export function createBlockSpec< editor as any, ); + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + const nodeView = wrapInBlockStructure( output, block.type, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index cfd17b9d11..1b7dc4afda 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,19 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; -import { LooseBlockSpec } from "./types.js"; +import { ChildBlocksConfig, LooseBlockSpec } from "./types.js"; + +// Re-export child-block helpers so existing callers of `internal.js` keep working. +export { + getChildBlocksConfig, + isContainerType, + childBlocksContentExpression, + getMinChildren, + isTopLevelContainer, + CHILD_CONTAINER_GROUP, + BLOCK_GROUP_CHILD_GROUP, + CONTAINER_NODE_PRIORITY, +} from "./childBlocks.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -157,6 +169,105 @@ export function getBlockFromNodeView( } } +/** + * Applies custom `blockContent` DOM attributes to an element, merging (rather + * than overwriting) its class list. + */ +export function applyDOMAttributes( + dom: HTMLElement | DocumentFragment, + domAttributes: Record | undefined, +) { + if (!domAttributes || !(dom instanceof HTMLElement)) { + return; + } + for (const [attr, value] of Object.entries(domAttributes)) { + if (attr === "class") { + dom.className = mergeCSSClasses(dom.className, value); + } else { + dom.setAttribute(attr, value); + } + } +} + +// Writes the `data-node-type` marker and each non-default prop (as a +// kebab-cased `data-*` attribute) a container block's root element needs to +// round-trip through the generated parse rules. Two modes: +// - `authoritative`: the caller owns the element (the node view), so it +// overwrites and clears defaulted attrs. +// - otherwise: the block's own render owns the element (HTML serialization), +// so existing attributes are left untouched — author-set attributes win. +// `data-id` is handled by `applyContainerAttributes` alone. +function writeContainerPropAttributes( + element: HTMLElement, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + authoritative: boolean, +) { + if (authoritative || !element.hasAttribute("data-node-type")) { + element.setAttribute("data-node-type", blockType); + } + for (const [prop, value] of Object.entries(blockProps)) { + const attr = camelToDataKebab(prop); + const isDefault = + value === propSchema[prop]?.default || value === undefined; + if (authoritative) { + if (isDefault) { + element.removeAttribute(attr); + } else { + element.setAttribute(attr, `${value}`); + } + } else if (!isDefault && !element.hasAttribute(attr)) { + element.setAttribute(attr, `${value}`); + } + } +} + +/** + * Applies the attributes BlockNote relies on to a container block's root + * element: `data-node-type`, `data-id`, and each non-default prop as a + * kebab-cased `data-*` attribute. Called from `buildContainerNode`'s node + * view (both initial render and `update`) so block renders don't have to + * stamp these themselves. Overwrites existing attributes. + */ +export function applyContainerAttributes( + dom: HTMLElement | DocumentFragment | undefined, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id: string | undefined, +) { + const element = dom as HTMLElement | undefined; + if (!element || typeof element.setAttribute !== "function") { + return; + } + writeContainerPropAttributes( + element, + blockType, + blockProps, + propSchema, + true, + ); + if (id) { + element.setAttribute("data-id", id); + } +} + +/** + * Fills in the `data-node-type` marker and non-default prop `data-*` + * attributes a container block's serialized root needs to parse back, without + * clobbering any the block's own render already set (author-set attributes + * win). Used by internal/external HTML serialization. + */ +export function fillContainerAttributes( + dom: HTMLElement, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, +) { + writeContainerPropAttributes(dom, blockType, blockProps, propSchema, false); +} + // Function that wraps the `dom` element returned from 'blockConfig.render' in a // `blockContent` div, which contains the block type and props as HTML // attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds @@ -232,6 +343,11 @@ export function createBlockSpecFromTiptapNode< node: Node; type: string; content: "inline" | "table" | "none" | "plain"; + // Declares the block's container semantics (min/max/repair etc.) even + // though the node itself is hand-written — the node's own content + // expression stays authoritative for the PM schema, while BlockNote-level + // behavior (repair, seeding, validation) reads this config. + childBlocks?: true | ChildBlocksConfig; }, P extends PropSchema, >( @@ -244,6 +360,9 @@ export function createBlockSpecFromTiptapNode< type: config.type as T["type"], content: config.content, propSchema, + ...(config.childBlocks !== undefined + ? { childBlocks: config.childBlocks } + : {}), }, implementation: { node: config.node, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 00b564ef0b..75c5ac4532 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,7 +1,7 @@ /** Define the main block types **/ // import { Extension, Node } from "@tiptap/core"; import type { Node, NodeViewRendererProps } from "@tiptap/core"; -import type { Fragment, Schema } from "prosemirror-model"; +import type { Fragment, Node as PMNode, Schema } from "prosemirror-model"; import type { ViewMutationRecord } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { @@ -59,8 +59,83 @@ export interface BlockConfigMeta { * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.isolating} block */ isolating?: boolean; + + /** + * Whether this block type gets a side menu drag handle (and can be dragged + * by it). Applies to any block type, not just container blocks — e.g. a + * "locked" block can opt out of dragging entirely. + * @default true + */ + draggable?: boolean; + + /** + * Only applies to container blocks (blocks with `childBlocks`): whether + * pressing Enter on an empty block that is the last child of the container + * moves that block out of (after) the container, list-style. Without this, + * a container as the last block in the document can trap the cursor, as + * Enter only ever creates new blocks *within* the container. + * @default true + */ + exitOnEnter?: boolean; } +/** + * Configuration for a block that hosts other blocks as its body (a + * "container" block). When set, the block's ProseMirror node is emitted in + * the `bnBlock` / `childContainer` groups with block-node content (by + * default `blockGroupChild{min,max}`) — the same shape that columns use. + * Child blocks live on `block.children` at runtime (matching the column + * model). Requires `content: "none"`. + */ +export type ChildBlocksConfig = { + /** + * Block types allowed as direct children. Container-block entries (types + * that themselves declare `childBlocks`) are enforced exactly by the + * ProseMirror schema. Regular block entries collapse to "any regular + * block" at the node level — every regular block is wrapped in the same + * `blockContainer` node, so the schema cannot distinguish between them. + * Defaults to any block (`blockGroupChild`). + */ + allowedBlocks?: string[]; + /** Minimum number of child blocks. Defaults to 1. */ + min?: number; + /** Maximum number of child blocks. Defaults to unbounded. */ + max?: number; + /** + * Children to seed the container with on first insert, as partial blocks + * (so props and nested children are expressible). Ignored when the + * inserted partial block already provides explicit `children`. Validated + * against `allowedBlocks`/`min`/`max` when the schema is created. + */ + defaultChildren?: PartialBlockNoDefaults[]; + /** + * Whether the block can be inserted at any position where a regular block + * goes — i.e. directly inside a `blockGroup` (the document root, or as a + * child of any other block). Defaults to `true`. Set to `false` for blocks + * that should only appear inside a specific schema-restricted parent (e.g. + * a `column` only ever lives inside a `columnList`). + */ + topLevel?: boolean; + /** + * When set, `fixContainer` collapses the container as its children empty + * out (after Backspace merges a child out, `replaceBlocks` deletes + * children, etc.): it drops emptied children (a child holding nothing but a + * single empty paragraph, possibly through nested containers) and, if that + * leaves fewer than `min` non-empty children, unwraps the container — + * replacing it with its remaining non-empty children (non-top-level + * container children are flattened into *their* children), or removing it + * entirely when none remain. Column lists use this so emptied columns + * disappear and a one-column list unwraps. + * + * Coupled to `min`, so it lives here rather than in `meta`: without it + * repair is a no-op, because ProseMirror's schema fitting always pads a + * container back up to `min` with empty children, so "effectively below + * min" can only be detected by discounting those empty children. + * @default false + */ + collapseWhenEmptied?: boolean; +}; + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -87,8 +162,16 @@ 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) + /** + * Marks this block as a container of other blocks. The block's PM node is + * emitted in the `bnBlock` / `childContainer` groups with block-node + * content (regular blocks wrapped in `blockContainer` nodes, plus + * container-type blocks); child blocks are exposed on `block.children`. + * Requires `content: "none"`. Pass `true` for defaults or an object to + * constrain which/how many children are allowed and seed the initial + * children. + */ + childBlocks?: true | ChildBlocksConfig; } /** @@ -210,6 +293,7 @@ export type LooseBlockSpec< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -268,6 +352,7 @@ export type BlockSpecs = { contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -553,6 +638,20 @@ export type BlockImplementation< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + /** + * Optional NodeView update hook. Called when the underlying ProseMirror + * node's attributes change (or its decorations change). Return `false` to + * tell ProseMirror to destroy and recreate the NodeView (i.e. re-run + * `render` from scratch). Return `true` (or `undefined`) when you have + * patched `dom` in-place and PM should keep the existing view. + * + * Only honored for container blocks (blocks with `childBlocks`), where + * recreating the node view would remount every child block — e.g. column + * resizing patches widths in place through this hook. Non-container + * blocks always recreate on attr changes (see + * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464). + */ + update?: (node: PMNode) => boolean | void; }; /** diff --git a/packages/core/src/schema/blocks/validateChildBlocks.test.ts b/packages/core/src/schema/blocks/validateChildBlocks.test.ts new file mode 100644 index 0000000000..4635164630 --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildBlocks.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { validateChildBlocksConfigs } from "./validateChildBlocks.js"; + +function configsWith(containers: Record) { + return { + paragraph: { type: "paragraph", content: "inline" as const }, + ...Object.fromEntries( + Object.entries(containers).map(([type, { childBlocks }]) => [ + type, + { type, content: "none" as const, childBlocks }, + ]), + ), + }; +} + +describe("validateChildBlocksConfigs", () => { + it("accepts a plain container config", () => { + const configs = configsWith({ callout: { childBlocks: { min: 1 } } }); + expect(() => validateChildBlocksConfigs(configs)).not.toThrow(); + }); + + it("accepts the columnList shape (restricted children, min 2)", () => { + const configs = configsWith({ + grid: { childBlocks: { allowedBlocks: ["gridCell"], min: 2 } }, + gridCell: { childBlocks: { topLevel: false } }, + }); + expect(() => validateChildBlocksConfigs(configs)).not.toThrow(); + }); + + it("rejects unknown allowedBlocks entries", () => { + const configs = configsWith({ + grid: { childBlocks: { allowedBlocks: ["doesNotExist"] } }, + }); + expect(() => validateChildBlocksConfigs(configs)).toThrow(/doesNotExist/); + }); + + it("rejects negative or non-integer min", () => { + const configs = configsWith({ callout: { childBlocks: { min: -1 } } }); + expect(() => validateChildBlocksConfigs(configs)).toThrow(/min/); + }); + + it("rejects max smaller than min", () => { + const configs = configsWith({ + callout: { childBlocks: { min: 3, max: 2 } }, + }); + expect(() => validateChildBlocksConfigs(configs)).toThrow(/max/); + }); + + it("rejects defaultChildren violating min/max", () => { + const configs = configsWith({ + callout: { + childBlocks: { + min: 2, + defaultChildren: [{ type: "paragraph" }], + }, + }, + }); + expect(() => validateChildBlocksConfigs(configs)).toThrow( + /defaultChildren/, + ); + }); + + it("rejects defaultChildren of unknown types", () => { + const configs = configsWith({ + callout: { + childBlocks: { defaultChildren: [{ type: "doesNotExist" }] }, + }, + }); + expect(() => validateChildBlocksConfigs(configs)).toThrow(/doesNotExist/); + }); + + it("rejects defaultChildren not allowed by allowedBlocks", () => { + const configs = configsWith({ + grid: { + childBlocks: { + allowedBlocks: ["gridCell"], + min: 1, + defaultChildren: [{ type: "paragraph" }], + }, + }, + gridCell: { childBlocks: { topLevel: false } }, + }); + expect(() => validateChildBlocksConfigs(configs)).toThrow( + /not permitted by/, + ); + }); + + it("rejects content that is not 'none'", () => { + const configs = { + paragraph: { type: "paragraph", content: "inline" }, + bad: { type: "bad", content: "inline", childBlocks: true }, + }; + expect(() => validateChildBlocksConfigs(configs as any)).toThrow( + /content: "none"/, + ); + }); + + it("rejects empty allowedBlocks", () => { + const configs = configsWith({ + callout: { childBlocks: { allowedBlocks: [] } }, + }); + expect(() => validateChildBlocksConfigs(configs)).toThrow( + /must not be empty/, + ); + }); +}); diff --git a/packages/core/src/schema/blocks/validateChildBlocks.ts b/packages/core/src/schema/blocks/validateChildBlocks.ts new file mode 100644 index 0000000000..0d39ce4e26 --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildBlocks.ts @@ -0,0 +1,134 @@ +import { + getChildBlocksConfig, + getMinChildren, + isContainerType, +} from "./childBlocks.js"; +import type { BlockConfig, ChildBlocksConfig } from "./types.js"; + +/** + * Validates the `childBlocks` config of every block in a schema, so that + * misconfigurations surface as a clear error at schema-creation time instead of + * as an opaque ProseMirror one (or a stack overflow) much later. + * + * @param blockConfigs The configs of every block in the schema, keyed by type. + */ +export function validateChildBlocksConfigs( + blockConfigs: Record< + string, + Pick & { + childBlocks?: true | ChildBlocksConfig; + } + >, +) { + const isContainerBlockType = (blockType: string) => + !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]); + + for (const [type, config] of Object.entries(blockConfigs)) { + const childBlocks = getChildBlocksConfig(config); + if (!childBlocks) { + continue; + } + + validateOne( + type, + config.content, + childBlocks, + blockConfigs, + isContainerBlockType, + ); + } +} + +function validateOne( + type: string, + content: string, + childBlocks: ChildBlocksConfig, + blockConfigs: Record, + isContainerBlockType: (blockType: string) => boolean, +) { + const fail = (message: string): never => { + throw new Error( + `Invalid \`childBlocks\` config for block "${type}": ${message}`, + ); + }; + + if (content !== "none") { + fail( + `\`childBlocks\` requires \`content: "none"\`, but content is "${content}". A container block holds blocks, not inline content.`, + ); + } + + const min = getMinChildren(childBlocks); + const { max, allowedBlocks, defaultChildren } = childBlocks; + + if (!Number.isInteger(min) || min < 0) { + fail(`\`min\` must be a non-negative integer, but is ${min}.`); + } + + if (max !== undefined) { + if (!Number.isInteger(max) || max < 1) { + fail(`\`max\` must be a positive integer, but is ${max}.`); + } + if (max < min) { + fail( + `\`max\` (${max}) must be greater than or equal to \`min\` (${min}).`, + ); + } + } + + if (allowedBlocks) { + if (allowedBlocks.length === 0) { + fail("`allowedBlocks` must not be empty. Omit it to allow any block."); + } + for (const allowed of allowedBlocks) { + if (!(allowed in blockConfigs)) { + fail( + `\`allowedBlocks\` contains "${allowed}", which is not a block type in this schema.`, + ); + } + } + } + + if (defaultChildren) { + if (defaultChildren.length < min) { + fail( + `\`defaultChildren\` has ${defaultChildren.length} block(s), fewer than \`min\` (${min}).`, + ); + } + if (max !== undefined && defaultChildren.length > max) { + fail( + `\`defaultChildren\` has ${defaultChildren.length} block(s), more than \`max\` (${max}).`, + ); + } + for (const child of defaultChildren) { + const childType = child.type ?? "paragraph"; + if (!(childType in blockConfigs)) { + fail( + `\`defaultChildren\` contains a block of type "${childType}", which is not a block type in this schema.`, + ); + } + if ( + allowedBlocks && + !isAllowed(childType, allowedBlocks, isContainerBlockType) + ) { + fail( + `\`defaultChildren\` contains a block of type "${childType}", which is not permitted by \`allowedBlocks\`.`, + ); + } + } + } +} + +function isAllowed( + blockType: string, + allowedBlocks: string[], + isContainerBlockType: (blockType: string) => boolean, +): boolean { + if (allowedBlocks.includes(blockType)) { + return true; + } + return ( + !isContainerBlockType(blockType) && + allowedBlocks.some((allowed) => !isContainerBlockType(allowed)) + ); +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..c86b9206b4 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,6 +1,8 @@ +export * from "./blocks/childBlocks.js"; export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; +export * from "./blocks/validateChildBlocks.js"; export * from "./inlineContent/createSpec.js"; export * from "./inlineContent/internal.js"; export * from "./inlineContent/types.js"; diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index a7a04e93dc..5418ac93dd 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -16,6 +16,9 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "./index.js"; +import { isContainerType } from "./blocks/childBlocks.js"; +import type { BlockSchemaContext } from "./blocks/createSpec.js"; +import { validateChildBlocksConfigs } from "./blocks/validateChildBlocks.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -91,6 +94,25 @@ export class CustomBlockNoteSchema< })), ); + // Container-ness is needed to build *other* blocks' nodes (a container's + // `allowedBlocks` maps block types to node terms, and only container types + // are their own node type), so it's resolved across the whole schema up + // front. Validation runs first so misconfigurations surface as clear errors + // rather than as opaque ProseMirror ones. + const blockConfigs = Object.fromEntries( + Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [ + key, + blockSpec.config, + ]), + ); + + validateChildBlocksConfigs(blockConfigs); + + const schemaContext: BlockSchemaContext = { + isContainerBlockType: (blockType) => + !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]), + }; + const blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { return [ @@ -100,6 +122,7 @@ export class CustomBlockNoteSchema< blockSpec.implementation, blockSpec.extensions, getPriority(key), + schemaContext, ), ]; }), diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts index 37fb1fd4e9..7dc3f4253d 100644 --- a/packages/core/src/yjs/extensions/FixUpSchema.ts +++ b/packages/core/src/yjs/extensions/FixUpSchema.ts @@ -25,7 +25,15 @@ export const FixUpSchemaExtension = createExtension(({ editor }) => { // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) const jsonNode = JSON.parse(JSON.stringify(ret.toJSON())); - jsonNode.content[0].content[0].attrs.id = "initialBlockId"; + // The first fill of the doc's blockGroup is guaranteed to be a + // `blockContainer` (container block nodes register at lower priority + // precisely so auto-fill picks `blockContainer` first), but guard on + // the node actually carrying an id attr in case a custom schema + // changes that. + const firstBlock = jsonNode.content?.[0]?.content?.[0]; + if (firstBlock?.attrs && "id" in firstBlock.attrs) { + firstBlock.attrs.id = "initialBlockId"; + } cache = Node.fromJSON(schema, jsonNode); return cache; diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 2bf0e4fa57..eed8e0ef93 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -1,4 +1,4 @@ -import { getNodeById } from "@blocknote/core"; +import { getNodeById, isContainerNode } from "@blocknote/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -29,6 +29,17 @@ export const BlockPopover = ( return undefined; } + // For container blocks the PM node IS the block, so a position + // inside it resolves to its contentDOM — the child-blocks area — + // which would anchor the popover to the first child's rows instead + // of the block's own element. + if (isContainerNode(nodePosInfo.node.type)) { + const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode); + if (dom instanceof Element) { + return { element: dom }; + } + } + const { node } = editor.prosemirrorView.domAtPos( nodePosInfo.posBeforeNode + 1, ); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2259babd84..586d7437b7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -137,6 +137,7 @@ export * from "./hooks/useExtension.js"; export * from "./hooks/useEditorState.js"; export * from "./schema/ReactBlockSpec.js"; +export * from "./schema/ChildBlocksWrapper.js"; export * from "./schema/ReactInlineContentSpec.js"; export * from "./schema/ReactStyleSpec.js"; export * from "./schema/useNodeViewBlock.js"; diff --git a/packages/react/src/schema/ChildBlocksWrapper.tsx b/packages/react/src/schema/ChildBlocksWrapper.tsx new file mode 100644 index 0000000000..fe8cfa4902 --- /dev/null +++ b/packages/react/src/schema/ChildBlocksWrapper.tsx @@ -0,0 +1,31 @@ +import { NodeViewWrapper } from "@tiptap/react"; +import { ComponentPropsWithoutRef, forwardRef, ReactNode } from "react"; + +export type ChildBlocksWrapperProps = ComponentPropsWithoutRef<"div"> & { + children: ReactNode; +}; + +/** + * The root element a container block's `render` should return. + * + * A container block owns its outer DOM: BlockNote adds no `blockContent` + * wrapper around it, and its content target holds child blocks rather than + * inline content. This component wraps Tiptap's `NodeViewWrapper` (so + * ProseMirror recognizes the node view's element) and spreads everything + * else through. + * + * The attributes BlockNote relies on (`data-node-type`, `data-id`, and each + * non-default prop as a `data-*` attribute) are applied by + * `applyContainerAttributes` in `@blocknote/core` — a container render + * doesn't set them itself. + */ +export const ChildBlocksWrapper = forwardRef< + HTMLDivElement, + ChildBlocksWrapperProps +>(({ children, ...rest }, ref) => ( + + {children} + +)); + +ChildBlocksWrapper.displayName = "ChildBlocksWrapper"; diff --git a/packages/react/src/schema/ReactBlockSpec.container.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.test.tsx new file mode 100644 index 0000000000..01680dd527 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx @@ -0,0 +1,95 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + defaultBlockSpecs, +} from "@blocknote/core"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +// Same shape as the example callout block (`examples/06-custom-schema/09-container-block`). +// This test exists to confirm the document-level transformation succeeds — it +// does NOT mount BlockNoteView, so React rendering of the nodeView itself is +// not exercised here. +const Callout = createReactBlockSpec( + { + type: "callout" as const, + propSchema: {}, + content: "none" as const, + childBlocks: { min: 1, defaultChildren: [{ type: "paragraph" }] }, + }, + { + render: ({ contentRef }) => ( +
+
+
+ ), + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + } as const, +}); + +describe("React updateBlock → container with defaultChildren (document-level)", () => { + let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + >; + const div = document.createElement("div"); + + beforeAll(() => { + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); + }); + + afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; + }); + + beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "" }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + }); + + it("converts an empty paragraph to a callout via editor.updateBlock", () => { + editor.updateBlock("p-0", { type: "callout" }); + expect(editor.document).toMatchSnapshot(); + }, 5000); + + it("does not wrap containers in a blockContent div in external HTML", async () => { + // A separate, unmounted (headless) editor: the React external-HTML path + // renders through a temporary root in headless mode. + const headlessEditor = BlockNoteEditor.create({ schema }); + + const html = headlessEditor.blocksToHTMLLossy([ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }], + }, + ] as any); + // Container blocks own their outer DOM entirely — regression test for + // the React `toExternalHTML` path wrapping them in a spurious + // `bn-block-content` div (core's `createBlockSpec` passes them through). + expect(html).not.toContain('data-content-type="callout"'); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain("Hello"); + }, 5000); +}); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 4bd1649292..db996a601b 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -10,7 +10,9 @@ import { Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, + isContainerType, mergeCSSClasses, + nodeToBlock, Props, PropSchema, } from "@blocknote/core"; @@ -33,11 +35,14 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; -} & (Config["content"] extends "inline" - ? { +} & (Config["content"] extends "table" + ? object + : { + // For inline-content blocks, points to where the inline text mounts. + // For container blocks, points to where child blocks mount. For other + // `content: "none"` blocks, this can be ignored. contentRef: (node: HTMLElement | null) => void; - } - : object); + }); // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< @@ -233,9 +238,33 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { + const isContainer = isContainerType(blockConfig); const BlockContent = blockImplementation.toExternalHTML || blockImplementation.render; const output = renderToDOMSpec((refCB) => { + const content = ( + { + refCB(element); + if (element && !isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + context={context} + /> + ); + if (isContainer) { + // Container blocks own their outer DOM entirely (the PM node IS + // the bnBlock — no `blockContent` wrapper), matching the core + // `createBlockSpec` pass-through and the node-view/dom render + // paths below. + return content; + } return ( - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - context={context} - /> + {content} ); }, editor); @@ -271,78 +287,137 @@ export function createReactBlockSpec< // constructed (itself guarded, via `getBlockFromNodeView`). Seeds // the fallback below so there is always something to render. const initialBlock = block; + // Container-ness is fixed per spec, so the node-view component + // can be chosen once — each variant is straight-line code using + // only the hooks and wrappers it needs. + const isContainer = isContainerType(blockConfig); + const BlockContent = blockImplementation.render; + const blockContentDOMAttributes = this.blockContentDOMAttributes; - return ReactNodeViewRenderer( - (props: NodeViewProps) => { - // Vanilla JS node views are recreated on each update. However, - // using `ReactNodeViewRenderer` makes it so the node view is - // only created once, so the block we get in the node view will - // be outdated. Therefore, we have to get the block in the - // `ReactNodeViewRenderer` instead. That position can be stale, - // so resolving it is guarded (see `useNodeViewBlock`). - const block = useNodeViewBlock(props, initialBlock); + // Vanilla JS node views are recreated on each update. However, + // using `ReactNodeViewRenderer` makes it so the node view is only + // created once, so the block we get in the node view will be + // outdated. Therefore, both variants have to (re-)resolve the + // block inside the `ReactNodeViewRenderer` component. - const ref = useReactNodeView().nodeViewContentRef; + const ContainerNodeView = (props: NodeViewProps) => { + // Container blocks are bnBlock nodes (no `blockContainer` + // wrapper), so the id lives on the node's own attrs and the + // block resolves by id. Position-based resolution + // (`useNodeViewBlock`) would walk up to a *parent* bnBlock — + // the wrong block here — and ids are also immune to the stale + // positions it has to guard against. + const id = (props.node.attrs as Record).id; + if (!id) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute.`, + ); + } + // The id lookup misses when the node was just removed from the + // document (e.g. a suggestion-mode deletion still rendering); + // fall back to converting the node the view was handed. + const block = + editor.getBlock(id) ?? + nodeToBlock(props.node, props.view.state.doc); - if (!ref) { - throw new Error("nodeViewContentRef is not set"); - } + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } + + // Container blocks own their entire DOM: the user's render is + // responsible for returning a `` (with any + // `data-*` attrs they want exposed). The framework doesn't + // insert any wrapping element — letting authors build tag-pure + // structures (e.g. ``/``/`
`). + return ( + { + ref(element); + if (element) { + element.dataset.nodeViewContent = ""; + } + }} + /> + ); + }; + + const RegularNodeView = (props: NodeViewProps) => { + // The node view's position can be stale mid-render, so + // resolving it is guarded (see `useNodeViewBlock`). + const block = useNodeViewBlock(props, initialBlock); + + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } - const BlockContent = blockImplementation.render; - return ( - - { - ref(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - element.dataset.nodeViewContent = ""; - } - }} - /> - - ); - }, - { - className: "bn-react-node-view-renderer", - }, - )(this.props!) as ReturnType; - } else { - const BlockContent = blockImplementation.render; - const output = renderToDOMSpec((refCB) => { return ( { - refCB(element); + ref(element); if (element) { element.className = mergeCSSClasses( "bn-inline-content", element.className, ); + element.dataset.nodeViewContent = ""; } }} /> ); + }; + + return ReactNodeViewRenderer( + isContainer ? ContainerNodeView : RegularNodeView, + { + className: "bn-react-node-view-renderer", + }, + )(this.props!) as ReturnType; + } else { + const isContainer = isContainerType(blockConfig); + const BlockContent = blockImplementation.render; + const output = renderToDOMSpec((refCB) => { + const content = ( + { + refCB(element); + if (element && !isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + /> + ); + if (isContainer) { + return content; + } + return ( + + {content} + + ); }, editor); return output; } diff --git a/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap b/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap new file mode 100644 index 0000000000..2a70aa4d12 --- /dev/null +++ b/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap @@ -0,0 +1,36 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`React updateBlock → container with defaultChildren (document-level) > converts an empty paragraph to a callout via editor.updateBlock 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "callout", + }, + { + "children": [], + "content": [], + "id": "trailing", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts index 02393a2fd0..74d2e84037 100644 --- a/packages/react/src/schema/useNodeViewBlock.ts +++ b/packages/react/src/schema/useNodeViewBlock.ts @@ -42,6 +42,17 @@ export function useNodeViewBlock( const lastBlockRef = useRef(initialBlock); const doc = props.view.state.doc; + // Position-based resolution finds the nearest bnBlock *parent* of the + // position — correct for blockContent node views, but wrong-by-construction + // for container blocks, whose node IS the bnBlock: it would return an + // ancestor block. Guarded loudly so a container node view can't silently + // render the wrong block. + if (props.node.type.isInGroup("bnBlock")) { + throw new Error( + `useNodeViewBlock cannot resolve container block "${props.node.type.name}": position-based resolution returns the nearest bnBlock parent, which is the wrong block when the node view's node is the block itself. Resolve container blocks by id instead, e.g. editor.getBlock(props.node.attrs.id).`, + ); + } + try { // Deliberate render-phase write: a monotonic "last good value" cache, so a // repeated render (e.g. StrictMode's double invoke) recomputes the same diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index a24340a7ab..9dfe893326 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -1,5 +1,6 @@ import { BlockNoteSchema, + createBlockSpec, defaultBlockSpecs, createPageBreakBlockSpec, } from "@blocknote/core"; @@ -270,6 +271,82 @@ describe("exporter", () => { ); }); +describe("custom container blocks", () => { + const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + childBlocks: { min: 1 }, + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "box"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, + )(); + + const boxSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + box: Box, + }, + }); + + const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [ + { + type: "box", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + it("passes children to a custom container mapping", async () => { + const exporter = new DOCXExporter( + boxSchema, + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + box: ( + _block: any, + _exporter: any, + _nesting: any, + _index: any, + children: any, + ) => + new Paragraph({ + children: [new TextRun(`BOX(${children?.length ?? 0})`)], + }), + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const transformed = await exporter.transformBlocks(boxDocument as any); + expect(transformed).toHaveLength(1); + const xml = JSON.stringify(transformed[0]); + expect(xml).toContain("BOX(2)"); + }); + + it("throws a clear error for an unmapped container block", async () => { + const exporter = new DOCXExporter( + boxSchema, + docxDefaultSchemaMappings as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow( + /container block type "box"/, + ); + }); +}); + function prettify(sourceXml: string) { let ret = xmlFormat(sourceXml); diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index 6fce968a3e..bf4a991a44 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -115,7 +115,7 @@ export class DOCXExporter< for (const b of blocks) { let children = await this.transformBlocks(b.children, nestingLevel + 1); - if (!["columnList", "column"].includes(b.type)) { + if (!this.isContainerBlock(b.type)) { children = children.map((c, _i) => { // NOTE: nested tables not supported (we can't insert the new Tab before a table) if ( @@ -138,7 +138,7 @@ export class DOCXExporter< 0 /*unused*/, children, ); // TODO: any - if (["columnList", "column"].includes(b.type)) { + if (this.isContainerBlock(b.type)) { ret.push(self as Table); } else if (Array.isArray(self)) { ret.push(...self, ...children); diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx index 5f4eecf3c5..df9fafdf61 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx @@ -246,6 +246,24 @@ export class ReactEmailExporter< i = nextIndex; continue; } + if (this.isContainerBlock(b.type)) { + // Container blocks (columnList, column, custom containers): the + // mapping owns the placement of the children, so they are passed in + // and not rendered as an indented sibling list. + const containerChildren = await this.transformBlocks( + b.children, + nestingLevel + 1, + ); + const containerSelf = (await this.mapBlock( + b as any, + nestingLevel, + 0, + containerChildren as any, + )) as any; + ret.push({containerSelf}); + i++; + continue; + } // Non-list blocks const children = await this.transformBlocks(b.children, nestingLevel + 1); const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any; diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index 2e49261ec6..3b5f7fa6c1 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,28 +1,81 @@ +import { createBlockSpec } from "@blocknote/core"; + +import { ColumnResizeExtension } from "../../extensions/ColumnResize/ColumnResizeExtension.js"; import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js"; -import { Column } from "../../pm-nodes/Column.js"; -import { ColumnList } from "../../pm-nodes/ColumnList.js"; -import { createBlockSpecFromTiptapNode } from "@blocknote/core"; +const COLUMN_WIDTH_DEFAULT = 1; -export const ColumnBlock = createBlockSpecFromTiptapNode( +export const ColumnBlock = createBlockSpec( { - node: Column, - type: "column", + type: "column" as const, + propSchema: { + width: { + default: COLUMN_WIDTH_DEFAULT, + }, + }, content: "none", + childBlocks: { topLevel: false }, }, { - width: { - default: 1, + meta: { + exitOnEnter: false, + draggable: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.className = "bn-block-column"; + dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT); + + return { + dom, + contentDOM: dom, + update: (newNode: { + type: { name: string }; + attrs: { width?: number }; + }) => { + if (newNode.type.name !== "column") { + return false; + } + dom.style.flexGrow = String( + newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT, + ); + return true; + }, + }; }, }, - [MultiColumnDropHandlerExtension()], -); + [MultiColumnDropHandlerExtension(), ColumnResizeExtension()], +)(); -export const ColumnListBlock = createBlockSpecFromTiptapNode( +export const ColumnListBlock = createBlockSpec( { - node: ColumnList, - type: "columnList", + type: "columnList" as const, + propSchema: {}, content: "none", + childBlocks: { + allowedBlocks: ["column"], + min: 2, + collapseWhenEmptied: true, + }, + }, + { + meta: { + isolating: false, + draggable: false, + exitOnEnter: false, + }, + render: () => { + const dom = document.createElement("div"); + dom.className = "bn-block-column-list"; + dom.style.display = "flex"; + + return { + dom, + contentDOM: dom, + update: (newNode: { type: { name: string } }) => { + return newNode.type.name === "columnList"; + }, + }; + }, }, - {}, -); +)(); diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts index 50d95c1292..9ffe5de2a0 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"; @@ -356,12 +355,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/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts index 61defa7886..e5db7a8a9e 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts @@ -1,4 +1,8 @@ -import { type DropCursorHooks, getNearestBlockPos } from "@blocknote/core"; +import { + type DropCursorHooks, + getNearestBlockPos, + isContainerNode, +} from "@blocknote/core"; import type { EditorState } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; @@ -31,10 +35,16 @@ export function detectEdgePosition( const blockPos = getNearestBlockPos(state.doc, eventPos.pos); - // If we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it - // Why? Because we want to insert a new column in the columnList, instead of a new columnList inside of the column + // If we're at a block inside a column of a columnList, we want to compare + // the mouse position to the column, not the block inside it. + // Why? Because we want to insert a new sibling column in the columnList + // instead of a new container inside the column. let resolved = state.doc.resolve(blockPos.posBeforeNode); - if (resolved.parent.type.name === "column") { + if ( + isContainerNode(resolved.parent.type) && + resolved.depth > 0 && + state.doc.resolve(resolved.before()).parent.type.name === "columnList" + ) { resolved = state.doc.resolve(resolved.before()); } diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 7c8e0b312e..8796cbb051 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -3,6 +3,7 @@ import { UniqueID, createExtension, getBlockInfo, + isContainerNode, nodeToBlock, } from "@blocknote/core"; import { Plugin } from "prosemirror-state"; @@ -41,17 +42,30 @@ export function createMultiColumnHandleDropPlugin( view.state.doc, ); - if (blockInfo.blockNoteType === "column") { - // Insert new column in existing columnList - const parentBlock = view.state.doc - .resolve(blockInfo.bnBlock.beforePos) - .node(); + // Whether the edge target is a `columnList` (after `detectEdgePosition` + // hoisted blocks inside a column to the column itself, the target's + // parent is the columnList). + const $target = view.state.doc.resolve(blockInfo.bnBlock.beforePos); + const targetInHorizontalContainer = + $target.node().type.name === "columnList"; + + if (targetInHorizontalContainer) { + // Insert a new sibling child in the existing horizontal container + // (e.g. a new column in the columnList). + const parentBlock = $target.node(); const columnList = nodeToBlock( parentBlock, view.state.doc, ); + // Whether the horizontal container's children are typed child + // containers (like `column`) that wrap the actual blocks, or plain + // blocks spliced in directly. + const targetIsChildContainer = isContainerNode( + blockInfo.bnBlock.node.type, + ); + // Normalize column widths to average of 1 // In a `columnList`, we expect that the average width of each column // is 1. However, there are cases in which this stops being true. For @@ -59,24 +73,31 @@ export function createMultiColumnHandleDropPlugin( // the average width to go down. This isn't really an issue until the // user tries to add a new column, which will, in this case, be wider // than expected. Therefore, we normalize the column widths to an - // average of 1 here to avoid this issue. - let sumColumnWidthPercent = 0; - columnList.children.forEach((column) => { - sumColumnWidthPercent += column.props.width as number; - }); - const avgColumnWidthPercent = - sumColumnWidthPercent / columnList.children.length; - - // If the average column width is not 1, normalize it. We're dealing - // with floats so we need a small margin to account for precision - // errors. - if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { - const scalingFactor = 1 / avgColumnWidthPercent; - + // average of 1 here to avoid this issue. (Only applies to child + // containers with a numeric `width` prop, i.e. columns.) + if ( + columnList.children.every( + (column) => typeof column.props.width === "number", + ) + ) { + let sumColumnWidthPercent = 0; columnList.children.forEach((column) => { - column.props.width = - (column.props.width as number) * scalingFactor; + sumColumnWidthPercent += column.props.width as number; }); + const avgColumnWidthPercent = + sumColumnWidthPercent / columnList.children.length; + + // If the average column width is not 1, normalize it. We're + // dealing with floats so we need a small margin to account for + // precision errors. + if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { + const scalingFactor = 1 / avgColumnWidthPercent; + + columnList.children.forEach((column) => { + column.props.width = + (column.props.width as number) * scalingFactor; + }); + } } const index = columnList.children.findIndex( @@ -85,22 +106,36 @@ export function createMultiColumnHandleDropPlugin( const newChildren = columnList.children // If the dragged block is in one of the columns, remove it. - .map((column) => ({ - ...column, - children: column.children.filter( - (block) => block.id !== draggedBlock.id, - ), - })) + .map((column) => + targetIsChildContainer + ? { + ...column, + children: column.children.filter( + (block) => block.id !== draggedBlock.id, + ), + } + : column, + ) // Remove empty columns (can happen when dragged block is removed). - .filter((column) => column.children.length > 0) - // Insert the dragged block in the correct position. - .toSpliced(edgePos.position === "left" ? index : index + 1, 0, { - type: "column", - children: [draggedBlock], - props: {}, - content: undefined, - id: UniqueID.options.generateID(), - }); + .filter( + (column) => !targetIsChildContainer || column.children.length > 0, + ) + // Insert the dragged block in the correct position, wrapped in a + // new child container (e.g. a new `column`) when the container's + // children are typed containers. + .toSpliced( + edgePos.position === "left" ? index : index + 1, + 0, + targetIsChildContainer + ? { + type: blockInfo.blockNoteType, + children: [draggedBlock], + props: {}, + content: undefined, + id: UniqueID.options.generateID(), + } + : draggedBlock, + ); if (editor.getBlock(draggedBlock.id)) { editor.removeBlocks([draggedBlock]); 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 95% 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..a5d8ddf91f 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 > 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 > 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 > 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 > 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 > 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 > 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 > 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..7a49e97907 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", () => { 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", () => { 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", () => { 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..72b0f2d7ab 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..083e86c6ad 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/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index de9567c59e..c2ea317bff 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -126,7 +126,7 @@ export class ODTExporter< numberedListIndex = 0; } - if (["columnList", "column"].includes(block.type)) { + if (this.isContainerBlock(block.type)) { const children = await this.transformBlocks(block.children, 0); const content = await this.mapBlock( block as any, diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx index 6d8962eaaf..591e5db33e 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx @@ -156,7 +156,7 @@ export class PDFExporter< children, ); // TODO: any - if (["pageBreak", "columnList", "column"].includes(b.type)) { + if (b.type === "pageBreak" || this.isContainerBlock(b.type)) { ret.push(self); continue; } diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 85037877a4..798b380806 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1445,6 +1445,33 @@ export const examples = { readme: "In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)", }, + { + projectSlug: "container-block", + fullSlug: "custom-schema/container-block", + pathFromRoot: "examples/06-custom-schema/09-container-block", + config: { + playground: true, + docs: true, + author: "nickthesick", + tags: [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Container Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.\n\nThe block uses the new `childBlocks` config on `BlockConfig`. Setting `childBlocks: { defaultChildren: [{ type: "paragraph" }] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested block children directly — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.\n\nThe callout also has an editable **title**, demonstrating the complementary "string prop slot" pattern: content that doesn\'t need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`.\n\nWe also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the callout\'s body and add a code block, heading, or list — anything goes.\n- Type a title into the title field — it\'s stored on `block.props.title`, not as document content.\n- Watch the JSON panel on the right update as you edit; the callout\'s children appear in `block.children`.\n- Insert a new callout via the Slash Menu (search "callout").\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", @@ -1853,7 +1880,7 @@ export const examples = { tags: ["Extension"], pro: true, dependencies: { - "@tiptap/core": "^3.13.0", + "@tiptap/core": "^3.29.2", } as any, }, title: "TipTap extension (arrow InputRule)", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4afc0c985..9e77e5daeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3393,6 +3393,52 @@ importers: specifier: ^0.1.24 version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(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))(yaml@2.9.0) + examples/06-custom-schema/09-container-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) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(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-plus: + specifier: ^0.1.24 + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(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))(yaml@2.9.0) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': @@ -11568,6 +11614,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx index 71101c7fa7..eb99225aa5 100644 --- a/tests/src/unit/react/useNodeViewBlock.test.tsx +++ b/tests/src/unit/react/useNodeViewBlock.test.tsx @@ -27,8 +27,15 @@ const createReproBlock = createReactBlockSpec( { render: (props) =>

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

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