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..d710e8c2e8 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/README.md @@ -0,0 +1,18 @@ +# 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 `container` config on `BlockConfig`. Setting `container: { defaultBlocks: ["paragraph"] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested `blockContainer+` children — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime. + +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. +- 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..73fced42ca --- /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 `defaultBlocks` (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..5b8ed49885 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -0,0 +1,72 @@ +import { createReactBlockSpec, NodeViewWrapper } 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 `container` +// config — the block hosts arbitrary child blocks in its body, exposed at +// runtime as `block.children`. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + container: { + min: 1, + defaultBlocks: ["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 }, + }); + }; + + return ( + + +
+ + ); + }, + }, +); 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..c92baabdf1 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/styles.css @@ -0,0 +1,93 @@ +.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-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/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 61bc44d68a..b0d1ebab21 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -16,10 +16,15 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +import type { ContainerConfig } from "../../schema/blocks/types.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 @@ -337,6 +342,7 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + _seenNodeTypes?: Set, ) { let id = block.id; @@ -378,6 +384,36 @@ export function blockToNode( groupNode ? [contentNode, groupNode] : contentNode, ); } else if (schema.nodes[block.type].isInGroup("bnBlock")) { + // this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node + let effectiveChildren = children; + + // Seed `defaultBlocks` for container blocks when no children would + // otherwise be present — covers both `block.children === undefined` and + // `block.children === []` (e.g. converting a leaf block whose + // `nodeToBlock` produced empty children into a container). + if (children.length === 0) { + // `container` is normalized to `ContainerConfig | undefined` at spec + // registration time (see addNodeAndExtensionsToSpec). + const containerConfig = getBlockSchema(schema)[block.type]?.container as + | ContainerConfig + | undefined; + const defaultBlocks = containerConfig?.defaultBlocks; + if (defaultBlocks && defaultBlocks.length > 0) { + const seenNodes = _seenNodeTypes ?? new Set(); + seenNodes.add(block.type); + effectiveChildren = defaultBlocks + .filter((type) => !seenNodes.has(type)) + .map((type) => + blockToNode( + { type } as PartialBlock, + schema, + styleSchema, + seenNodes, + ), + ); + } + } + // `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 +421,7 @@ export function blockToNode( id: id, ...block.props, }, - children, + effectiveChildren, ); } else { throw new Error( diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 2592b25d2a..71176e5576 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -59,7 +59,25 @@ 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", + // Block specs whose PM node is itself in the `bnBlock` group (column, + // columnList, callout, etc.) — i.e. the bnBlock node IS the block, so + // the id lives on its attrs rather than on a wrapping blockContainer. + ...Object.entries(editor.schema.blockSpecs) + .filter(([, spec]) => { + const node = spec.implementation.node as Node | undefined; + if (!node?.config) { + return false; + } + const group = node.config.group; + return ( + typeof group === "string" && + group.split(/\s+/).includes("bnBlock") + ); + }) + .map(([name]) => name), + ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, }), diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 5db7ff48eb..98b08578f1 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -6,6 +6,7 @@ import { TagParseRule, } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { @@ -13,8 +14,10 @@ import { ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + containerContentExpression, getBlockFromNodeView, propsToAttributes, wrapInBlockStructure, @@ -25,6 +28,7 @@ import { BlockImplementation, BlockImplementationOrCreator, BlockSpec, + ContainerConfig, LooseBlockSpec, } from "./types.js"; @@ -167,6 +171,131 @@ export function getParseRules< return rules; } +function buildContainerNode( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + containerConfig: ContainerConfig, + // priority is hardcoded inside the node spec below; the param is kept so the + // caller signature mirrors the non-container path but is intentionally unused. + _priority?: number, +) { + return Node.create({ + name: blockConfig.type, + content: containerContentExpression(containerConfig), + group: + containerConfig.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" }, + block as any, + editor as any, + ) as unknown as NodeView; + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(nodeView, this.editor); + } + + 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< @@ -179,119 +308,151 @@ export function addNodeAndExtensionsToSpec< extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, ): LooseBlockSpec { - const node = - ((blockImplementation as any).node as Node) || - Node.create({ - name: blockConfig.type, - content: (blockConfig.content === "inline" - ? "inline*" - : blockConfig.content === "plain" - ? "text*" - : blockConfig.content === "none" - ? "" - : blockConfig.content) as TContent extends "inline" - ? "inline*" - : TContent extends "plain" - ? "text*" - : "", - // "plain" blocks hold unstyled text, so they disallow formatting marks. - // They still allow the non-formatting marks (comments and - // suggestions/diffs) — those annotate content without changing it and are - // ignored by the block model. `nonFormattingMarks` resolves the group only - // when at least one such mark is registered, so a plain block in an editor - // without any of them doesn't reference an empty (unknown) mark group. - marks() { - return blockConfig.content === "plain" - ? nonFormattingMarks(this.editor) - : undefined; - }, - group: "blockContent", - selectable: blockImplementation.meta?.selectable ?? true, - isolating: blockImplementation.meta?.isolating ?? true, - code: blockImplementation.meta?.code ?? false, - defining: blockImplementation.meta?.defining ?? true, - priority, - addAttributes() { - return propsToAttributes(blockConfig.propSchema); - }, + // Normalize the `container: true` shorthand once, here. Downstream code + // sees `ContainerConfig | undefined` only. + // + // Normalize in place rather than spreading into a fresh object: `init()` + // calls this per schema instance on the same shared `blockSpec.config`, and + // consumers such as `checkMultiColumnBlocksInSchema` compare + // `blockSchema[type]` by reference across schemas. A new object per call + // would break that identity. The mutation only fires for the `true` + // shorthand and is idempotent, so the shared config stays stable. + const containerConfig: ContainerConfig | undefined = + blockConfig.container === true ? {} : blockConfig.container; + if (blockConfig.container !== containerConfig) { + blockConfig.container = containerConfig; + } - parseHTML() { - return getParseRules(blockConfig, blockImplementation); - }, + if (containerConfig && blockConfig.content !== "none") { + throw new Error( + `Block "${blockConfig.type}" sets \`container\` 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) || + (containerConfig + ? buildContainerNode( + blockConfig as unknown as BlockConfig, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "none" + >, + containerConfig, + priority, + ) + : 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 +632,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 (editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock")) { + return output; + } + return wrapInBlockStructure( output, block.type, @@ -490,6 +657,10 @@ export function createBlockSpec< editor as any, ); + if (editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock")) { + 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..c24ff198d5 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,26 @@ 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 { ContainerConfig, LooseBlockSpec } from "./types.js"; + +// Builds the ProseMirror content expression for a container block from its +// cardinality config. +export function containerContentExpression(config: ContainerConfig): string { + const min = config.min; + const max = config.max; + + if (max !== undefined) { + const effectiveMin = min ?? 1; + return `blockGroupChild{${effectiveMin},${max}}`; + } + if (min === 0) { + return "blockGroupChild*"; + } + if (min === undefined || min === 1) { + return "blockGroupChild+"; + } + return `blockGroupChild{${min},}`; +} // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 00b564ef0b..deaa3fa96a 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 { @@ -61,6 +61,34 @@ export interface BlockConfigMeta { isolating?: boolean; } +/** + * Configuration for a block that hosts other blocks as its body (a "container"). + * When set, the block's ProseMirror node is emitted in the `bnBlock` / + * `childContainer` groups with `blockContainer{min,max}` content — the same + * shape that columns use today. Child blocks live on `block.children` at + * runtime (matching the column model). Requires `content: "none"`. + */ +export type ContainerConfig = { + /** Minimum number of child blocks. Defaults to 1. */ + min?: number; + /** Maximum number of child blocks. Defaults to unbounded. */ + max?: number; + /** + * Block types to seed the container with on first insert. Each entry + * produces one empty block of that type. Ignored when the inserted partial + * block already provides explicit `children`. + */ + defaultBlocks?: string[]; + /** + * 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; +}; + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -87,8 +115,14 @@ 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 `blockContainer+` + * content; child blocks are exposed on `block.children`. Requires + * `content: "none"`. Pass `true` for defaults or an object to constrain + * cardinality and seed the initial children. + */ + container?: true | ContainerConfig; } /** @@ -210,6 +244,7 @@ export type LooseBlockSpec< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -268,6 +303,7 @@ export type BlockSpecs = { contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -553,6 +589,18 @@ export type BlockImplementation< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + // TODO this may not be the right API for this, but let's just stick with it for now + /** + * 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 today; non-container blocks always + * recreate on attr changes. + */ + update?: (node: PMNode) => boolean | void; }; /** diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2259babd84..9fc6838754 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -143,6 +143,11 @@ export * from "./schema/useNodeViewBlock.js"; export * from "./icons.js"; +// TODO figure out the right API for this, but let's just stick with it for now +// Re-exported so container-block authors can return `` from +// their `render` (the framework no longer auto-wraps containers). +export { NodeViewWrapper } from "@tiptap/react"; + export * from "./util/elementOverflow.js"; export * from "./util/mergeRefs.js"; 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..3b6789afeb --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx @@ -0,0 +1,75 @@ +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, + container: { min: 1, defaultBlocks: ["paragraph"] }, + }, + { + render: ({ contentRef }) => ( +
+
+
+ ), + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + } as const, +}); + +describe("React updateBlock → container with defaultBlocks (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); +}); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 4bd1649292..ae6e41c885 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -11,6 +11,7 @@ import { ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, mergeCSSClasses, + nodeToBlock, Props, PropSchema, } from "@blocknote/core"; @@ -33,11 +34,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 +237,39 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { + const isContainer = + !!editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock"); 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) { + return ( + + {content} + + ); + } return ( - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - context={context} - /> + {content} ); }, editor); @@ -280,7 +301,29 @@ export function createReactBlockSpec< // 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); + const isContainer = props.node.type.isInGroup("bnBlock"); + // `useNodeViewBlock` is a hook, so it must run unconditionally + // (rules of hooks). For container blocks its position-based + // result is discarded in favor of the id-based lookup below. + const nodeViewBlock = useNodeViewBlock(props, initialBlock); + let block; + if (isContainer) { + // Container blocks are bnBlock-group nodes (no blockContainer + // wrapper), so the id lives on `props.node.attrs.id`. The + // standard position-based resolution walks up to a parent, + // which would return the wrong node here. + const id = (props.node.attrs as Record).id; + if (!id) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute.`, + ); + } + block = + editor.getBlock(id) ?? + nodeToBlock(props.node, editor.prosemirrorView.state.doc); + } else { + block = nodeViewBlock; + } const ref = useReactNodeView().nodeViewContentRef; @@ -289,6 +332,32 @@ export function createReactBlockSpec< } const BlockContent = blockImplementation.render; + const content = ( + { + ref(element); + if (element) { + if (!isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + element.dataset.nodeViewContent = ""; + } + }} + /> + ); + if (isContainer) { + // 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 content; + } return ( - { - ref(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - element.dataset.nodeViewContent = ""; - } - }} - /> + {content} ); }, @@ -319,8 +375,28 @@ export function createReactBlockSpec< }, )(this.props!) as ReturnType; } else { + const isContainer = + !!editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock"); 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 ( - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - /> + {content} ); }, editor); 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..810f3cc319 --- /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 defaultBlocks (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/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index 2e49261ec6..c1670e030d 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,22 +1,79 @@ +import { + createBlockSpec, + createBlockSpecFromTiptapNode, +} 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. + container: { topLevel: false }, }, { - width: { - default: 1, + 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( { 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/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/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/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 85037877a4..efae7f0509 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 `container` config on `BlockConfig`. Setting `container: { defaultBlocks: ["paragraph"] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested `blockContainer+` children — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.\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- 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", 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==}