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..2fa4208d65 --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,120 @@ +--- +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`). | +| `repair` | — | Structural cleanup after children are removed: `removeEmptyChildren` drops emptied children, and `belowMin` (`"unwrap"` / `"remove"` / `"fill"`) decides what happens when fewer than `min` non-empty children remain. Column lists use `{ removeEmptyChildren: true, belowMin: "unwrap" }`. | + +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). | +| `childLayout` | `"vertical"` | Set `"horizontal"` for side-by-side children (like columns) — drives side menu positioning and edge-drop behavior. | +| `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, + repair: { removeEmptyChildren: true, belowMin: "unwrap" }, +} + +// 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..35eea18d72 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -0,0 +1,109 @@ +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 ( + // `ChildBlocksWrapper` is the root element for container blocks — it + // automatically applies the `data-node-type` / `data-id` / prop + // attributes BlockNote needs for HTML parsing and UI positioning. + + +
+ {/* 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..02b6161a2d 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -8,10 +8,14 @@ import type { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { getNodeById } from "../../../nodeUtil.js"; import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getPmSchema } from "../../../pmUtil.js"; -import { fixColumnList } from "./util/fixColumnList.js"; +import { + fixContainer, + isContainerNode, +} from "../../containers/fixContainer.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +26,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixContainers?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,7 +47,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 +91,15 @@ 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 (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if ( + isContainerNode(ancestor.type) && + ancestor.attrs.id && + !containersToFix.some((c) => c.id === ancestor.attrs.id) + ) { + containersToFix.push({ id: ancestor.attrs.id, depth }); + } } if ( @@ -119,11 +131,24 @@ 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). 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. Runs deepest-first, + // re-locating each container by id, so a repair that removes or unwraps a + // nested container is simply skipped at the ancestor level if the ancestor + // was affected (and ancestors are re-checked in their own pass). + if (options.fixContainers !== false) { + [...containersToFix] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (!target) { + // Already removed by a deeper repair. + return; + } + fixContainer(tr, target.posBeforeNode); + }); } // 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..772f8b34d8 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts @@ -0,0 +1,92 @@ +import { isContainerType } from "../../../schema/blocks/internal.js"; + +/** + * Schema-derived info about the container block types in an editor, consumed + * by UI code (side menu positioning, drag & drop). Container DOM always + * carries `data-node-type`, so container elements can be matched with CSS + * selectors built from the type names. + */ +export type ContainerUIInfo = { + /** All container block types (blocks holding child blocks directly). */ + containerTypes: ReadonlySet; + /** Container types whose children are laid out side-by-side. */ + horizontalContainerTypes: ReadonlySet; + /** Container types that get their own side-menu drag handle. */ + draggableContainerTypes: ReadonlySet; + /** Selector matching any container element, or null if there are none. */ + containerSelector: string | null; + /** Selector matching horizontal container elements, or null if none. */ + horizontalContainerSelector: string | null; +}; + +// Minimal structural view of the editor, to avoid depending on the full +// BlockNoteEditor type here. `blockSpecs` is a schema-specific mapped type +// on the editor, so it is accepted loosely and read defensively below. +type EditorWithSchema = { + schema: { + blockSpecs: any; + }; +}; + +const cache = new WeakMap(); + +function buildSelector(types: ReadonlySet): string | null { + if (types.size === 0) { + return null; + } + return [...types].map((type) => `[data-node-type="${type}"]`).join(","); +} + +/** + * Returns (and caches per editor) the container-type info derived from the + * editor's block schema. + */ +export function getContainerUIInfo(editor: EditorWithSchema): ContainerUIInfo { + const cached = cache.get(editor); + if (cached) { + return cached; + } + + const containerTypes = new Set(); + const horizontalContainerTypes = new Set(); + const draggableContainerTypes = new Set(); + + for (const [type, spec] of Object.entries( + editor.schema.blockSpecs as Record< + string, + { + config: any; + implementation?: { + meta?: { + childLayout?: "vertical" | "horizontal"; + draggable?: boolean; + }; + node?: { config?: { group?: string } }; + }; + } + >, + )) { + if (!isContainerType(editor.schema.blockSpecs, type)) { + continue; + } + + containerTypes.add(type); + const meta = spec.implementation?.meta; + if (meta?.childLayout === "horizontal") { + horizontalContainerTypes.add(type); + } + if (meta?.draggable !== false) { + draggableContainerTypes.add(type); + } + } + + const info: ContainerUIInfo = { + containerTypes, + horizontalContainerTypes, + draggableContainerTypes, + containerSelector: buildSelector(containerTypes), + horizontalContainerSelector: buildSelector(horizontalContainerTypes), + }; + cache.set(editor, info); + return info; +} 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..0abf0ba2eb --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,538 @@ +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, + repair: { + removeEmptyChildren: true, + belowMin: "unwrap", + }, + }, + }, + { + meta: { + childLayout: "horizontal", + }, + 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..2622cac7ed --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,247 @@ +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 } from "../../../schema/blocks/internal.js"; +import { getNodeById } from "../../nodeUtil.js"; +import { getBlockSchema, getPmSchema } from "../../pmUtil.js"; + +/** + * Whether a PM node type is a container block node — a `bnBlock` that holds + * child blocks directly (e.g. `column`, `columnList`, or any block declaring + * `childBlocks`). `blockGroup` shares the `childContainer` group (it's the + * child-holding node of regular indentation) but is not a container block. + */ +export function isContainerNode(type: NodeType): boolean { + return type.isInGroup("childContainer") && type.name !== "blockGroup"; +} + +/** + * Checks whether a direct child of a container is "empty": + * - a `blockContainer` holding a single empty paragraph and nothing else; + * - a nested container whose single child is itself empty. + * + * (A child holding several blocks is never considered empty, even if each of + * them is — collapsing multi-block structure the user built up 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; +} + +/** + * Removes all empty children (see `isEmptyContainerChild`) of the container + * node at `containerPos`. If the removals leave the container below its + * schema minimum, ProseMirror re-adds empty children to fit the schema — + * `fixContainer` detects that state via the non-empty child count. + * @param tr The `Transaction` to add the changes to. + * @param containerPos The position just before the container node. + */ +export function removeEmptyChildren(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + 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); + } + } +} + +// A container child is directly insertable next to the container itself when +// its node can sit anywhere a regular block goes. Children that can't +// (`topLevel: false` containers, like `column`) are flattened into *their* +// children when a container is unwrapped. +function isInsertableChild(node: Node): boolean { + return ( + node.type.name === "blockContainer" || + node.type.isInGroup("blockGroupChild") + ); +} + +/** + * Repairs the container node at `containerPos` after children were (re)moved + * from it, according to the block's `childBlocks.repair` config: + * + * - When `repair.removeEmptyChildren` is set, drops empty children. If that + * leaves fewer than `min` non-empty children (ProseMirror pads the + * container back up to `min` with empty ones, so the *total* count never + * drops), applies `repair.belowMin`: + * - `"unwrap"` (default) — replaces the container with its remaining + * non-empty children (non-top-level container children are flattened + * into their own children), or deletes it when none remain; + * - `"remove"` — deletes the container; + * - `"fill"` — keeps the container with its padded empty children. + * - Containers without `repair.removeEmptyChildren` are left untouched: + * ProseMirror's schema fitting already guarantees they satisfy `min`. + * + * This generalizes what `fixColumnList` did for column lists (which set + * `removeEmptyChildren: true` + `belowMin: "unwrap"`). + * @param tr The `Transaction` to add the changes to. + * @param containerPos The position just before the container node. + */ +export function fixContainer(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + const blockConfig = getBlockSchema(getPmSchema(tr))[container.type.name]; + const config = blockConfig ? getChildBlocksConfig(blockConfig) : undefined; + + if (!config?.repair?.removeEmptyChildren) { + return; + } + + removeEmptyChildren(tr, containerPos); + + const refreshed = tr.doc.resolve(containerPos).nodeAfter; + if (!refreshed || refreshed.type !== container.type) { + // The container itself disappeared as a side effect of the deletions + // (shouldn't happen, but there is nothing left to repair). + return; + } + + const min = config.min ?? 1; + + const nonEmptyChildren: { child: Node; offset: number }[] = []; + refreshed.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + nonEmptyChildren.push({ child, offset }); + } + }); + + if (nonEmptyChildren.length >= min) { + return; + } + + const belowMin = config.repair.belowMin ?? "unwrap"; + + if (belowMin === "fill") { + return; + } + + if (belowMin === "remove" || nonEmptyChildren.length === 0) { + tr.delete(containerPos, containerPos + refreshed.nodeSize); + return; + } + + // "unwrap": replace the container with its remaining non-empty children. + if (nonEmptyChildren.length === 1) { + // Single survivor: move its content out with a `ReplaceAroundStep` so the + // content is mapped (moved) rather than deleted-and-recreated — this + // keeps cursor positions and collaborative rebasing stable. + const { child, offset } = nonEmptyChildren[0]; + const childStart = containerPos + 1 + offset; + + const [gapFrom, gapTo] = isInsertableChild(child) + ? // The child node itself can go where the container was. + [childStart, childStart + child.nodeSize] + : // The child (e.g. a `column`) can't — its *content* can. + [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` (only possible when `min` > 2): + // no single gap covers them, so rebuild the 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 a set of containers identified by id, deepest + * first. Containers are re-located by id before each repair, since a repair + * (or the caller's preceding mutations) shifts positions — a container that + * an earlier repair removed is skipped. + * @param tr The `Transaction` to add the changes to. + * @param containers The containers to repair, with the depth they were + * originally found at (used only for ordering). + */ +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); + }); +} + +/** + * Replaces blocks that cannot be inserted at the top level of a document + * region (containers with `childBlocks.topLevel: false`, like `column`) with + * their children, recursively. Used when moving/copying blocks out of a + * container into a regular block position. + */ +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..f04663ae1d 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -8,7 +8,9 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { camelToDataKebab } from "../../../../util/string.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; +import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js"; import { inlineContentToNodes, tableContentToNodes, @@ -270,6 +272,29 @@ 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. + const dom = ret.dom as HTMLElement; + if (!dom.hasAttribute("data-node-type")) { + dom.setAttribute("data-node-type", block.type!); + } + const propSchema = + editor.schema.blockSchema[block.type as any].propSchema; + for (const [propName, value] of Object.entries(props)) { + const attrName = camelToDataKebab(propName); + if ( + value !== (propSchema as any)[propName]?.default && + !dom.hasAttribute(attrName) + ) { + dom.setAttribute(attrName, String(value)); + } + } + } 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..f765a3f183 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -7,7 +7,9 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { camelToDataKebab } from "../../../../util/string.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; +import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js"; import { inlineContentToNodes, tableContentToNodes, @@ -172,7 +174,26 @@ 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. + const dom = ret.dom as HTMLElement; + if (!dom.hasAttribute("data-node-type")) { + dom.setAttribute("data-node-type", block.type!); + } + const propSchema = editor.schema.blockSchema[block.type as any].propSchema; + for (const [propName, value] of Object.entries(props)) { + const attrName = camelToDataKebab(propName); + if ( + value !== (propSchema as any)[propName]?.default && + !dom.hasAttribute(attrName) + ) { + dom.setAttribute(attrName, String(value)); + } + } + 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..7e71edbe32 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -16,10 +16,16 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +import { getChildBlocksConfig } from "../../schema/blocks/internal.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 +336,8 @@ function blockOrInlineContentToContentNode( return contentNode; } +const EMPTY_SEEDING: ReadonlySet = new Set(); + /** * Converts a BlockNote block to a Prosemirror node. */ @@ -337,6 +345,11 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + // Internal: container block types whose `defaultChildren` are currently + // being seeded further up the recursion. Used to fail a self-referential + // `defaultChildren` config with a clear error instead of a stack overflow. + // Not part of the public API. + seedingTypes: ReadonlySet = EMPTY_SEEDING, ) { let id = block.id; @@ -348,7 +361,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)); } } @@ -377,7 +390,55 @@ export function blockToNode( }, groupNode ? [contentNode, groupNode] : contentNode, ); - } else if (schema.nodes[block.type].isInGroup("bnBlock")) { + } else if (isContainerNode(schema.nodes[block.type])) { + // this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node + let effectiveChildren = children; + + // Seed `defaultChildren` for container blocks when no children would + // otherwise be present (covers both `block.children === undefined` and + // `block.children === []`, e.g. converting a leaf block into a container). + if (children.length === 0) { + const blockSchemaConfig = getBlockSchema(schema)[block.type]; + const childBlocksConfig = blockSchemaConfig + ? getChildBlocksConfig(blockSchemaConfig) + : undefined; + const defaultChildren = childBlocksConfig?.defaultChildren; + // Only seed to satisfy a positive `min`. A `min: 0` container is allowed + // to be empty, so it must NOT be re-populated on a round-trip — + // `nodeToBlock` emits `children: []` for any childless container, which + // would otherwise re-seed it every time it passes through here. + if ( + defaultChildren && + defaultChildren.length > 0 && + (childBlocksConfig!.min ?? 1) > 0 + ) { + // A `defaultChildren` that (transitively) seeds its own type would + // recurse forever; fail with a clear error instead of overflowing the + // stack. `validateChildBlocks` only checks types/cardinality, not this. + if (seedingTypes.has(block.type!)) { + throw new Error( + `Container block "${block.type}" has a \`defaultChildren\` cycle (seeding it requires seeding itself). Give the cyclic default explicit children, or remove the self-reference.`, + ); + } + const nextSeeding = new Set(seedingTypes).add(block.type!); + effectiveChildren = defaultChildren.map((child) => + blockToNode( + child as PartialBlock, + schema, + styleSchema, + nextSeeding, + ), + ); + return schema.nodes[block.type].createChecked( + { + id: id, + ...block.props, + }, + effectiveChildren, + ); + } + } + // `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 +446,7 @@ export function blockToNode( id: id, ...block.props, }, - children, + effectiveChildren, ); } else { throw new Error( diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..1a0eeca7db 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,10 +1,13 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, + getChildBlocksConfig, } from "../../schema/index.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; +import { getBlockSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; /** @@ -18,6 +21,32 @@ export function fragmentToBlocks< // first convert selection to blocknote-style blocks, and then // pass these to the exporter const blocks: BlockNoDefaults[] = []; + + // Pushes a bnBlock node as a block, flattening containers that shouldn't + // surface on their own: containers with fewer children than their `min` + // (e.g. a single selected column of a columnList — the user selected + // content within the container, not the container itself) and containers + // that can't stand outside their parent (`topLevel: false`, e.g. a + // `column`). + const pushFlattened = (node: Node, root: Node) => { + const config = getChildBlocksConfig( + getBlockSchema(node.type.schema)[node.type.name] ?? {}, + ); + const belowMin = + isContainerNode(node.type) && + config !== undefined && + node.childCount < (config.min ?? 1); + const nonInsertable = + isContainerNode(node.type) && !node.type.isInGroup("blockGroupChild"); + + if (belowMin || nonInsertable) { + 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") { @@ -44,16 +73,8 @@ export function fragmentToBlocks< } } - if (node.type.name === "columnList" && node.childCount === 1) { - // column lists with a single column should be flattened (not the entire column list has been selected) - node.firstChild?.forEach((child) => { - blocks.push(nodeToBlock(child, node)); - }); - return false; - } - if (node.type.isInGroup("bnBlock")) { - blocks.push(nodeToBlock(node, node)); + pushFlattened(node, node); // don't descend into children, as they're already included in the block returned by nodeToBlock return false; } 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..5208af8173 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/internal.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.keys(editor.schema.blockSpecs).filter((type) => + isContainerType(editor.schema.blockSpecs, 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..00c8a3dcbc 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -8,6 +8,7 @@ import { StyleSchema, StyledText, Styles, + isContainerType, } from "../schema/index.js"; import type { @@ -44,15 +45,32 @@ 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 { + return isContainerType(this.blockNoteSchema.blockSpecs, blockType); + } public async resolveFile(url: string) { if (!this.options?.resolveFileUrl) { @@ -92,12 +110,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..b9d50bdbfe 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -20,6 +20,10 @@ 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"; @@ -37,7 +41,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,9 +51,17 @@ function getBlockFromCoords( // probably a ui overlay like formatting toolbar etc continue; } - if (adjustForColumns) { - const column = element.closest("[data-node-type=columnList]"); - if (column) { + if ( + adjustForHorizontalContainers && + containerUIInfo.horizontalContainerSelector + ) { + // 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. + const horizontalContainer = element.closest( + containerUIInfo.horizontalContainerSelector, + ); + if (horizontalContainer) { return getBlockFromCoords( view, { @@ -56,21 +69,70 @@ function getBlockFromCoords( 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; } +/** + * 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. + */ +function getContainerChildAtCursor( + element: Element, + mousePos: { x: number; y: number }, + containerUIInfo: ContainerUIInfo, +): Element | undefined { + const nodeType = element.getAttribute("data-node-type"); + if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) { + return undefined; + } + + const childSelector = containerUIInfo.containerSelector + ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` + : `[data-node-type="blockContainer"]`; + + let verticalMatch: Element | undefined = undefined; + for (const child of element.querySelectorAll(childSelector)) { + // Direct children only (in the block sense): the closest block element + // above the candidate must be the container itself. + if (child.parentElement?.closest(childSelector) !== element) { + continue; + } + 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; +} + function getBlockFromMousePos( mousePos: { x: number; 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 +156,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 +171,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 +287,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 +317,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 +340,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/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..d2c4682159 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, repair: { removeEmptyChildren: true, belowMin: "unwrap" } } 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 `repair` 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/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 5db7ff48eb..3a6254b4e3 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -6,6 +6,8 @@ import { TagParseRule, } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; +import { isContainerNode } from "../../api/blockManipulation/containers/fixContainer.js"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { @@ -13,9 +15,12 @@ import { ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + childBlocksContentExpression, getBlockFromNodeView, + getChildBlocksConfig, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; @@ -25,6 +30,7 @@ import { BlockImplementation, BlockImplementationOrCreator, BlockSpec, + ChildBlocksConfig, LooseBlockSpec, } from "./types.js"; @@ -167,6 +173,139 @@ export function getParseRules< return rules; } +function buildContainerNode( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + childBlocksConfig: ChildBlocksConfig, + isContainerType?: (type: string) => boolean, +) { + return Node.create({ + name: blockConfig.type, + content: childBlocksContentExpression(childBlocksConfig, isContainerType), + group: + childBlocksConfig.topLevel === false + ? "bnBlock childContainer" + : "bnBlock childContainer blockGroupChild", + // All bnBlock-group structural nodes allow the block-level suggestion marks + // (see Doc, BlockGroup, BlockContainer, Table), so a whole container can be + // marked inserted/deleted/modified in suggestion mode. Resolved + // conditionally so a plain editor without those marks doesn't reference an + // unknown mark group. + marks() { + return suggestionMarks(this.editor); + }, + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + defining: true, + // Hardcoded priority 40 (matches the historical Column/ColumnList shape). + // Why hardcoded and ignoring the caller-supplied priority? Because PM's + // `fillBefore` picks the FIRST type in an or-expression / group when + // auto-filling a non-optional node. With `blockGroupChild+` content + // (which includes containers themselves), if a container appeared first + // in the schema's `nodes` map, PM would try to auto-fill empty + // containers with another container and stack-overflow. We need + // `blockContainer` (priority 50, registered later) to come BEFORE + // container blocks in the schema map. Tiptap registers higher-priority + // extensions earlier, so we want our priority to be LOWER than 50. + // The schema layer passes its own per-block priority (~101) but we + // override it here for cycle-safety. + priority: 40, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + const rules: TagParseRule[] = [ + { + tag: "*", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } + if (element.getAttribute("data-node-type") === blockConfig.type) { + return {}; + } + return false; + }, + }, + ]; + + if (blockImplementation.parse) { + rules.push({ + tag: "*", + getAttrs(node) { + if (typeof node === "string") { + return false; + } + const props = blockImplementation.parse?.(node); + if (props === undefined) { + return false; + } + return props; + }, + preserveWhitespace: true, + }); + } + + return rules; + }, + + renderHTML({ HTMLAttributes }) { + const div = document.createElement("div"); + div.setAttribute("data-node-type", blockConfig.type); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + div.setAttribute(attribute, value as any); + } + return { + dom: div, + contentDOM: div, + }; + }, + + addNodeView() { + return (props) => { + const editor = this.options.editor; + // For container blocks the PM node IS the bnBlock (no blockContainer + // wrapper), so the id lives on `props.node.attrs.id` directly. We + // can't use getBlockFromPos here because it walks up to the parent. + const blockIdentifier = (props.node.attrs as Record).id; + if (!blockIdentifier) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute. Make sure it is registered with UniqueID.`, + ); + } + const block = + editor.getBlock(blockIdentifier) ?? + nodeToBlock(props.node, editor.prosemirrorView.state.doc); + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + const nodeView = blockImplementation.render.call( + { + blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + editor as any, + ) as unknown as NodeView; + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(nodeView, this.editor); + } + + // 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(nodeView); + + return nodeView; + }; + }, + }); +} + // 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< @@ -178,120 +317,145 @@ export function addNodeAndExtensionsToSpec< blockImplementation: BlockImplementation, extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, + // Resolves whether a block type is a container-type block. Provided by + // schema creation (which knows the full spec set); required to resolve + // `childBlocks.allowedBlocks` into a content expression. + isContainerType?: (type: string) => boolean, ): 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); - }, + // Resolve the `childBlocks: true` shorthand at read time — the user's + // config object is never mutated (see `getChildBlocksConfig`). + const childBlocksConfig = getChildBlocksConfig(blockConfig); - parseHTML() { - return getParseRules(blockConfig, blockImplementation); - }, + 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"\`.`, + ); + } - 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, + const node = + ((blockImplementation as any).node as Node) || + (childBlocksConfig + ? buildContainerNode( + blockConfig as unknown as BlockConfig, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "none" + >, + childBlocksConfig, + isContainerType, + ) + : 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); }, - 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 || {}; - const nodeView = blockImplementation.render.call( - { - blockContentDOMAttributes, - props, - renderType: "nodeView", - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ); + parseHTML() { + return getParseRules(blockConfig, blockImplementation); + }, - // Cast needed because render returns `dom: HTMLElement | DocumentFragment` - // but tiptap's NodeView expects `dom: HTMLElement` - const typedNodeView = nodeView as unknown as NodeView; + 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, + ); + }, - if (blockImplementation.meta?.selectable === false) { - applyNonSelectableBlockFix(typedNodeView, this.editor); - } + addNodeView() { + return (props) => { + // Gets the BlockNote editor instance + const editor = this.options.editor; + // Gets the block. Resolving this can't rely on `getPos()` alone — + // node views are constructed part-way through ProseMirror's + // reconciliation, where positions don't always line up with + // `view.state.doc` yet (see `getBlockFromNodeView`). + const block = getBlockFromNodeView( + props.getPos, + props.node, + props.view.state.doc, + ); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + const nodeView = blockImplementation.render.call( + { + blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + editor as any, + ); + + // Cast needed because render returns `dom: HTMLElement | DocumentFragment` + // but tiptap's NodeView expects `dom: HTMLElement` + const typedNodeView = nodeView as unknown as NodeView; + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, this.editor); + } - // 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; - }; - }, - }); + // 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 (node.name !== blockConfig.type) { throw new Error( @@ -471,6 +635,12 @@ export function createBlockSpec< return undefined; } + // Container blocks own their outer DOM entirely (the PM node IS + // the bnBlock — no `blockContent` wrapper) — pass through. + if (isContainerNode(editor.pmSchema.nodes[block.type])) { + return output; + } + return wrapInBlockStructure( output, block.type, @@ -490,6 +660,10 @@ export function createBlockSpec< editor as any, ); + if (isContainerNode(editor.pmSchema.nodes[block.type])) { + 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..e60f9354b1 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,95 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; -import { LooseBlockSpec } from "./types.js"; +import { BlockConfig, ChildBlocksConfig, LooseBlockSpec } from "./types.js"; + +// 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: Pick, +): ChildBlocksConfig | undefined { + return config.childBlocks === true ? {} : config.childBlocks; +} + +// Whether a block *type* is a container block, resolved from the block spec +// set alone (no compiled PM schema needed) — a type is a container if it +// declares `childBlocks`, or if its hand-written tiptap node is in the +// `bnBlock` group. Use this when you only hold the spec set (schema +// construction, the exporter); when you hold a live PM `NodeType` use +// `isContainerNode`, and when you already have the block config use +// `getChildBlocksConfig`. +export function isContainerType( + // Loosely typed: the block spec's tiptap `node` has a complex tiptap type + // that doesn't structurally match a narrow shape, and only `config` / + // `node.config.group` are read defensively here. + blockSpecs: Record, + type: string, +): boolean { + const spec = blockSpecs[type]; + if (!spec) { + return false; + } + if (getChildBlocksConfig(spec.config)) { + return true; + } + const group = spec.implementation?.node?.config?.group; + return typeof group === "string" && group.split(/\s+/).includes("bnBlock"); +} + +// 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( + config: ChildBlocksConfig, + // Resolves whether a block type is a container-type block (its own bnBlock + // PM node). Provided by schema creation, which knows the full spec set. + isContainerType?: (type: string) => boolean, +): string { + let term = "blockGroupChild"; + + if (config.allowedBlocks && config.allowedBlocks.length > 0) { + if (!isContainerType) { + throw new Error( + "`childBlocks.allowedBlocks` requires full-schema context to resolve. " + + "Blocks using it must be registered through `BlockNoteSchema`.", + ); + } + const containerTypes = [ + ...new Set(config.allowedBlocks.filter((type) => isContainerType(type))), + ]; + const hasRegularTypes = containerTypes.length < config.allowedBlocks.length; + const terms = [ + ...(hasRegularTypes ? ["blockContainer"] : []), + ...containerTypes, + ]; + term = terms.length === 1 ? terms[0] : `(${terms.join(" | ")})`; + } + + const min = config.min; + const max = config.max; + + if (max !== undefined) { + const effectiveMin = min ?? 1; + return `${term}{${effectiveMin},${max}}`; + } + if (min === 0) { + return `${term}*`; + } + if (min === undefined || min === 1) { + return `${term}+`; + } + return `${term}{${min},}`; +} // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -232,6 +320,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 +337,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..5d32760a22 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,105 @@ 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; + + /** + * Only applies to container blocks (blocks with `childBlocks`): how the + * container visually lays out its children. `"horizontal"` (side-by-side, + * like a column list) drives UI behavior — side menu positioning and + * edge-drop handling — and is never consulted by the document model. + * @default "vertical" + */ + childLayout?: "vertical" | "horizontal"; } +/** + * 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; + /** + * Structural policy applied by `fixContainer` after child removal (e.g. + * Backspace merging a child out, or `replaceBlocks` deleting children). + * Coupled to `min`/`max` — the policy is meaningless without them, so it + * lives here rather than in `meta`. Without `removeEmptyChildren`, repair + * is a no-op: ProseMirror's schema fitting always pads a container back up + * to `min` with empty children, so a container can only be detected as + * "effectively below min" by discounting empty children. + */ + repair?: { + /** + * Whether repair drops empty children (a child holding nothing but a + * single empty paragraph, possibly through nested containers). Column + * lists use this so emptied columns disappear. + * @default false + */ + removeEmptyChildren?: boolean; + /** + * What to do when, after removing empty children, fewer than `min` + * non-empty children remain (ProseMirror pads the container back up to + * `min` with empty ones, so the *total* count never drops below `min`): + * - `"unwrap"` — replace the container with its remaining non-empty + * children (children that are themselves non-top-level containers are + * flattened into *their* children); the container is removed entirely + * when none remain. + * - `"remove"` — delete the container. + * - `"fill"` — keep the container with its padded empty children. + * @default "unwrap" + */ + belowMin?: "unwrap" | "remove" | "fill"; + }; +}; + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -87,8 +184,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 +315,7 @@ export type LooseBlockSpec< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -268,6 +374,7 @@ export type BlockSpecs = { contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -553,6 +660,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..b8a64de64e --- /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"; + +const paragraph = { config: { type: "paragraph", content: "inline" } } as any; + +function specsWith(containers: Record) { + return { + paragraph, + ...Object.fromEntries( + Object.entries(containers).map(([type, { childBlocks, meta }]) => [ + type, + { + config: { type, content: "none", childBlocks }, + implementation: meta ? { meta } : {}, + }, + ]), + ), + }; +} + +function isContainerType(specs: Record) { + return (type: string) => specs[type]?.config?.childBlocks !== undefined; +} + +describe("validateChildBlocksConfigs", () => { + it("accepts a plain container config", () => { + const specs = specsWith({ callout: { childBlocks: { min: 1 } } }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).not.toThrow(); + }); + + it("accepts the columnList shape (restricted children, min 2)", () => { + const specs = specsWith({ + grid: { childBlocks: { allowedBlocks: ["gridCell"], min: 2 } }, + gridCell: { childBlocks: { topLevel: false } }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).not.toThrow(); + }); + + it("rejects unknown allowedBlocks entries", () => { + const specs = specsWith({ + grid: { childBlocks: { allowedBlocks: ["doesNotExist"] } }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/doesNotExist/); + }); + + it("rejects negative or non-integer min", () => { + const specs = specsWith({ callout: { childBlocks: { min: -1 } } }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/min/); + }); + + it("rejects max smaller than min", () => { + const specs = specsWith({ callout: { childBlocks: { min: 3, max: 2 } } }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/max/); + }); + + it("rejects defaultChildren violating min/max", () => { + const specs = specsWith({ + callout: { + childBlocks: { + min: 2, + defaultChildren: [{ type: "paragraph" }], + }, + }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/defaultChildren/); + }); + + it("rejects defaultChildren of unknown types", () => { + const specs = specsWith({ + callout: { + childBlocks: { defaultChildren: [{ type: "doesNotExist" }] }, + }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/doesNotExist/); + }); + + it("rejects defaultChildren not allowed by allowedBlocks", () => { + const specs = specsWith({ + grid: { + childBlocks: { + allowedBlocks: ["gridCell"], + min: 1, + defaultChildren: [{ type: "paragraph" }], + }, + }, + gridCell: { childBlocks: { topLevel: false } }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/not allowed/); + }); +}); diff --git a/packages/core/src/schema/blocks/validateChildBlocks.ts b/packages/core/src/schema/blocks/validateChildBlocks.ts new file mode 100644 index 0000000000..a965e3f15c --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildBlocks.ts @@ -0,0 +1,92 @@ +import { getChildBlocksConfig } from "./internal.js"; +import { BlockConfig } from "./types.js"; + +type SpecLike = { + config: BlockConfig; +}; + +/** + * Validates every `childBlocks` config in a spec set when a schema is + * created, so misconfigurations fail with a clear message at build time + * instead of surfacing as opaque ProseMirror errors deep inside schema + * compilation or document mutations. Kept to the cheap correctness guards + * only — it does not try to robustly detect fill cycles (a custom-schema dev + * foot-gun, out of scope). + */ +export function validateChildBlocksConfigs( + blockSpecs: Record, + isContainerType: (type: string) => boolean, +): void { + for (const [type, spec] of Object.entries(blockSpecs)) { + const config = getChildBlocksConfig(spec.config); + + if (!config) { + continue; + } + + const min = config.min ?? 1; + const max = config.max ?? Infinity; + + if (!Number.isInteger(min) || min < 0) { + throw new Error( + `Block "${type}": \`childBlocks.min\` must be a non-negative integer, got ${config.min}.`, + ); + } + if (config.max !== undefined && (!Number.isInteger(max) || max < 1)) { + throw new Error( + `Block "${type}": \`childBlocks.max\` must be a positive integer, got ${config.max}.`, + ); + } + if (max < min) { + throw new Error( + `Block "${type}": \`childBlocks.max\` (${max}) is smaller than \`min\` (${min}).`, + ); + } + + for (const allowed of config.allowedBlocks ?? []) { + if (!(allowed in blockSpecs)) { + throw new Error( + `Block "${type}": \`childBlocks.allowedBlocks\` entry "${allowed}" does not exist in the schema.`, + ); + } + } + + if (config.defaultChildren) { + if ( + config.defaultChildren.length < min || + config.defaultChildren.length > max + ) { + throw new Error( + `Block "${type}": \`childBlocks.defaultChildren\` has ${config.defaultChildren.length} entries, which does not satisfy min ${min}` + + (config.max !== undefined ? ` / max ${max}` : "") + + `.`, + ); + } + for (const child of config.defaultChildren) { + const childType = child.type ?? "paragraph"; + if (!(childType in blockSpecs)) { + throw new Error( + `Block "${type}": \`childBlocks.defaultChildren\` entry type "${childType}" does not exist in the schema.`, + ); + } + if (config.allowedBlocks && config.allowedBlocks.length > 0) { + // Mirror the node-level enforcement: container types must be listed + // explicitly; regular types pass if any regular type is allowed. + const allowedContainerTypes = config.allowedBlocks.filter((t) => + isContainerType(t), + ); + const allowsRegularTypes = + allowedContainerTypes.length < config.allowedBlocks.length; + const isAllowed = isContainerType(childType) + ? config.allowedBlocks.includes(childType) + : allowsRegularTypes; + if (!isAllowed) { + throw new Error( + `Block "${type}": \`childBlocks.defaultChildren\` entry type "${childType}" is not allowed by \`allowedBlocks\` [${config.allowedBlocks.join(", ")}].`, + ); + } + } + } + } + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..5aec783978 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,6 +1,7 @@ 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..c4f45e454c 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -16,6 +16,8 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "./index.js"; +import { isContainerType } from "./blocks/internal.js"; +import { validateChildBlocksConfigs } from "./blocks/validateChildBlocks.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -91,6 +93,17 @@ export class CustomBlockNoteSchema< })), ); + // Resolves whether a block type is a container-type block, bound to this + // schema's spec set (see `isContainerType`). Threaded into the spec + // machinery as a single-arg callback. + const resolveIsContainerType = (type: string): boolean => + isContainerType(this.opts.blockSpecs as any, type); + + validateChildBlocksConfigs( + this.opts.blockSpecs as any, + resolveIsContainerType, + ); + const blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { return [ @@ -100,6 +113,7 @@ export class CustomBlockNoteSchema< blockSpec.implementation, blockSpec.extensions, getPriority(key), + resolveIsContainerType, ), ]; }), 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..a742a886a5 --- /dev/null +++ b/packages/react/src/schema/ChildBlocksWrapper.tsx @@ -0,0 +1,57 @@ +import { BlockNoteEditor, camelToDataKebab } from "@blocknote/core"; +import { NodeViewWrapper } from "@tiptap/react"; +import { HTMLAttributes, ReactNode } from "react"; + +/** + * The root element for a container block's `render`. Container blocks own + * their outer DOM entirely (the framework doesn't wrap them in a + * `blockContent` div), so their root element must carry the attributes + * BlockNote relies on for HTML parsing and UI positioning. This component + * applies them automatically: + * + * - `data-node-type` — keys the block's HTML parse rule and the side menu / + * drag-and-drop selectors. + * - `data-id` — the block's id. + * - each non-default prop as a kebab-cased `data-*` attribute, so props + * round-trip through HTML serialization. + * + * Any other props (`className`, event handlers, additional attributes) are + * spread onto the element; explicitly passed attributes win over the + * generated ones. + */ +export function ChildBlocksWrapper( + props: { + block: { + id: string; + type: string; + props: Record; + }; + editor: BlockNoteEditor; + children: ReactNode; + } & Omit, "children">, +) { + const { block, editor, children, ...rest } = props; + + const propSchema = + (editor.schema.blockSchema as Record)[block.type] + ?.propSchema ?? {}; + + // Non-default props as kebab-cased `data-*` attributes — the same + // convention `propsToAttributes` parses back out of pasted/exported HTML. + const propAttributes = Object.fromEntries( + Object.entries(block.props ?? {}) + .filter(([prop, value]) => value !== propSchema[prop]?.default) + .map(([prop, value]) => [camelToDataKebab(prop), value]), + ); + + return ( + + {children} + + ); +} 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..446cde75a3 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -10,7 +10,9 @@ import { Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, + isContainerNode, 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,35 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { + const isContainer = isContainerNode( + editor.pmSchema.nodes[block.type], + ); 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 +289,141 @@ 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 = isContainerNode( + editor.pmSchema.nodes[blockConfig.type], + ); + 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 = isContainerNode( + editor.pmSchema.nodes[block.type], + ); + 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..edcdd12e50 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,28 +1,142 @@ +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"; +// 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 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 others. The same can be said for +// using px instead of percent widths and making them add to the editor width. +// Using flex-grow on the value handles all the resizing for us, instead of +// manually having to set `width` on each column. +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", + // Columns only ever live inside a `columnList` (whose content expression + // is `column column+`). `topLevel: false` keeps column out of the + // generic `blockGroupChild` group so it can't be inserted at the document + // root or as a child of any other block. + childBlocks: { topLevel: false }, }, { - width: { - default: 1, + meta: { + // Enter on an empty last block stays inside the column (the generic + // container default would move it out below the columnList). + exitOnEnter: false, + // Columns are never dragged individually — whole columnLists are + // rearranged via column-specific drag handling instead. + draggable: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.className = "bn-block-column"; + const width = block.props.width ?? COLUMN_WIDTH_DEFAULT; + dom.style.flexGrow = String(width); + dom.setAttribute("data-node-type", "column"); + dom.setAttribute("data-id", block.id); + if (width !== COLUMN_WIDTH_DEFAULT) { + dom.setAttribute("data-width", String(width)); + } + + return { + dom, + contentDOM: dom, + update: (newNode: { + type: { name: string }; + attrs: { id?: string; width?: number }; + }) => { + if (newNode.type.name !== "column") { + return false; + } + const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT; + dom.style.flexGrow = String(newWidth); + if (newWidth !== COLUMN_WIDTH_DEFAULT) { + dom.setAttribute("data-width", String(newWidth)); + } else { + dom.removeAttribute("data-width"); + } + if (newNode.attrs.id) { + dom.setAttribute("data-id", newNode.attrs.id); + } else { + dom.removeAttribute("data-id"); + } + return true; + }, + }; }, }, - [MultiColumnDropHandlerExtension()], -); + [MultiColumnDropHandlerExtension(), ColumnResizeExtension()], +)(); -export const ColumnListBlock = createBlockSpecFromTiptapNode( +export const ColumnListBlock = createBlockSpec( { - node: ColumnList, - type: "columnList", + type: "columnList" as const, + propSchema: {}, content: "none", + // Generates the `column{2,}` content expression (equivalent to the + // previous hand-written node's `column column+`) and drives the generic + // container machinery: emptied columns are removed on repair, and a + // columnList left with fewer than two non-empty columns is replaced by + // the surviving column's content. + childBlocks: { + allowedBlocks: ["column"], + min: 2, + repair: { + removeEmptyChildren: true, + belowMin: "unwrap", + }, + }, + }, + { + meta: { + // Preserved from the hand-written ColumnList node (which used the PM + // default); container blocks otherwise default to `isolating: true`. + isolating: false, + // Columns are laid out side-by-side — drives side menu positioning + // and edge-drop behavior. + childLayout: "horizontal", + // Whole-columnList dragging stays disabled (matches previous + // behavior; columns are rearranged via column-specific drag handling). + draggable: false, + // Enter on an empty last block stays inside the column list's columns. + exitOnEnter: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.className = "bn-block-column-list"; + dom.setAttribute("data-node-type", "columnList"); + dom.setAttribute("data-id", block.id); + dom.style.display = "flex"; + + return { + dom, + contentDOM: dom, + update: (newNode: { + type: { name: string }; + attrs: { id?: string }; + }) => { + if (newNode.type.name !== "columnList") { + return false; + } + if (newNode.attrs.id) { + dom.setAttribute("data-id", newNode.attrs.id); + } else { + dom.removeAttribute("data-id"); + } + return true; + }, + }; + }, }, - {}, -); +)(); 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..49dd6712d7 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts @@ -1,4 +1,10 @@ -import { type DropCursorHooks, getNearestBlockPos } from "@blocknote/core"; +import { + type BlockNoteEditor, + type DropCursorHooks, + getContainerUIInfo, + getNearestBlockPos, + isContainerNode, +} from "@blocknote/core"; import type { EditorState } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; @@ -16,6 +22,7 @@ export interface EdgeDropPosition { * Returns null when the event position cannot be resolved (e.g. drop outside editor bounds). */ export function detectEdgePosition( + editor: BlockNoteEditor, event: DragEvent, view: EditorView, state: EditorState, @@ -31,10 +38,20 @@ 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 child container of a horizontal-layout + // container (e.g. inside a column of a columnList), we want to compare the + // mouse position to the child container, not the block inside it. + // Why? Because we want to insert a new sibling child (a new column in the + // columnList) instead of a new container inside the child. + const { horizontalContainerTypes } = getContainerUIInfo(editor); let resolved = state.doc.resolve(blockPos.posBeforeNode); - if (resolved.parent.type.name === "column") { + if ( + isContainerNode(resolved.parent.type) && + resolved.depth > 0 && + horizontalContainerTypes.has( + state.doc.resolve(resolved.before()).parent.type.name, + ) + ) { resolved = state.doc.resolve(resolved.before()); } @@ -84,6 +101,7 @@ export const multiColumnDropCursor: { hooks: DropCursorHooks } = { hooks: { computeDropPosition: (context) => { const edgePos = detectEdgePosition( + context.editor, context.event, context.view, context.view.state, diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 7c8e0b312e..8fcd316691 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -3,6 +3,8 @@ import { UniqueID, createExtension, getBlockInfo, + getContainerUIInfo, + isContainerNode, nodeToBlock, } from "@blocknote/core"; import { Plugin } from "prosemirror-state"; @@ -20,7 +22,7 @@ export function createMultiColumnHandleDropPlugin( return new Plugin({ props: { handleDrop(view: EditorView, event: DragEvent, slice, _moved) { - const edgePos = detectEdgePosition(event, view, view.state); + const edgePos = detectEdgePosition(editor, event, view, view.state); if (edgePos === null) { return false; // Let ProseMirror handle the drop (e.g. outside editor bounds) } @@ -41,17 +43,33 @@ 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 sits directly inside a horizontal-layout + // container (after `detectEdgePosition` hoisted blocks inside a + // column to the column itself) — e.g. the target is a `column` whose + // parent is a `columnList`. + const { horizontalContainerTypes } = getContainerUIInfo(editor); + const $target = view.state.doc.resolve(blockInfo.bnBlock.beforePos); + const targetInHorizontalContainer = horizontalContainerTypes.has( + $target.node().type.name, + ); + + 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 +77,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 +110,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..ec052ff27b 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..ea4d3b437a 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"/); + }); });