From 927088242e7d4d4f290024c2f0a11c4332eaaa1b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 5 May 2026 14:20:27 +0200 Subject: [PATCH] feat: combinator content schemas for custom blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce ContentType abstraction and POJO combinator API (`c.inline`, `c.none`, `c.record`, `c.list`, `c.blocks`, `c.props`) for declaring arbitrary block content shapes — multi-slot records, variable-length lists, regions of editor blocks, and typed per-item props. Block JSON shapes are derived directly from the schema. Phase 1 rebuilds the table block on top of the new ContentType primitive with no observable behaviour or JSON-shape change. Phase 3 ships the combinator surface, widens createReactBlockSpec to accept ContentType, and adds 4 runnable examples (multi-slot alert, FAQ, callout, tab group) plus end-to-end tests for the React render path and the data layer. Also fixes textCursorPosition's parent walk-up to traverse multiple non-bnBlock layers, which combinator slot chains can introduce. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../09-multi-slot-alert-block/.bnexample.json | 15 + .../09-multi-slot-alert-block/README.md | 41 ++ .../09-multi-slot-alert-block/index.html | 14 + .../09-multi-slot-alert-block/main.tsx | 11 + .../09-multi-slot-alert-block/package.json | 32 + .../09-multi-slot-alert-block/src/Alert.tsx | 97 +++ .../09-multi-slot-alert-block/src/App.tsx | 75 +++ .../09-multi-slot-alert-block/src/styles.css | 122 ++++ .../09-multi-slot-alert-block/tsconfig.json | 36 + .../09-multi-slot-alert-block/vite.config.ts | 32 + .../10-faq-block/.bnexample.json | 16 + .../06-custom-schema/10-faq-block/README.md | 38 ++ .../06-custom-schema/10-faq-block/index.html | 14 + .../06-custom-schema/10-faq-block/main.tsx | 11 + .../10-faq-block/package.json | 32 + .../06-custom-schema/10-faq-block/src/App.tsx | 97 +++ .../06-custom-schema/10-faq-block/src/Faq.tsx | 78 +++ .../10-faq-block/src/styles.css | 132 ++++ .../10-faq-block/tsconfig.json | 36 + .../10-faq-block/vite.config.ts | 32 + .../11-callout-block/.bnexample.json | 16 + .../11-callout-block/README.md | 34 + .../11-callout-block/index.html | 14 + .../11-callout-block/main.tsx | 11 + .../11-callout-block/package.json | 32 + .../11-callout-block/src/App.tsx | 112 ++++ .../11-callout-block/src/Callout.tsx | 94 +++ .../11-callout-block/src/styles.css | 107 +++ .../11-callout-block/tsconfig.json | 36 + .../11-callout-block/vite.config.ts | 32 + .../12-tab-group-block/.bnexample.json | 15 + .../12-tab-group-block/README.md | 52 ++ .../12-tab-group-block/index.html | 14 + .../12-tab-group-block/main.tsx | 11 + .../12-tab-group-block/package.json | 32 + .../12-tab-group-block/src/App.tsx | 152 +++++ .../12-tab-group-block/src/Tabs.tsx | 170 +++++ .../12-tab-group-block/src/styles.css | 164 +++++ .../12-tab-group-block/tsconfig.json | 36 + .../12-tab-group-block/vite.config.ts | 32 + .../commands/updateBlock/updateBlock.ts | 25 +- .../blockManipulation/selections/selection.ts | 5 +- .../selections/textCursorPosition.ts | 26 +- .../clipboard/toClipboard/copyExtension.ts | 1 + .../exporters/html/externalHTMLExporter.ts | 2 + .../html/util/serializeBlocksExternalHTML.ts | 33 +- .../html/util/serializeBlocksInternalHTML.ts | 24 +- .../src/api/nodeConversions/blockToNode.ts | 116 +++- .../src/api/nodeConversions/nodeToBlock.ts | 25 +- packages/core/src/blocks/Table/block.ts | 65 +- packages/core/src/editor/BlockNoteEditor.ts | 5 + .../getDefaultSlashMenuItems.ts | 5 +- packages/core/src/schema/blocks/createSpec.ts | 12 +- packages/core/src/schema/blocks/internal.ts | 33 +- packages/core/src/schema/blocks/types.ts | 48 +- .../contentTypes/combinators/factory.test.ts | 633 ++++++++++++++++++ .../contentTypes/combinators/factory.ts | 382 +++++++++++ .../schema/contentTypes/combinators/types.ts | 158 +++++ .../core/src/schema/contentTypes/types.ts | 133 ++++ packages/core/src/schema/index.ts | 3 + packages/react/src/schema/ReactBlockSpec.tsx | 123 +++- .../src/api/schema/schemaToJSONSchema.ts | 4 +- playground/src/examples.gen.tsx | 106 +++ pnpm-lock.yaml | 212 +++++- shared/formatConversionTestUtil.ts | 12 +- .../core/schema/__snapshots__/blocks.json | 5 +- .../CombinatorContentReactRender.test.tsx | 151 +++++ 67 files changed, 4300 insertions(+), 174 deletions(-) create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/.bnexample.json create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/README.md create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/index.html create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/main.tsx create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/package.json create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/src/Alert.tsx create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/src/App.tsx create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/src/styles.css create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/tsconfig.json create mode 100644 examples/06-custom-schema/09-multi-slot-alert-block/vite.config.ts create mode 100644 examples/06-custom-schema/10-faq-block/.bnexample.json create mode 100644 examples/06-custom-schema/10-faq-block/README.md create mode 100644 examples/06-custom-schema/10-faq-block/index.html create mode 100644 examples/06-custom-schema/10-faq-block/main.tsx create mode 100644 examples/06-custom-schema/10-faq-block/package.json create mode 100644 examples/06-custom-schema/10-faq-block/src/App.tsx create mode 100644 examples/06-custom-schema/10-faq-block/src/Faq.tsx create mode 100644 examples/06-custom-schema/10-faq-block/src/styles.css create mode 100644 examples/06-custom-schema/10-faq-block/tsconfig.json create mode 100644 examples/06-custom-schema/10-faq-block/vite.config.ts create mode 100644 examples/06-custom-schema/11-callout-block/.bnexample.json create mode 100644 examples/06-custom-schema/11-callout-block/README.md create mode 100644 examples/06-custom-schema/11-callout-block/index.html create mode 100644 examples/06-custom-schema/11-callout-block/main.tsx create mode 100644 examples/06-custom-schema/11-callout-block/package.json create mode 100644 examples/06-custom-schema/11-callout-block/src/App.tsx create mode 100644 examples/06-custom-schema/11-callout-block/src/Callout.tsx create mode 100644 examples/06-custom-schema/11-callout-block/src/styles.css create mode 100644 examples/06-custom-schema/11-callout-block/tsconfig.json create mode 100644 examples/06-custom-schema/11-callout-block/vite.config.ts create mode 100644 examples/06-custom-schema/12-tab-group-block/.bnexample.json create mode 100644 examples/06-custom-schema/12-tab-group-block/README.md create mode 100644 examples/06-custom-schema/12-tab-group-block/index.html create mode 100644 examples/06-custom-schema/12-tab-group-block/main.tsx create mode 100644 examples/06-custom-schema/12-tab-group-block/package.json create mode 100644 examples/06-custom-schema/12-tab-group-block/src/App.tsx create mode 100644 examples/06-custom-schema/12-tab-group-block/src/Tabs.tsx create mode 100644 examples/06-custom-schema/12-tab-group-block/src/styles.css create mode 100644 examples/06-custom-schema/12-tab-group-block/tsconfig.json create mode 100644 examples/06-custom-schema/12-tab-group-block/vite.config.ts create mode 100644 packages/core/src/schema/contentTypes/combinators/factory.test.ts create mode 100644 packages/core/src/schema/contentTypes/combinators/factory.ts create mode 100644 packages/core/src/schema/contentTypes/combinators/types.ts create mode 100644 packages/core/src/schema/contentTypes/types.ts create mode 100644 tests/src/unit/react/CombinatorContentReactRender.test.tsx diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/.bnexample.json b/examples/06-custom-schema/09-multi-slot-alert-block/.bnexample.json new file mode 100644 index 0000000000..ee8ec8e550 --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Combinator Content" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/README.md b/examples/06-custom-schema/09-multi-slot-alert-block/README.md new file mode 100644 index 0000000000..b3b845d21f --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/README.md @@ -0,0 +1,41 @@ +# Multi-Slot Alert Block + +In this example, we create a custom `Alert` block whose content is a +**combinator content schema** — a record of two inline regions, `title` and +`body`. The block JSON exposes both slots as named keys, and the editor +displays the document's JSON live so you can see the resulting shape. + +This is the same alert idea as `01-alert-block`, but with a richer content +shape: where the simple alert has a single inline region, this one has two +independently editable regions stored as named slots in the JSON. + +```ts +const alertContentType = combinatorContentType( + "alert", + c.record({ + title: c.inline(), + body: c.inline(), + }), +); +``` + +The block's content JSON is automatically derived from the schema: + +```json +{ + "type": "alert", + "props": { "variant": "warning" }, + "content": { + "title": [{ "type": "text", "text": "Heads up", "styles": {} }], + "body": [{ "type": "text", "text": "Be careful.", "styles": {} }] + } +} +``` + +**Try it out:** click the icon to change the alert variant, and edit the title +and body inline. Watch the JSON panel below update in real time. + +**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-multi-slot-alert-block/index.html b/examples/06-custom-schema/09-multi-slot-alert-block/index.html new file mode 100644 index 0000000000..6f72d81225 --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/index.html @@ -0,0 +1,14 @@ + + + + + Multi-Slot Alert Block + + + +
+ + + diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/main.tsx b/examples/06-custom-schema/09-multi-slot-alert-block/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-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-multi-slot-alert-block/package.json b/examples/06-custom-schema/09-multi-slot-alert-block/package.json new file mode 100644 index 0000000000..eee41e1966 --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocknote/example-custom-schema-multi-slot-alert-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "@mantine/utils": "^6.0.22", + "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": "^8.0.8" + } +} \ No newline at end of file diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/src/Alert.tsx b/examples/06-custom-schema/09-multi-slot-alert-block/src/Alert.tsx new file mode 100644 index 0000000000..618ecbd04a --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/src/Alert.tsx @@ -0,0 +1,97 @@ +import { c, combinatorContentType } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; +import { Menu } from "@mantine/core"; +import { MdCancel, MdCheckCircle, MdError, MdInfo } from "react-icons/md"; + +import "./styles.css"; + +// The variants of alert that users can choose from. +export const alertVariants = [ + { value: "warning", title: "Warning", icon: MdError }, + { value: "error", title: "Error", icon: MdCancel }, + { value: "info", title: "Info", icon: MdInfo }, + { value: "success", title: "Success", icon: MdCheckCircle }, +] as const; + +// The content schema: a record of two named inline regions. The block's +// `content` JSON shape is automatically derived as +// `{ title: InlineContent[]; body: InlineContent[] }`. +const alertContentType = combinatorContentType( + "alert", + c.record({ + title: c.inline(), + body: c.inline(), + }), +); + +export const createAlert = createReactBlockSpec( + { + type: "alert", + propSchema: { + variant: { + default: "warning", + values: ["warning", "error", "info", "success"] as const, + }, + }, + content: alertContentType, + }, + { + render: (props) => { + const variant = + alertVariants.find((v) => v.value === props.block.props.variant) ?? + alertVariants[0]; + const Icon = variant.icon; + + return ( +
+ {/* Icon — non-editable; opens a menu to change the variant. */} + + +
+ +
+
+ + Alert variant + + {alertVariants.map((v) => { + const ItemIcon = v.icon; + return ( + + } + onClick={() => + props.editor.updateBlock(props.block, { + type: "alert", + props: { variant: v.value }, + }) + }> + {v.title} + + ); + })} + +
+ {/* + Content slots: the parent record's children (title + body) mount + as siblings inside this element. Each slot is a real ProseMirror + node, identified by `data-content-name="alert__"`, which we + target with CSS to give title and body distinct styling. + */} +
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/src/App.tsx b/examples/06-custom-schema/09-multi-slot-alert-block/src/App.tsx new file mode 100644 index 0000000000..e60f7ea978 --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/src/App.tsx @@ -0,0 +1,75 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { useEffect, useState } from "react"; + +import { createAlert } from "./Alert.js"; +import "./styles.css"; + +// Schema with the multi-slot alert block added. +const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + alert: createAlert(), + }, +}); + +export default function App() { + // The editor's `document` carries the full custom-schema type (including + // the `alert` block with its `{ title, body }` content), so we infer the + // state type from it instead of using the unparameterized `Block`. + const [blocks, setBlocks] = useState([]); + + // Editor preloaded with an example alert that has both slots populated, so + // the JSON panel below shows the `{ title, body }` shape from the start. + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "An alert below has two independent rich-text regions:", + }, + { + type: "alert" as const, + props: { variant: "info" }, + content: { + title: [ + { type: "text", text: "Heads up", styles: { bold: true } }, + ], + body: [ + { type: "text", text: "Title and body are ", styles: {} }, + { type: "text", text: "separate slots", styles: { italic: true } }, + { type: "text", text: " in the JSON.", styles: {} }, + ], + } as any, + } as any, + { + type: "paragraph", + content: + "Edit either slot and watch the JSON below — title and body update independently.", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ setBlocks(editor.document)} + /> +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/src/styles.css b/examples/06-custom-schema/09-multi-slot-alert-block/src/styles.css new file mode 100644 index 0000000000..91a6b2924f --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/src/styles.css @@ -0,0 +1,122 @@ +/* App layout: editor on top, JSON panel below. */ +.wrapper { + display: flex; + flex-direction: column; + gap: 1rem; + 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; +} + +/* Alert chrome: icon on the left, content slots on the right. */ +.alert { + display: flex; + align-items: stretch; + flex-grow: 1; + border-radius: 6px; + min-height: 64px; + padding: 8px 12px; + gap: 12px; +} + +.alert[data-alert-variant="warning"] { + background-color: #fff6e6; +} +.alert[data-alert-variant="error"] { + background-color: #ffe6e6; +} +.alert[data-alert-variant="info"] { + background-color: #e6ebff; +} +.alert[data-alert-variant="success"] { + background-color: #e6ffe6; +} + +[data-color-scheme="dark"] .alert[data-alert-variant="warning"] { + background-color: #805d20; +} +[data-color-scheme="dark"] .alert[data-alert-variant="error"] { + background-color: #802020; +} +[data-color-scheme="dark"] .alert[data-alert-variant="info"] { + background-color: #203380; +} +[data-color-scheme="dark"] .alert[data-alert-variant="success"] { + background-color: #208020; +} + +.alert-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + user-select: none; + padding-top: 4px; +} + +.alert-icon[data-alert-icon-variant="warning"] { + color: #e69819; +} +.alert-icon[data-alert-icon-variant="error"] { + color: #d80d0d; +} +.alert-icon[data-alert-icon-variant="info"] { + color: #507aff; +} +.alert-icon[data-alert-icon-variant="success"] { + color: #0bc10b; +} + +/* Slots container: holds the title and body as stacked editable regions. */ +.alert-slots { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +/* Per-slot styling, targeted by the deterministic data-content-name that the + combinator factory emits for each record field. */ +.alert-slots [data-content-name="alert__title"] { + font-weight: 600; + font-size: 1.05em; + line-height: 1.4; +} + +.alert-slots [data-content-name="alert__body"] { + line-height: 1.5; +} + +/* Empty-slot affordance: a hint of placeholder behavior for each slot. + (BlockNote's Placeholder extension targets the block-level content; for the + sub-slots we use CSS :empty for a minimal visual cue.) */ +.alert-slots [data-content-name="alert__title"]:empty::before { + content: "Title…"; + color: rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +.alert-slots [data-content-name="alert__body"]:empty::before { + content: "Body…"; + color: rgba(0, 0, 0, 0.35); + pointer-events: none; +} diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/tsconfig.json b/examples/06-custom-schema/09-multi-slot-alert-block/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "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/" + } + ] +} \ No newline at end of file diff --git a/examples/06-custom-schema/09-multi-slot-alert-block/vite.config.ts b/examples/06-custom-schema/09-multi-slot-alert-block/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/06-custom-schema/09-multi-slot-alert-block/vite.config.ts @@ -0,0 +1,32 @@ +// 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"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + 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), + }, +})); diff --git a/examples/06-custom-schema/10-faq-block/.bnexample.json b/examples/06-custom-schema/10-faq-block/.bnexample.json new file mode 100644 index 0000000000..e30fc04380 --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/.bnexample.json @@ -0,0 +1,16 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Combinator Content", + "List Combinator" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/10-faq-block/README.md b/examples/06-custom-schema/10-faq-block/README.md new file mode 100644 index 0000000000..ae7a7cc16e --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/README.md @@ -0,0 +1,38 @@ +# FAQ Block + +A custom block whose content is a **variable-length list** of question/answer +pairs, built with the `c.list` and `c.record` combinators: + +```ts +const faqContentType = combinatorContentType( + "faq", + c.list( + c.record({ + question: c.inline(), + answer: c.inline(), + }), + ), +); +``` + +The block's JSON `content` is automatically derived as an array: + +```json +[ + { "question": [...], "answer": [...] }, + { "question": [...], "answer": [...] } +] +``` + +The example renders all FAQ items in the block's chrome and has an +**Add question** button that calls `editor.updateBlock` to append a new item +to the list — demonstrating how arbitrary list mutations work today through +the existing block-update API. + +**Try it:** edit any question or answer and watch the JSON update; click +"Add question" to see the array grow. + +**Relevant Docs:** + +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/10-faq-block/index.html b/examples/06-custom-schema/10-faq-block/index.html new file mode 100644 index 0000000000..91e91a712f --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/index.html @@ -0,0 +1,14 @@ + + + + + FAQ Block + + + +
+ + + diff --git a/examples/06-custom-schema/10-faq-block/main.tsx b/examples/06-custom-schema/10-faq-block/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/06-custom-schema/10-faq-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/10-faq-block/package.json b/examples/06-custom-schema/10-faq-block/package.json new file mode 100644 index 0000000000..672e134bce --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocknote/example-custom-schema-faq-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "@mantine/utils": "^6.0.22", + "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": "^8.0.8" + } +} \ No newline at end of file diff --git a/examples/06-custom-schema/10-faq-block/src/App.tsx b/examples/06-custom-schema/10-faq-block/src/App.tsx new file mode 100644 index 0000000000..ec074149a0 --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/src/App.tsx @@ -0,0 +1,97 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { useEffect, useState } from "react"; + +import { createFaq } from "./Faq.js"; +import "./styles.css"; + +const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + faq: createFaq(), + }, +}); + +export default function App() { + // The editor's `document` carries the full custom-schema type (including + // the `faq` block whose content is `Array<{ question, answer }>`). + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "An FAQ block whose content is a list of question/answer records:", + }, + { + type: "faq" as const, + content: [ + { + question: [ + { type: "text", text: "What is this?", styles: {} }, + ], + answer: [ + { + type: "text", + text: "A demo of the ", + styles: {}, + }, + { + type: "text", + text: "c.list", + styles: { code: true } as any, + }, + { + type: "text", + text: " combinator.", + styles: {}, + }, + ], + }, + { + question: [ + { type: "text", text: "How is the JSON shaped?", styles: {} }, + ], + answer: [ + { + type: "text", + text: "An array of records — see the panel below.", + styles: {}, + }, + ], + }, + ] as any, + } as any, + { + type: "paragraph", + content: + "Click 'Add question' to grow the list, or edit any question or answer and watch the array update.", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ setBlocks(editor.document)} + /> +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/10-faq-block/src/Faq.tsx b/examples/06-custom-schema/10-faq-block/src/Faq.tsx new file mode 100644 index 0000000000..4ce2d59457 --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/src/Faq.tsx @@ -0,0 +1,78 @@ +import { c, combinatorContentType } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +// Content schema: a list of records. Each item has its own `question` and +// `answer` rich-text region, and the JSON content is automatically the array +// `Array<{ question: InlineContent[]; answer: InlineContent[] }>`. +const faqContentType = combinatorContentType( + "faq", + c.list( + c.record({ + question: c.inline(), + answer: c.inline(), + }), + ), +); + +export const createFaq = createReactBlockSpec( + { + type: "faq", + propSchema: {}, + content: faqContentType, + }, + { + render: (props) => { + // The current item count is read straight off the block. We use this to + // synthesize a new item with empty question/answer slots when the user + // clicks "Add question" — the editor takes care of inserting the new + // ProseMirror nodes via the standard updateBlock API. + const itemCount = (props.block.content as unknown as unknown[]).length; + + return ( +
+
+ FAQ + +
+ {/* + Single contentRef target: ProseMirror mounts each list item as a + DOM sibling here. Each item is a `c.record` whose own children + (question + body) are nested under `[data-content-name="faq__item"]`. + CSS targets the deterministic data-content-name attributes for + per-slot styling. + */} +
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/10-faq-block/src/styles.css b/examples/06-custom-schema/10-faq-block/src/styles.css new file mode 100644 index 0000000000..d8cb963159 --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/src/styles.css @@ -0,0 +1,132 @@ +/* App layout */ +.wrapper { + display: flex; + flex-direction: column; + gap: 1rem; + 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; +} + +/* FAQ chrome */ +.faq { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px; + border-radius: 8px; + background-color: #f6f8fb; + border: 1px solid #d8e0ea; + flex-grow: 1; +} + +[data-color-scheme="dark"] .faq { + background-color: #1f2530; + border-color: #2c3441; +} + +.faq-title { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 700; + font-size: 1.1em; + letter-spacing: 0.02em; + color: #4a5568; + user-select: none; +} + +[data-color-scheme="dark"] .faq-title { + color: #cbd5e1; +} + +.faq-add { + font: inherit; + font-size: 0.85em; + font-weight: 500; + background-color: #4a90e2; + color: white; + border: none; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; +} + +.faq-add:hover { + background-color: #357abd; +} + +.faq-items { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Each list item is a record container, addressable via the deterministic + data-content-name attribute the combinator factory emits. */ +.faq-items [data-content-name="faq__item"] { + display: flex; + flex-direction: column; + padding: 8px 12px; + border-radius: 6px; + background-color: white; + border: 1px solid #e8eef5; +} + +[data-color-scheme="dark"] .faq-items [data-content-name="faq__item"] { + background-color: #2a313e; + border-color: #353c4a; +} + +/* Per-record-field slots, addressable by their inferred names. */ +.faq-items [data-content-name="faq__item__question"] { + font-weight: 600; + font-size: 1.02em; + line-height: 1.4; + color: #2d3748; +} + +[data-color-scheme="dark"] .faq-items [data-content-name="faq__item__question"] { + color: #f0f4fa; +} + +.faq-items [data-content-name="faq__item__answer"] { + margin-top: 4px; + line-height: 1.5; + color: #4a5568; +} + +[data-color-scheme="dark"] .faq-items [data-content-name="faq__item__answer"] { + color: #cbd5e1; +} + +/* Empty-slot affordance for both fields. */ +.faq-items [data-content-name="faq__item__question"]:empty::before { + content: "Question…"; + color: rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +.faq-items [data-content-name="faq__item__answer"]:empty::before { + content: "Answer…"; + color: rgba(0, 0, 0, 0.35); + pointer-events: none; +} diff --git a/examples/06-custom-schema/10-faq-block/tsconfig.json b/examples/06-custom-schema/10-faq-block/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "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/" + } + ] +} \ No newline at end of file diff --git a/examples/06-custom-schema/10-faq-block/vite.config.ts b/examples/06-custom-schema/10-faq-block/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/06-custom-schema/10-faq-block/vite.config.ts @@ -0,0 +1,32 @@ +// 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"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + 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), + }, +})); diff --git a/examples/06-custom-schema/11-callout-block/.bnexample.json b/examples/06-custom-schema/11-callout-block/.bnexample.json new file mode 100644 index 0000000000..b5f05bec03 --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/.bnexample.json @@ -0,0 +1,16 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Combinator Content", + "Blocks Combinator" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/11-callout-block/README.md b/examples/06-custom-schema/11-callout-block/README.md new file mode 100644 index 0000000000..8ef510d61c --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/README.md @@ -0,0 +1,34 @@ +# Callout Block + +A callout block whose content is a **sequence of editor blocks** rather than a +single rich-text region. Built with the `c.blocks` combinator: + +```ts +const calloutContentType = combinatorContentType( + "callout", + c.blocks(), +); +``` + +The block's JSON `content` is automatically derived as `Block[]`: + +```json +{ + "type": "callout", + "props": { "tone": "info" }, + "content": [ + { "type": "heading", "props": { "level": 3 }, "content": [...] }, + { "type": "paragraph", "content": [...] }, + { "type": "bulletListItem", "content": [...] } + ] +} +``` + +Inside the callout's body you can drop any block the editor knows about — +headings, paragraphs, lists, even other callouts. Try the slash menu (`/`) +or hit Enter to add new blocks. + +**Relevant Docs:** + +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/11-callout-block/index.html b/examples/06-custom-schema/11-callout-block/index.html new file mode 100644 index 0000000000..389f1cfc66 --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/index.html @@ -0,0 +1,14 @@ + + + + + Callout Block + + + +
+ + + diff --git a/examples/06-custom-schema/11-callout-block/main.tsx b/examples/06-custom-schema/11-callout-block/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + +); diff --git a/examples/06-custom-schema/11-callout-block/package.json b/examples/06-custom-schema/11-callout-block/package.json new file mode 100644 index 0000000000..9af780bbd6 --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocknote/example-custom-schema-callout-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "@mantine/utils": "^6.0.22", + "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": "^8.0.8" + } +} \ No newline at end of file diff --git a/examples/06-custom-schema/11-callout-block/src/App.tsx b/examples/06-custom-schema/11-callout-block/src/App.tsx new file mode 100644 index 0000000000..70f8240fd7 --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/src/App.tsx @@ -0,0 +1,112 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { useEffect, useState } from "react"; + +import { createCallout } from "./Callout.js"; +import "./styles.css"; + +const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + callout: createCallout(), + }, +}); + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "A callout block whose content is a sequence of editor blocks (note the array of blocks in the JSON below):", + }, + { + type: "callout" as const, + props: { tone: "tip" }, + content: [ + { + type: "heading", + props: { level: 3 }, + content: [ + { type: "text", text: "Combinator content schemas", styles: {} }, + ], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "The ", styles: {} }, + { type: "text", text: "c.blocks()", styles: { code: true } as any }, + { + type: "text", + text: + " combinator lets a block's content be a stretch of editor blocks — exactly like the top-level document.", + styles: {}, + }, + ], + }, + { + type: "bulletListItem", + content: [ + { + type: "text", + text: "Drop in headings, paragraphs, lists, …", + styles: {}, + }, + ], + }, + { + type: "bulletListItem", + content: [ + { + type: "text", + text: "All editor commands work inside the callout body.", + styles: {}, + }, + ], + }, + { + type: "bulletListItem", + content: [ + { + type: "text", + text: "Even nested callouts are valid.", + styles: {}, + }, + ], + }, + ] as any, + } as any, + { + type: "paragraph", + content: + "Try the slash menu (/) inside the callout, or click the icon to change the tone.", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ setBlocks(editor.document)} + /> +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/11-callout-block/src/Callout.tsx b/examples/06-custom-schema/11-callout-block/src/Callout.tsx new file mode 100644 index 0000000000..6c99259f44 --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/src/Callout.tsx @@ -0,0 +1,94 @@ +import { c, combinatorContentType } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; +import { Menu } from "@mantine/core"; +import { + MdInfoOutline, + MdLightbulbOutline, + MdOutlineCheckCircle, + MdWarningAmber, +} from "react-icons/md"; + +import "./styles.css"; + +// The callout's content is a stretch of full editor blocks — paragraphs, +// headings, lists, even nested callouts. The block's JSON `content` is +// automatically `Block[]` (the same shape used everywhere else in BlockNote +// for a sequence of blocks). +const calloutContentType = combinatorContentType("callout", c.blocks()); + +export const calloutTones = [ + { value: "info", title: "Info", icon: MdInfoOutline }, + { value: "tip", title: "Tip", icon: MdLightbulbOutline }, + { value: "success", title: "Success", icon: MdOutlineCheckCircle }, + { value: "warning", title: "Warning", icon: MdWarningAmber }, +] as const; + +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + tone: { + default: "info", + values: ["info", "tip", "success", "warning"] as const, + }, + }, + content: calloutContentType, + }, + { + render: (props) => { + const tone = + calloutTones.find((t) => t.value === props.block.props.tone) ?? + calloutTones[0]; + const Icon = tone.icon; + return ( +
+ {/* Icon — non-editable; opens a menu to change the tone. */} + + +
+ +
+
+ + Callout tone + + {calloutTones.map((t) => { + const ItemIcon = t.icon; + return ( + + } + onClick={() => + props.editor.updateBlock(props.block, { + type: "callout", + props: { tone: t.value }, + }) + } + > + {t.title} + + ); + })} + +
+ {/* + Content body: the parent's contentDOM. ProseMirror mounts each + child block as a `blockContainer` here, just like in the top-level + document. All editor commands (slash menu, drag handle, copy/paste, + block manipulation APIs) work inside this slot. + */} +
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/11-callout-block/src/styles.css b/examples/06-custom-schema/11-callout-block/src/styles.css new file mode 100644 index 0000000000..86aec84b85 --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/src/styles.css @@ -0,0 +1,107 @@ +/* App layout */ +.wrapper { + display: flex; + flex-direction: column; + gap: 1rem; + 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 chrome */ +.callout { + display: flex; + align-items: stretch; + flex-grow: 1; + border-radius: 8px; + padding: 12px 16px; + gap: 12px; + border-left: 4px solid; +} + +.callout[data-callout-tone="info"] { + background-color: #eef4ff; + border-left-color: #4a90e2; +} + +.callout[data-callout-tone="tip"] { + background-color: #fffbe6; + border-left-color: #d8a823; +} + +.callout[data-callout-tone="success"] { + background-color: #ecfdf3; + border-left-color: #18a957; +} + +.callout[data-callout-tone="warning"] { + background-color: #fff4ed; + border-left-color: #e07a3a; +} + +[data-color-scheme="dark"] .callout[data-callout-tone="info"] { + background-color: #1f2a3d; + border-left-color: #4a90e2; +} + +[data-color-scheme="dark"] .callout[data-callout-tone="tip"] { + background-color: #2d2614; + border-left-color: #d8a823; +} + +[data-color-scheme="dark"] .callout[data-callout-tone="success"] { + background-color: #14302a; + border-left-color: #18a957; +} + +[data-color-scheme="dark"] .callout[data-callout-tone="warning"] { + background-color: #2d1d10; + border-left-color: #e07a3a; +} + +.callout-icon-wrapper { + display: flex; + align-items: flex-start; + flex-shrink: 0; + cursor: pointer; + user-select: none; + padding-top: 4px; +} + +.callout-icon[data-callout-icon-tone="info"] { + color: #4a90e2; +} +.callout-icon[data-callout-icon-tone="tip"] { + color: #d8a823; +} +.callout-icon[data-callout-icon-tone="success"] { + color: #18a957; +} +.callout-icon[data-callout-icon-tone="warning"] { + color: #e07a3a; +} + +/* The callout body holds the child blockContainers as siblings — the same + layout pattern the top-level document uses. */ +.callout-body { + flex-grow: 1; + min-width: 0; +} diff --git a/examples/06-custom-schema/11-callout-block/tsconfig.json b/examples/06-custom-schema/11-callout-block/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "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/" + } + ] +} \ No newline at end of file diff --git a/examples/06-custom-schema/11-callout-block/vite.config.ts b/examples/06-custom-schema/11-callout-block/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/06-custom-schema/11-callout-block/vite.config.ts @@ -0,0 +1,32 @@ +// 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"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + 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), + }, +})); diff --git a/examples/06-custom-schema/12-tab-group-block/.bnexample.json b/examples/06-custom-schema/12-tab-group-block/.bnexample.json new file mode 100644 index 0000000000..07f0d1754e --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Advanced", + "Blocks", + "Custom Schemas", + "Combinator Content" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/12-tab-group-block/README.md b/examples/06-custom-schema/12-tab-group-block/README.md new file mode 100644 index 0000000000..e037b931b6 --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/README.md @@ -0,0 +1,52 @@ +# Tab Group Block + +The motivating example for the combinator content schema design — a tab +group that combines all three variable-shape combinators: + +```ts +const tabsContentType = combinatorContentType( + "tabs", + c.list( + c.props( + { label: { default: "Tab" } }, + c.blocks(), + ), + ), +); +``` + +- `c.list` — variable-arity sequence of items +- `c.props` — each item carries its own typed `label` attribute +- `c.blocks` — each tab body is a stretch of full editor blocks + +The block's JSON `content` shape is automatically derived from the schema: + +```json +[ + { + "props": { "label": "Overview" }, + "content": [ + { "type": "heading", "props": { "level": 3 }, "content": [...] }, + { "type": "paragraph", "content": [...] } + ] + }, + { + "props": { "label": "Details" }, + "content": [...] + } +] +``` + +**Try it:** + +- Click a tab label to switch tabs (React state controls visibility; the + underlying ProseMirror document holds all tabs). +- Click "+ Add tab" to grow the list. +- Edit the label by clicking it; press Enter to commit. +- Inside a tab body, hit `/` for the slash menu, or just type — any block the + editor knows about can live inside a tab body. + +**Relevant Docs:** + +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/12-tab-group-block/index.html b/examples/06-custom-schema/12-tab-group-block/index.html new file mode 100644 index 0000000000..43f6c5f016 --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/index.html @@ -0,0 +1,14 @@ + + + + + Tab Group Block + + + +
+ + + diff --git a/examples/06-custom-schema/12-tab-group-block/main.tsx b/examples/06-custom-schema/12-tab-group-block/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-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/12-tab-group-block/package.json b/examples/06-custom-schema/12-tab-group-block/package.json new file mode 100644 index 0000000000..1263f47cd8 --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/package.json @@ -0,0 +1,32 @@ +{ + "name": "@blocknote/example-custom-schema-tab-group-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "@mantine/utils": "^6.0.22", + "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": "^8.0.8" + } +} \ No newline at end of file diff --git a/examples/06-custom-schema/12-tab-group-block/src/App.tsx b/examples/06-custom-schema/12-tab-group-block/src/App.tsx new file mode 100644 index 0000000000..afb81d7a5a --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/src/App.tsx @@ -0,0 +1,152 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { useEffect, useState } from "react"; + +import { createTabs } from "./Tabs.js"; +import "./styles.css"; + +const schema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + tabs: createTabs(), + }, +}); + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "A tab group whose content is a list of items, each carrying a typed `label` prop and a body of editor blocks:", + }, + { + type: "tabs" as const, + content: [ + { + props: { label: "Overview" }, + content: [ + { + type: "heading", + props: { level: 3 }, + content: [ + { type: "text", text: "Combinator content schemas", styles: {} }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: + "This tab group composes three combinators: list of (props of (blocks)).", + styles: {}, + }, + ], + }, + ], + }, + { + props: { label: "Schema" }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "Schema declaration:", + styles: {}, + }, + ], + }, + { + type: "codeBlock", + content: [ + { + type: "text", + text: + 'c.list(\n c.props(\n { label: { default: "Tab" } },\n c.blocks(),\n ),\n)', + styles: {}, + }, + ], + }, + ], + }, + { + props: { label: "Try it" }, + content: [ + { + type: "bulletListItem", + content: [ + { type: "text", text: "Click a label to switch tabs.", styles: {} }, + ], + }, + { + type: "bulletListItem", + content: [ + { + type: "text", + text: "Double-click a label to rename.", + styles: {}, + }, + ], + }, + { + type: "bulletListItem", + content: [ + { + type: "text", + text: "Click + Add tab to grow the list.", + styles: {}, + }, + ], + }, + { + type: "bulletListItem", + content: [ + { + type: "text", + text: + "Inside a tab body, hit `/` for the slash menu — the body is a real block region.", + styles: {}, + }, + ], + }, + ], + }, + ] as any, + } as any, + { + type: "paragraph", + content: + "Watch the JSON below as you switch tabs and edit content — the array shape mirrors the schema exactly.", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ setBlocks(editor.document)} + /> +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/12-tab-group-block/src/Tabs.tsx b/examples/06-custom-schema/12-tab-group-block/src/Tabs.tsx new file mode 100644 index 0000000000..8da1a1bd05 --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/src/Tabs.tsx @@ -0,0 +1,170 @@ +import { c, combinatorContentType } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +import "./styles.css"; + +// The motivating combinator stack: a list of items, each carrying its own +// typed `label` prop and a body of full editor blocks. The Block.content JSON +// shape is automatically derived from the schema: +// Array<{ props: { label: string }; content: Block[] }> +const tabsContentType = combinatorContentType( + "tabs", + c.list( + c.props( + { label: { default: "Tab" } }, + c.blocks(), + ), + ), +); + +type TabItem = { props: { label: string }; content: any[] }; + +export const createTabs = createReactBlockSpec( + { + type: "tabs", + propSchema: {}, + content: tabsContentType, + }, + { + render: (props) => { + const tabs = (props.block.content as unknown as TabItem[]) ?? []; + + // React state for the visible tab. The full document (every tab's + // content) lives in ProseMirror; this state only controls what the + // user sees, via inline `display` styles applied in a layout effect + // below. + const [active, setActive] = useState(0); + const activeIndex = Math.min(active, Math.max(tabs.length - 1, 0)); + + // Inline-style toggle of which tab body is visible. We use inline + // styles (not CSS) because Tiptap's React node view inserts a + // `[data-node-view-content-react]` wrapper between this ref and the + // tab item nodes — and inline styles are robust to that, where + // `nth-child` / `>` selectors can drift if Tiptap's wrapper layout + // ever changes. + const bodyRef = useRef(null); + const setBodyRefs = useCallback( + (el: HTMLDivElement | null) => { + bodyRef.current = el; + props.contentRef(el); + }, + [props.contentRef], + ); + useLayoutEffect(() => { + const root = bodyRef.current; + if (!root) { + return; + } + const items = root.querySelectorAll( + '[data-content-name="tabs__item"]', + ); + items.forEach((el, i) => { + el.style.display = i === activeIndex ? "" : "none"; + }); + }); + + const updateContent = (next: TabItem[], focusIndex?: number) => { + props.editor.updateBlock(props.block, { + type: "tabs", + content: next as any, + }); + if (focusIndex !== undefined) { + setActive(focusIndex); + } + }; + + const addTab = () => { + const next: TabItem[] = [ + ...tabs, + { + props: { label: `Tab ${tabs.length + 1}` }, + content: [ + { + type: "paragraph", + content: [ + { + type: "text", + text: "New tab — type away.", + styles: {}, + }, + ], + }, + ], + }, + ]; + updateContent(next, next.length - 1); + }; + + const removeTab = (i: number) => { + if (tabs.length <= 1) { + return; + } + const next = tabs.filter((_, idx) => idx !== i); + updateContent(next, Math.max(0, Math.min(activeIndex, next.length - 1))); + }; + + const renameTab = (i: number) => { + const current = tabs[i].props.label; + // eslint-disable-next-line no-alert + const next = window.prompt("Rename tab", current); + if (next === null || next.trim() === "") { + return; + } + const updated = tabs.map((t, idx) => + idx === i ? { ...t, props: { ...t.props, label: next } } : t, + ); + updateContent(updated); + }; + + return ( +
+ {/* + Tab bar — non-editable; clicks switch React's `active` index. Each + label is sourced from the corresponding item's `props.label` (a + Tiptap attr stored on the tab item node). + */} +
+ {tabs.map((tab, i) => ( +
+ + {tabs.length > 1 && ( + + )} +
+ ))} + +
+ {/* + Tab bodies — every tab's body is in the DOM; the layout effect + above toggles `display` so only the active one is visible. Each + body is a real block region: hit `/` inside it for the slash + menu, drag/drop blocks, etc. + */} +
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/12-tab-group-block/src/styles.css b/examples/06-custom-schema/12-tab-group-block/src/styles.css new file mode 100644 index 0000000000..99f1bc79e3 --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/src/styles.css @@ -0,0 +1,164 @@ +/* App layout */ +.wrapper { + display: flex; + flex-direction: column; + gap: 1rem; + 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; +} + +/* Tab group chrome */ +.tabs { + flex-grow: 1; + display: flex; + flex-direction: column; + border-radius: 8px; + border: 1px solid #d8e0ea; + background-color: #fafbfd; + overflow: hidden; +} + +[data-color-scheme="dark"] .tabs { + border-color: #2c3441; + background-color: #1a1f29; +} + +/* Tab bar — non-editable; sits above the bodies. */ +.tabs-bar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 6px 8px 0; + border-bottom: 1px solid #d8e0ea; + background-color: #eef2f7; + user-select: none; +} + +[data-color-scheme="dark"] .tabs-bar { + background-color: #232a36; + border-bottom-color: #2c3441; +} + +.tab-button-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.tab-button { + font: inherit; + font-size: 0.9em; + font-weight: 500; + color: #4a5568; + background-color: transparent; + border: 1px solid transparent; + border-bottom: none; + padding: 6px 28px 6px 12px; + border-radius: 6px 6px 0 0; + cursor: pointer; + margin-bottom: -1px; + transition: background-color 0.1s; +} + +[data-color-scheme="dark"] .tab-button { + color: #cbd5e1; +} + +.tab-button:hover { + background-color: #dde4ee; +} + +[data-color-scheme="dark"] .tab-button:hover { + background-color: #2c3441; +} + +.tab-button-active, +.tab-button-active:hover { + background-color: #fafbfd; + border-color: #d8e0ea; + color: #1a202c; + cursor: default; +} + +[data-color-scheme="dark"] .tab-button-active, +[data-color-scheme="dark"] .tab-button-active:hover { + background-color: #1a1f29; + border-color: #2c3441; + color: #f0f4fa; +} + +.tab-close { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font: inherit; + font-size: 0.9em; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + background-color: transparent; + color: #94a3b8; + border: none; + border-radius: 8px; + cursor: pointer; + padding: 0; +} + +.tab-close:hover { + background-color: #cbd5e1; + color: #1a202c; +} + +[data-color-scheme="dark"] .tab-close:hover { + background-color: #475569; + color: #f0f4fa; +} + +.tab-add, +.tab-add:hover { + margin-right: 4px; + padding-right: 12px; + background-color: transparent; + border: 1px dashed transparent; + color: #4a5568; +} + +.tab-add:hover { + border-color: #94a3b8; + background-color: #dde4ee; +} + +[data-color-scheme="dark"] .tab-add:hover { + border-color: #475569; + background-color: #2c3441; +} + +/* Tab bodies. Every tab's body lives in the DOM; the React component toggles + `display` inline on each item via a layout effect (see Tabs.tsx). */ +.tabs-bodies { + flex-grow: 1; + padding: 12px 16px; + min-height: 60px; +} diff --git a/examples/06-custom-schema/12-tab-group-block/tsconfig.json b/examples/06-custom-schema/12-tab-group-block/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "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/" + } + ] +} \ No newline at end of file diff --git a/examples/06-custom-schema/12-tab-group-block/vite.config.ts b/examples/06-custom-schema/12-tab-group-block/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/06-custom-schema/12-tab-group-block/vite.config.ts @@ -0,0 +1,32 @@ +// 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"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + 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), + }, +})); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 4318b19ca7..157ccc485b 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -15,15 +15,13 @@ import type { } from "../../../../schema/blocks/types.js"; import type { InlineContentSchema } from "../../../../schema/inlineContent/types.js"; import type { StyleSchema } from "../../../../schema/styles/types.js"; -import { UnreachableCaseError } from "../../../../util/typescript.js"; import { type BlockInfo, getBlockInfoFromResolvedPos, } from "../../../getBlockInfoFromPos.js"; import { + blockContentToNodes, blockToNode, - inlineContentToNodes, - tableContentToNodes, } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; @@ -178,22 +176,11 @@ function updateBlockContentNode< // Has there been any custom content provided? if (block.content) { - if (typeof block.content === "string") { - // Adds a single text node with no marks to the content. - content = inlineContentToNodes( - [block.content], - pmSchema, - newNodeType.name, - ); - } else if (Array.isArray(block.content)) { - // Adds a text node with the provided styles converted into marks to the content, - // for each InlineContent object. - content = inlineContentToNodes(block.content, pmSchema, newNodeType.name); - } else if (block.content.type === "tableContent") { - content = tableContentToNodes(block.content, pmSchema); - } else { - throw new UnreachableCaseError(block.content.type); - } + content = blockContentToNodes( + block.content as PartialBlock["content"], + pmSchema, + newNodeType.name, + ); } else { // no custom content has been provided, use existing content IF possible // Since some block types contain inline content and others don't, diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index e5bd761918..2986edf599 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -1,6 +1,7 @@ import { TextSelection, type Transaction } from "prosemirror-state"; import { TableMap } from "prosemirror-tables"; import { Block } from "../../../blocks/defaultBlocks.js"; +import { tableContentType } from "../../../blocks/Table/block.js"; import { Selection } from "../../../editor/selectionTypes.js"; import { BlockIdentifier, @@ -186,7 +187,7 @@ export function setSelection( let startPos: number; let endPos: number; - if (anchorBlockConfig.content === "table") { + if (anchorBlockConfig.content === tableContentType) { const tableMap = TableMap.get(anchorBlockInfo.blockContent.node); const firstCellPos = anchorBlockInfo.blockContent.beforePos + @@ -197,7 +198,7 @@ export function setSelection( startPos = anchorBlockInfo.blockContent.beforePos + 1; } - if (headBlockConfig.content === "table") { + if (headBlockConfig.content === tableContentType) { const tableMap = TableMap.get(headBlockInfo.blockContent.node); const lastCellPos = headBlockInfo.blockContent.beforePos + diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts index 83f5340698..36ecd84416 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -4,6 +4,7 @@ import { TextSelection, type Transaction, } from "prosemirror-state"; +import { tableContentType } from "../../../blocks/Table/block.js"; import type { TextCursorPosition } from "../../../editor/cursorPositionTypes.js"; import type { BlockIdentifier, @@ -35,14 +36,18 @@ export function getTextCursorPosition< // Gets next blockContainer node at the same nesting level, if the current node isn't the last child. const nextNode = tr.doc.resolve(bnBlock.afterPos).nodeAfter; - // Gets parent blockContainer node, if the current node is nested. + // Gets the nearest enclosing bnBlock ancestor, if the current block is + // nested. With the original block schema there's at most one non-bnBlock + // wrapper between blocks (a `blockGroup`), but combinator content types + // (e.g. `c.list` of records, or `c.list(c.props(c.blocks()))`) can nest + // additional slot nodes between an inner `blockContainer` and the outer + // one — so we walk up until we find a bnBlock or run out of depth. let parentNode: Node | undefined = undefined; - if (resolvedPos.depth > 1) { - // for nodes nested in bnBlocks - parentNode = resolvedPos.node(); - if (!parentNode.type.isInGroup("bnBlock")) { - // for blockGroups, we need to go one level up - parentNode = resolvedPos.node(resolvedPos.depth - 1); + for (let d = resolvedPos.depth; d > 0; d--) { + const candidate = resolvedPos.node(d); + if (candidate.type.isInGroup("bnBlock")) { + parentNode = candidate; + break; } } @@ -71,8 +76,7 @@ export function setTextCursorPosition( const info = getBlockInfo(posInfo); - const contentType: "none" | "inline" | "table" = - schema.blockSchema[info.blockNoteType]!.content; + const contentType = schema.blockSchema[info.blockNoteType]!.content; if (info.isBlockContainer) { const blockContent = info.blockContent; @@ -91,7 +95,7 @@ export function setTextCursorPosition( TextSelection.create(tr.doc, blockContent.afterPos - 1), ); } - } else if (contentType === "table") { + } else if (contentType === tableContentType) { if (placement === "start") { // Need to offset the position as we have to get through the `tableRow` // and `tableCell` nodes to get to the `tableParagraph` node we want to @@ -105,7 +109,7 @@ export function setTextCursorPosition( ); } } else { - throw new UnreachableCaseError(contentType); + throw new UnreachableCaseError(contentType as never); } } else { const child = diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index 7812ae3c2e..0582732e8c 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -83,6 +83,7 @@ function fragmentToExternalHTML< externalHTML = `${externalHTMLExporter.exportInlineContent( ic as any, {}, + "table", )}
`; } else if (isWithinBlockContent) { // first convert selection to blocknote-style inline content, and then diff --git a/packages/core/src/api/exporters/html/externalHTMLExporter.ts b/packages/core/src/api/exporters/html/externalHTMLExporter.ts index 2149c884e7..dca26365b9 100644 --- a/packages/core/src/api/exporters/html/externalHTMLExporter.ts +++ b/packages/core/src/api/exporters/html/externalHTMLExporter.ts @@ -59,12 +59,14 @@ export const createExternalHTMLExporter = < exportInlineContent: ( inlineContent: InlineContent[], options: { document?: Document }, + blockType?: string, ) => { const domFragment = serializeInlineContentExternalHTML( editor, inlineContent as any, serializer, options, + blockType, ); const parent = document.createElement("div"); diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 894c0f1c1b..5b56f4aaac 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -1,4 +1,4 @@ -import { DOMSerializer, Fragment, Node } from "prosemirror-model"; +import { DOMSerializer, Fragment } from "prosemirror-model"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; @@ -8,11 +8,7 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; -import { UnreachableCaseError } from "../../../../util/typescript.js"; -import { - inlineContentToNodes, - tableContentToNodes, -} from "../../../nodeConversions/blockToNode.js"; +import { blockContentToNodes } from "../../../nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js"; function addAttributesAndRemoveClasses(element: HTMLElement) { @@ -38,21 +34,23 @@ export function serializeInlineContentExternalHTML< blockContent: PartialBlock["content"], serializer: DOMSerializer, options?: { document?: Document }, + blockType: string = "paragraph", ) { - let nodes: Node[]; - - // TODO: reuse function from nodeconversions? if (!blockContent) { throw new Error("blockContent is required"); - } else if (typeof blockContent === "string") { - nodes = inlineContentToNodes([blockContent], editor.pmSchema); - } else if (Array.isArray(blockContent)) { - nodes = inlineContentToNodes(blockContent, editor.pmSchema); - } else if (blockContent.type === "tableContent") { - nodes = tableContentToNodes(blockContent, editor.pmSchema); - } else { - throw new UnreachableCaseError(blockContent.type); } + // External HTML export historically parses `\n` inside text as hard breaks + // even for code blocks (so HTML output uses `
`). Pass + // `inlineBlockType: undefined` to preserve that behavior; `blockType` itself + // is still threaded through for content-type lookup of structured content + // (e.g. tables). + const nodes = blockContentToNodes( + blockContent as PartialBlock["content"], + editor.pmSchema, + blockType, + undefined, + { inlineBlockType: undefined }, + ); // Check if any of the nodes are custom inline content with toExternalHTML const doc = options?.document ?? document; @@ -263,6 +261,7 @@ function serializeBlock< block.content as any, // TODO serializer, options, + block.type, ); ret.contentDOM.appendChild(ic); diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..2e5b7497a9 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -1,4 +1,4 @@ -import { DOMSerializer, Fragment, Node } from "prosemirror-model"; +import { DOMSerializer, Fragment } from "prosemirror-model"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; @@ -7,11 +7,7 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; -import { UnreachableCaseError } from "../../../../util/typescript.js"; -import { - inlineContentToNodes, - tableContentToNodes, -} from "../../../nodeConversions/blockToNode.js"; +import { blockContentToNodes } from "../../../nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js"; export function serializeInlineContentInternalHTML< @@ -25,20 +21,14 @@ export function serializeInlineContentInternalHTML< blockType?: string, options?: { document?: Document }, ) { - let nodes: Node[]; - - // TODO: reuse function from nodeconversions? if (!blockContent) { throw new Error("blockContent is required"); - } else if (typeof blockContent === "string") { - nodes = inlineContentToNodes([blockContent], editor.pmSchema, blockType); - } else if (Array.isArray(blockContent)) { - nodes = inlineContentToNodes(blockContent, editor.pmSchema, blockType); - } else if (blockContent.type === "tableContent") { - nodes = tableContentToNodes(blockContent, editor.pmSchema); - } else { - throw new UnreachableCaseError(blockContent.type); } + const nodes = blockContentToNodes( + blockContent as PartialBlock["content"], + editor.pmSchema, + blockType ?? "paragraph", + ); // Check if any of the nodes are custom inline content with toExternalHTML const doc = options?.document ?? document; diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 206ff8d9fd..310bcea3a3 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -19,7 +19,12 @@ import { import { getColspan, isPartialTableCell } from "../../util/table.js"; import { UnreachableCaseError } from "../../util/typescript.js"; import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js"; -import { getStyleSchema } from "../pmUtil.js"; +import { + getBlockSchema, + getInlineContentSchema, + getStyleSchema, +} from "../pmUtil.js"; +import { contentNodeToInlineContent, nodeToBlock } from "./nodeToBlock.js"; /** * Convert a StyledText inline element to a @@ -272,6 +277,83 @@ export function tableContentToNodes< return rowNodes; } +/** + * Convert any BlockNote block content (inline-content array, string, or a + * content-type structured value like a table) to ProseMirror child nodes. + * + * Dispatches based on the JSON shape and the block schema's registered + * {@link ContentType}. This is the canonical conversion entry-point used by + * `blockOrInlineContentToContentNode` (which wraps the result in a parent + * node), and is also called directly by callers that need the raw nodes + * (e.g. `updateBlock`, the HTML serializers). + * + * `blockType` is the block-schema lookup key for content-type dispatch. + * + * `options.inlineBlockType` controls hard-break parsing inside inline-content + * regions: for code blocks the editor preserves literal `\n` characters (no + * hard-break nodes), but external HTML export deliberately *does* want hard + * breaks even inside code. The option is set via an options bag rather than a + * defaulted positional parameter because JS default parameters fire when + * `undefined` is passed explicitly, making it impossible to distinguish + * "omitted" from "deliberately undefined". Defaults to `blockType` when the + * option is not present. + */ +export function blockContentToNodes( + content: PartialBlock["content"], + schema: Schema, + blockType: string, + styleSchema: StyleSchema = getStyleSchema(schema), + options: { inlineBlockType?: string | undefined } = {}, +): Node[] { + const inlineBlockType = + "inlineBlockType" in options ? options.inlineBlockType : blockType; + if (content === undefined) { + return []; + } + // ContentType dispatch comes before the array shortcut: a `c.list()`-backed + // block also has an array-shaped JSON content (e.g. tabs are an array of + // tab items), but those arrays must route through the content type's + // `jsonToNodes` rather than being treated as inline content. + const blockSchema = getBlockSchema(schema); + const blockConfig = blockSchema?.[blockType]; + const contentType = blockConfig?.content; + if ( + contentType && + typeof contentType === "object" && + "jsonToNodes" in contentType + ) { + const inlineContentSchema = getInlineContentSchema(schema); + return [ + ...contentType.jsonToNodes(content, { + schema, + inlineContentSchema, + styleSchema, + contentNodeToInlineContent: (n) => + contentNodeToInlineContent(n, inlineContentSchema, styleSchema), + inlineContentToNodes: (c, bt) => + inlineContentToNodes(c, schema, bt, styleSchema), + nodeToBlock: (n) => nodeToBlock(n as Node, schema), + blockToNode: (b) => + blockToNode(b as PartialBlock, schema, styleSchema), + }), + ]; + } + if (typeof content === "string") { + return inlineContentToNodes( + [content], + schema, + inlineBlockType, + styleSchema, + ); + } + if (Array.isArray(content)) { + return inlineContentToNodes(content, schema, inlineBlockType, styleSchema); + } + throw new Error( + `Block type "${blockType}" has unknown content shape; cannot convert.`, + ); +} + function blockOrInlineContentToContentNode( block: | PartialBlock @@ -279,7 +361,6 @@ function blockOrInlineContentToContentNode( schema: Schema, styleSchema: StyleSchema, ) { - let contentNode: Node; let type = block.type; // TODO: needed? came from previous code @@ -292,30 +373,15 @@ function blockOrInlineContentToContentNode( } if (!block.content) { - contentNode = schema.nodes[type].createChecked(block.props); - } else if (typeof block.content === "string") { - const nodes = inlineContentToNodes( - [block.content], - schema, - type, - styleSchema, - ); - contentNode = schema.nodes[type].createChecked(block.props, nodes); - } else if (Array.isArray(block.content)) { - const nodes = inlineContentToNodes( - block.content, - schema, - type, - styleSchema, - ); - contentNode = schema.nodes[type].createChecked(block.props, nodes); - } else if (block.content.type === "tableContent") { - const nodes = tableContentToNodes(block.content, schema, styleSchema); - contentNode = schema.nodes[type].createChecked(block.props, nodes); - } else { - throw new UnreachableCaseError(block.content.type); + return schema.nodes[type].createChecked(block.props); } - return contentNode; + const nodes = blockContentToNodes( + block.content as PartialBlock["content"], + schema, + type, + styleSchema, + ); + return schema.nodes[type].createChecked(block.props, nodes); } /** diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 5048f91a2b..6b59c1dd41 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -13,6 +13,7 @@ import type { TableCell, TableContent, } from "../../schema/index.js"; +import { blockToNode, inlineContentToNodes } from "./blockToNode.js"; import { isLinkInlineContent, isStyledTextInlineContent, @@ -467,19 +468,29 @@ export function nodeToBlock< inlineContentSchema, styleSchema, ); - } else if (blockConfig.content === "table") { + } else if (blockConfig.content === "none") { + content = undefined; + } else if ( + typeof blockConfig.content === "object" && + blockConfig.content !== null && + "nodeToJSON" in blockConfig.content + ) { if (!blockInfo.isBlockContainer) { throw new Error("impossible"); } - content = contentNodeToTableContent( - blockInfo.blockContent.node, + content = blockConfig.content.nodeToJSON(blockInfo.blockContent.node, { + schema, inlineContentSchema, styleSchema, - ); - } else if (blockConfig.content === "none") { - content = undefined; + contentNodeToInlineContent: (n) => + contentNodeToInlineContent(n, inlineContentSchema, styleSchema), + inlineContentToNodes: (c, blockType) => + inlineContentToNodes(c, schema, blockType, styleSchema), + nodeToBlock: (n) => nodeToBlock(n as Node, schema), + blockToNode: (b) => blockToNode(b as any, schema, styleSchema), + }); } else { - throw new UnreachableCaseError(blockConfig.content); + throw new UnreachableCaseError(blockConfig.content as never); } const block = { diff --git a/packages/core/src/blocks/Table/block.ts b/packages/core/src/blocks/Table/block.ts index c71d9ffb7d..71934311de 100644 --- a/packages/core/src/blocks/Table/block.ts +++ b/packages/core/src/blocks/Table/block.ts @@ -3,12 +3,16 @@ import { DOMParser, Fragment, Node as PMNode, Schema } from "prosemirror-model"; import { CellSelection, TableView } from "prosemirror-tables"; import { NodeView } from "prosemirror-view"; +import { contentNodeToTableContent } from "../../api/nodeConversions/nodeToBlock.js"; +import { tableContentToNodes } from "../../api/nodeConversions/blockToNode.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; import { BlockConfig, createBlockSpecFromTiptapNode, + PartialTableContent, TableContent, } from "../../schema/index.js"; +import type { ContentType } from "../../schema/contentTypes/types.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; @@ -404,23 +408,66 @@ export type TableBlockConfig = BlockConfig< default: "default"; }; }, - "table" + typeof tableContentType >; +/** + * The table content type, packaged as a {@link ContentType} instance. + * + * This bundles the Tiptap nodes (`table` / `tableRow` / `tableCell` / + * `tableHeader` / `tableParagraph`), the column-resizing/cell-editing + * extension, and the existing PM <-> JSON conversion routines into a single + * value that satisfies the `ContentType` interface. + * + * It is currently *not yet wired into dispatch* — the conversion layer in + * `nodeToBlock` / `blockToNode` still hard-codes the `"table"` branch. This + * value exists as the abstraction landing pad: the next slice flips dispatch + * to read from a registered content type instead of switching on a string + * literal, and at that point this becomes the canonical definition of the + * table content shape. + */ +export const tableContentType: ContentType< + PartialTableContent, + TableContent +> = { + name: "table", + containerNode: TiptapTableNode, + innerNodes: [ + TiptapTableRow, + TiptapTableCell, + TiptapTableHeader, + TiptapTableParagraph, + ], + nodeToJSON(node, ctx) { + return contentNodeToTableContent( + node, + ctx.inlineContentSchema, + ctx.styleSchema, + ); + }, + jsonToNodes(json, ctx) { + return tableContentToNodes(json, ctx.schema, ctx.styleSchema); + }, + // Used by JSON.stringify and pretty-format (snapshot tests). Produces a + // concise tag rather than dumping the Tiptap node hierarchy. Distinct from + // `nodeToJSON` which performs the runtime PM-to-BlockNote-JSON conversion. + toJSON() { + return { __contentType: "table" }; + }, +}; + export const createTableBlockSpec = () => createBlockSpecFromTiptapNode( - { node: TiptapTableNode, type: "table", content: "table" }, + { node: TiptapTableNode, type: "table", content: tableContentType }, tablePropSchema, [ + // The table inner nodes (row/cell/header/paragraph) are registered + // automatically from `tableContentType.innerNodes`. Only the upstream + // `TableExtension` (column resizing, cell selection plugins) needs to be + // wired up explicitly here. createExtension({ key: "table-extensions", - tiptapExtensions: [ - TableExtension, - TiptapTableParagraph, - TiptapTableHeader, - TiptapTableCell, - TiptapTableRow, - ], + tiptapExtensions: [TableExtension], }), // Extension for keyboard shortcut which deletes the table if it's empty // and all cells are selected. Uses a separate extension as it needs diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ca6e0b4817..b9dfa8ba85 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -560,6 +560,11 @@ export class BlockNoteEditor< ); } const schema = getSchema(tiptapOptions.extensions!); + // `blockContentToNodes` (called from `blockToNode`) looks up the block + // schema via `schema.cached.blockNoteEditor`, but the back-pointer below + // on `this.pmSchema` isn't set until after the TiptapEditor is created. + // For initial-content conversion we wire it up on the temp schema here. + schema.cached.blockNoteEditor = this; const pmNodes = initialContent.map((b) => blockToNode(b, schema, this.schema.styleSchema).toJSON(), ); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index bd4a517900..62ac6754e1 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -30,10 +30,7 @@ function setSelectionToNextContentEditableBlock< if (block === undefined) { return; } - contentType = editor.schema.blockSchema[block.type].content as - | "inline" - | "table" - | "none"; + contentType = editor.schema.blockSchema[block.type].content; editor.setTextCursorPosition(block, "end"); } } diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..f03523c8c9 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -15,6 +15,7 @@ import { import { BlockConfig, BlockConfigOrCreator, + BlockContent, BlockImplementation, BlockImplementationOrCreator, BlockSpec, @@ -44,7 +45,7 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) { export function getParseRules< TName extends string, TProps extends PropSchema, - TContent extends "inline" | "none" | "table", + TContent extends BlockContent, >( config: BlockConfig, implementation: BlockImplementation, @@ -140,13 +141,18 @@ export function getParseRules< export function addNodeAndExtensionsToSpec< TName extends string, TProps extends PropSchema, - TContent extends "inline" | "none" | "table", + TContent extends BlockContent, >( blockConfig: BlockConfig, blockImplementation: BlockImplementation, extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, ): LooseBlockSpec { + // For string-discriminator content modes, we auto-generate a Tiptap node + // here. For ContentType-based content, the spec is required to provide a + // pre-built node via `blockImplementation.node` (this is what + // `createBlockSpecFromTiptapNode` does for the table block); the + // construction branch below is unreachable in that case. const node = ((blockImplementation as any).node as Node) || Node.create({ @@ -155,7 +161,7 @@ export function addNodeAndExtensionsToSpec< ? "inline*" : blockConfig.content === "none" ? "" - : blockConfig.content) as TContent extends "inline" ? "inline*" : "", + : "") as TContent extends "inline" ? "inline*" : "", group: "blockContent", selectable: blockImplementation.meta?.selectable ?? true, isolating: blockImplementation.meta?.isolating ?? true, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index eed8cf9fa3..67cc0b44a4 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -1,7 +1,10 @@ import { Attribute, Attributes, Editor, Node } from "@tiptap/core"; import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.js"; +import { + createExtension, + type ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { InlineContentSchema } from "../inlineContent/types.js"; @@ -9,6 +12,7 @@ import { PropSchema, Props } from "../propTypes.js"; import { StyleSchema } from "../styles/types.js"; import { BlockConfig, + BlockContent, BlockSchemaWithBlock, LooseBlockSpec, SpecificBlock, @@ -197,7 +201,7 @@ export function createBlockSpecFromTiptapNode< const T extends { node: Node; type: string; - content: "inline" | "table" | "none"; + content: BlockContent; }, P extends PropSchema, >( @@ -205,6 +209,29 @@ export function createBlockSpecFromTiptapNode< propSchema: P, extensions?: ExtensionFactoryInstance[], ): LooseBlockSpec { + // If the block uses a `ContentType` whose container references additional + // Tiptap nodes (e.g. table cells, combinator-derived slot nodes), register + // them automatically so the block author doesn't have to wire them up by + // hand. The content type may also expose explicit BlockNote `extensions` + // (e.g. table column resizing) — those are appended too. + const mergedExtensions: ExtensionFactoryInstance[] = []; + if ( + typeof config.content === "object" && + config.content !== null && + "innerNodes" in config.content && + config.content.innerNodes.length > 0 + ) { + mergedExtensions.push( + createExtension({ + key: `${config.content.name}-content-type-nodes`, + tiptapExtensions: [...config.content.innerNodes], + }), + ); + } + if (extensions) { + mergedExtensions.push(...extensions); + } + return { config: { type: config.type as T["type"], @@ -216,6 +243,6 @@ export function createBlockSpecFromTiptapNode< render: defaultBlockToHTML, toExternalHTML: defaultBlockToHTML, }, - extensions, + extensions: mergedExtensions, }; } diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index ba3da8b737..c1f725eacf 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -8,6 +8,7 @@ import type { Extension, ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; +import type { ContentType } from "../contentTypes/types.js"; import type { InlineContent, InlineContentSchema, @@ -16,6 +17,16 @@ import type { import type { PropSchema, Props } from "../propTypes.js"; import type { StyleSchema } from "../styles/types.js"; +/** + * The full set of values that can appear in {@link BlockConfig#content}. + * + * - `"inline"` — a single rich-text region (the most common case). + * - `"none"` — the block has no editable content. + * - A {@link ContentType} instance — a custom content shape (the table block + * uses this; other blocks can register their own). + */ +export type BlockContent = "inline" | "none" | ContentType; + export type BlockNoteDOMElement = | "editor" | "block" @@ -67,7 +78,7 @@ export interface BlockConfigMeta { export interface BlockConfig< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends BlockContent = BlockContent, > { /** * The type of the block (unique identifier within a schema) @@ -79,11 +90,10 @@ export interface BlockConfig< */ readonly propSchema: PS; /** - * The content that the block supports + * The content that the block supports. Either `"inline"`, `"none"`, or a + * {@link ContentType} instance describing a custom content shape. */ content: C; - // TODO: how do you represent things that have nested content? - // e.g. tables, alerts (with title & content) } /** @@ -93,7 +103,7 @@ export interface BlockConfig< export type BlockConfigOrCreator< TName extends string = string, TProps extends PropSchema = PropSchema, - TContent extends "inline" | "none" = "inline" | "none", + TContent extends BlockContent = BlockContent, TOptions extends Record | undefined = | Record | undefined, @@ -108,8 +118,8 @@ export type BlockConfigOrCreator< */ export type ExtractBlockConfigFromConfigOrCreator< ConfigOrCreator extends - | BlockConfig - | ((...args: any[]) => BlockConfig), + | BlockConfig + | ((...args: any[]) => BlockConfig), > = ConfigOrCreator extends (...args: any[]) => infer Config ? Config : ConfigOrCreator; @@ -125,7 +135,7 @@ export type CustomBlockConfig< export type BlockSpec< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends BlockContent = BlockContent, > = { config: BlockConfig; implementation: BlockImplementation; @@ -139,7 +149,7 @@ export type BlockSpec< export type BlockSpecOrCreator< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends BlockContent = BlockContent, TOptions extends Record | undefined = | Record | undefined, @@ -167,7 +177,7 @@ export type ExtractBlockSpecFromSpecOrCreator< export type LooseBlockSpec< T extends string = string, PS extends PropSchema = PropSchema, - C extends "inline" | "none" | "table" = "inline" | "none" | "table", + C extends BlockContent = BlockContent, > = { config: BlockConfig; implementation: Omit< @@ -334,10 +344,10 @@ export type BlockFromConfigNoChildren< props: Props; content: B["content"] extends "inline" ? InlineContent[] - : B["content"] extends "table" - ? TableContent - : B["content"] extends "none" - ? undefined + : B["content"] extends "none" + ? undefined + : B["content"] extends ContentType + ? TJSONOut : never; }; @@ -418,10 +428,10 @@ type PartialBlockFromConfigNoChildren< props?: Partial>; content?: B["content"] extends "inline" ? PartialInlineContent - : B["content"] extends "table" - ? PartialTableContent - : B["content"] extends "none" - ? undefined + : B["content"] extends "none" + ? undefined + : B["content"] extends ContentType + ? TJSONIn : never; }; @@ -472,7 +482,7 @@ export type BlockIdentifier = { id: string } | string; export type BlockImplementation< TName extends string = string, TProps extends PropSchema = PropSchema, - TContent extends "inline" | "none" | "table" = "inline" | "none" | "table", + TContent extends BlockContent = BlockContent, > = { /** * Metadata diff --git a/packages/core/src/schema/contentTypes/combinators/factory.test.ts b/packages/core/src/schema/contentTypes/combinators/factory.test.ts new file mode 100644 index 0000000000..39f44a57ea --- /dev/null +++ b/packages/core/src/schema/contentTypes/combinators/factory.test.ts @@ -0,0 +1,633 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { createBlockSpecFromTiptapNode } from "../../blocks/internal.js"; +import { combinatorContentType } from "./factory.js"; +import { c } from "./types.js"; + +/** + * End-to-end smoke test for the combinator content-schema pipeline: + * + * 1. Build a custom block whose content is `record({ title, body })` of inline + * regions, compiled through `combinatorContentType`. + * 2. Mount an editor with this block in its schema. + * 3. Insert a block instance with title and body content as JSON. + * 4. Read the document back and verify the JSON shape matches the schema. + * + * This is the proof point that the design works end-to-end at the data layer: + * a POJO content schema → ProseMirror nodes → JSON, round-tripped through a + * real editor. + */ +describe("combinatorContentType: alert (record of inlines)", () => { + // Build a content schema and content type for a multi-slot alert block. + const alertSchema = c.record({ + title: c.inline(), + body: c.inline(), + }); + const alertContentType = combinatorContentType("demoAlert", alertSchema); + + const demoAlertBlockSpec = createBlockSpecFromTiptapNode( + { + node: alertContentType.containerNode, + type: "demoAlert", + content: alertContentType, + }, + {}, + ); + + let editor: BlockNoteEditor; + let div: HTMLElement; + + beforeEach(() => { + div = document.createElement("div"); + editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + demoAlert: demoAlertBlockSpec, + }, + }), + }); + editor.mount(div); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + }); + + it("registers the block's container and inner Tiptap nodes", () => { + const pmSchema = editor.pmSchema; + expect(pmSchema.nodes.demoAlert).toBeDefined(); + expect(pmSchema.nodes.demoAlert__title).toBeDefined(); + expect(pmSchema.nodes.demoAlert__body).toBeDefined(); + + expect(pmSchema.nodes.demoAlert.spec.content).toBe( + "demoAlert__title demoAlert__body", + ); + expect(pmSchema.nodes.demoAlert__title.spec.content).toBe("inline*"); + expect(pmSchema.nodes.demoAlert__body.spec.content).toBe("inline*"); + }); + + it("round-trips JSON content through the editor", () => { + const inputContent = { + title: [{ type: "text", text: "Heads up", styles: { bold: true } }], + body: [{ type: "text", text: "This is the body.", styles: {} }], + }; + + editor.replaceBlocks(editor.document, [ + { + type: "demoAlert" as const, + content: inputContent as any, + } as any, + ]); + + const block = editor.document[0] as any; + expect(block.type).toBe("demoAlert"); + expect(block.content).toMatchObject({ + title: [{ type: "text", text: "Heads up", styles: { bold: true } }], + body: [{ type: "text", text: "This is the body.", styles: {} }], + }); + }); + + it("accepts structured content via `initialContent` (no editor warm-up)", () => { + // Regression: `BlockNoteEditor.create()` converts `initialContent` to PM + // nodes through `blockToNode` -> `blockContentToNodes` before its back- + // pointer onto `pmSchema.cached.blockNoteEditor` is wired up. Earlier + // tests round-tripped via `editor.replaceBlocks` *after* creation, which + // missed this path. With the fix, structured content in `initialContent` + // round-trips just like content inserted later. + const localDiv = document.createElement("div"); + const localEditor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + demoAlert: demoAlertBlockSpec, + }, + }), + initialContent: [ + { + type: "demoAlert" as const, + content: { + title: [{ type: "text", text: "Initial title", styles: {} }], + body: [{ type: "text", text: "Initial body", styles: {} }], + } as any, + } as any, + ], + }); + localEditor.mount(localDiv); + const block = localEditor.document[0] as any; + expect(block.type).toBe("demoAlert"); + expect(block.content).toMatchObject({ + title: [{ type: "text", text: "Initial title", styles: {} }], + body: [{ type: "text", text: "Initial body", styles: {} }], + }); + localEditor._tiptapEditor.destroy(); + }); + + it("preserves content shape when retrieved after mutation", () => { + editor.replaceBlocks(editor.document, [ + { + type: "demoAlert" as const, + content: { + title: [{ type: "text", text: "First title", styles: {} }], + body: [{ type: "text", text: "First body", styles: {} }], + } as any, + } as any, + ]); + + // Edit the body via updateBlock + const original = editor.document[0] as any; + editor.updateBlock(original, { + content: { + title: [{ type: "text", text: "Edited title", styles: {} }], + body: [ + { type: "text", text: "Edited body, ", styles: {} }, + { + type: "text", + text: "with bold", + styles: { bold: true }, + }, + ], + } as any, + }); + + const updated = editor.document[0] as any; + expect(updated.content.title).toEqual([ + { type: "text", text: "Edited title", styles: {} }, + ]); + expect(updated.content.body).toEqual([ + { type: "text", text: "Edited body, ", styles: {} }, + { type: "text", text: "with bold", styles: { bold: true } }, + ]); + }); +}); + +/** + * End-to-end smoke tests for the `list` combinator: a variable-arity sequence + * of identically-shaped items. The motivating shape is a tab group, where + * each tab has a title and a body, and tabs can be added, removed, and + * reordered at runtime. + */ +describe("combinatorContentType: tabs (list of records)", () => { + const tabsSchema = c.list( + c.record({ + title: c.inline(), + body: c.inline(), + }), + ); + const tabsContentType = combinatorContentType("demoTabs", tabsSchema); + + const demoTabsBlockSpec = createBlockSpecFromTiptapNode( + { + node: tabsContentType.containerNode, + type: "demoTabs", + content: tabsContentType, + }, + {}, + ); + + let editor: BlockNoteEditor; + + beforeEach(() => { + const div = document.createElement("div"); + editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + demoTabs: demoTabsBlockSpec, + }, + }), + }); + editor.mount(div); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + }); + + it("registers the container and the per-item Tiptap nodes", () => { + const pmSchema = editor.pmSchema; + expect(pmSchema.nodes.demoTabs).toBeDefined(); + // The list compiles to a single shared item node, plus the item's children. + expect(pmSchema.nodes.demoTabs__item).toBeDefined(); + expect(pmSchema.nodes.demoTabs__item__title).toBeDefined(); + expect(pmSchema.nodes.demoTabs__item__body).toBeDefined(); + + // The list's content expression references the item node `*`-many times. + expect(pmSchema.nodes.demoTabs.spec.content).toBe("demoTabs__item*"); + // The item itself is a record of title and body. + expect(pmSchema.nodes.demoTabs__item.spec.content).toBe( + "demoTabs__item__title demoTabs__item__body", + ); + }); + + it("round-trips a list of records through the editor", () => { + const inputContent = [ + { + title: [{ type: "text", text: "Overview", styles: {} }], + body: [{ type: "text", text: "Welcome to the overview.", styles: {} }], + }, + { + title: [{ type: "text", text: "Details", styles: { bold: true } }], + body: [{ type: "text", text: "Lots of details here.", styles: {} }], + }, + { + title: [{ type: "text", text: "FAQ", styles: {} }], + body: [{ type: "text", text: "Common questions answered.", styles: {} }], + }, + ]; + + editor.replaceBlocks(editor.document, [ + { + type: "demoTabs" as const, + content: inputContent as any, + } as any, + ]); + + const block = editor.document[0] as any; + expect(block.type).toBe("demoTabs"); + expect(Array.isArray(block.content)).toBe(true); + expect(block.content).toHaveLength(3); + expect(block.content[0].title).toEqual([ + { type: "text", text: "Overview", styles: {} }, + ]); + expect(block.content[1].title).toEqual([ + { type: "text", text: "Details", styles: { bold: true } }, + ]); + expect(block.content[2].body).toEqual([ + { type: "text", text: "Common questions answered.", styles: {} }, + ]); + }); + + it("supports adding, removing, and reordering items via updateBlock", () => { + editor.replaceBlocks(editor.document, [ + { + type: "demoTabs" as const, + content: [ + { + title: [{ type: "text", text: "A", styles: {} }], + body: [{ type: "text", text: "Body A", styles: {} }], + }, + { + title: [{ type: "text", text: "B", styles: {} }], + body: [{ type: "text", text: "Body B", styles: {} }], + }, + ] as any, + } as any, + ]); + + // Reorder + add a third tab via a single update. + const original = editor.document[0] as any; + editor.updateBlock(original, { + content: [ + { + title: [{ type: "text", text: "B", styles: {} }], + body: [{ type: "text", text: "Body B", styles: {} }], + }, + { + title: [{ type: "text", text: "A", styles: {} }], + body: [{ type: "text", text: "Body A", styles: {} }], + }, + { + title: [{ type: "text", text: "C", styles: {} }], + body: [{ type: "text", text: "Body C", styles: {} }], + }, + ] as any, + }); + + const updated = editor.document[0] as any; + expect(updated.content).toHaveLength(3); + expect(updated.content[0].title[0].text).toBe("B"); + expect(updated.content[1].title[0].text).toBe("A"); + expect(updated.content[2].title[0].text).toBe("C"); + }); + + it("accepts an empty list when min is unspecified (defaults to 0)", () => { + editor.replaceBlocks(editor.document, [ + { type: "demoTabs" as const, content: [] as any } as any, + ]); + const block = editor.document[0] as any; + expect(block.content).toEqual([]); + }); +}); + +/** + * End-to-end smoke tests for the `blocks` combinator: a region whose content + * is editor blocks (paragraphs, headings, custom blocks…), not inline text. + * The motivating shapes are tab bodies, accordion panels, and callouts — + * any composite block whose interior is itself a stretch of editor blocks. + */ +describe("combinatorContentType: callout (blocks region)", () => { + // A simple callout block: its content is just a sequence of editor blocks. + const calloutSchema = c.blocks(); + const calloutContentType = combinatorContentType("demoCallout", calloutSchema); + const demoCalloutBlockSpec = createBlockSpecFromTiptapNode( + { + node: calloutContentType.containerNode, + type: "demoCallout", + content: calloutContentType, + }, + {}, + ); + + let editor: BlockNoteEditor; + + beforeEach(() => { + const div = document.createElement("div"); + editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + demoCallout: demoCalloutBlockSpec, + }, + }), + }); + editor.mount(div); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + }); + + it("uses blockContainer as the slot's content type", () => { + const pmSchema = editor.pmSchema; + expect(pmSchema.nodes.demoCallout).toBeDefined(); + // The blocks combinator references the editor's existing blockContainer + // schema; no new per-item node is generated. + expect(pmSchema.nodes.demoCallout.spec.content).toBe("blockContainer*"); + }); + + it("round-trips an array of full Block JSON through the editor", () => { + editor.replaceBlocks(editor.document, [ + { + type: "demoCallout" as const, + content: [ + { + type: "heading", + props: { level: 2 }, + content: [{ type: "text", text: "Heading inside callout", styles: {} }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "Body paragraph with ", styles: {} }, + { type: "text", text: "bold", styles: { bold: true } }, + { type: "text", text: " text.", styles: {} }, + ], + }, + ] as any, + } as any, + ]); + + const block = editor.document[0] as any; + expect(block.type).toBe("demoCallout"); + expect(Array.isArray(block.content)).toBe(true); + expect(block.content).toHaveLength(2); + + const inner1 = block.content[0]; + expect(inner1.type).toBe("heading"); + expect(inner1.props.level).toBe(2); + expect(inner1.content[0].text).toBe("Heading inside callout"); + + const inner2 = block.content[1]; + expect(inner2.type).toBe("paragraph"); + expect(inner2.content).toHaveLength(3); + expect(inner2.content[1].styles.bold).toBe(true); + }); + + it("preserves nested-block content through updateBlock", () => { + editor.replaceBlocks(editor.document, [ + { + type: "demoCallout" as const, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "First", styles: {} }], + }, + ] as any, + } as any, + ]); + + const original = editor.document[0] as any; + editor.updateBlock(original, { + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Edited", styles: {} }], + }, + { + type: "bulletListItem", + content: [{ type: "text", text: "New bullet", styles: {} }], + }, + ] as any, + }); + + const updated = editor.document[0] as any; + expect(updated.content).toHaveLength(2); + expect(updated.content[0].type).toBe("paragraph"); + expect(updated.content[0].content[0].text).toBe("Edited"); + expect(updated.content[1].type).toBe("bulletListItem"); + expect(updated.content[1].content[0].text).toBe("New bullet"); + }); +}); + +/** + * The combinator stack `c.list(c.props(_, c.blocks()))` is the motivating + * shape for a tab group: an array of items, each carrying its own `label` + * prop plus a body of editor blocks. This test exercises all three layers + * working together end-to-end (variable-arity items, per-item attrs, + * full-Block contents). + */ +describe("combinatorContentType: tabs (list of props-wrapped block regions)", () => { + const tabsSchema = c.list( + c.props( + { label: { default: "Tab" } }, + c.blocks(), + ), + ); + const tabsContentType = combinatorContentType("demoTabsRich", tabsSchema); + const demoTabsRichBlockSpec = createBlockSpecFromTiptapNode( + { + node: tabsContentType.containerNode, + type: "demoTabsRich", + content: tabsContentType, + }, + {}, + ); + + let editor: BlockNoteEditor; + + beforeEach(() => { + const div = document.createElement("div"); + editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + demoTabsRich: demoTabsRichBlockSpec, + }, + }), + }); + editor.mount(div); + }); + + afterEach(() => { + editor._tiptapEditor.destroy(); + }); + + it("compiles with `props` collapsing into the item node's attrs", () => { + const pmSchema = editor.pmSchema; + expect(pmSchema.nodes.demoTabsRich).toBeDefined(); + expect(pmSchema.nodes.demoTabsRich__item).toBeDefined(); + // c.props wrapping c.blocks doesn't introduce a new PM node — the props + // become attrs on the item node, and the item's content is blockContainer*. + expect(pmSchema.nodes.demoTabsRich.spec.content).toBe("demoTabsRich__item*"); + expect(pmSchema.nodes.demoTabsRich__item.spec.content).toBe( + "blockContainer*", + ); + expect(pmSchema.nodes.demoTabsRich__item.spec.attrs).toHaveProperty("label"); + }); + + it("round-trips the full {props, content} item shape with editor blocks", () => { + editor.replaceBlocks(editor.document, [ + { + type: "demoTabsRich" as const, + content: [ + { + props: { label: "Overview" }, + content: [ + { + type: "heading", + props: { level: 2 }, + content: [{ type: "text", text: "Welcome", styles: {} }], + }, + { + type: "paragraph", + content: [{ type: "text", text: "Intro text.", styles: {} }], + }, + ], + }, + { + props: { label: "Details" }, + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Body of ", styles: {} }, + { type: "text", text: "details", styles: { italic: true } }, + { type: "text", text: " tab.", styles: {} }, + ], + }, + ], + }, + ] as any, + } as any, + ]); + + const block = editor.document[0] as any; + expect(block.type).toBe("demoTabsRich"); + expect(block.content).toHaveLength(2); + + expect(block.content[0].props.label).toBe("Overview"); + expect(block.content[0].content).toHaveLength(2); + expect(block.content[0].content[0].type).toBe("heading"); + expect(block.content[0].content[0].props.level).toBe(2); + + expect(block.content[1].props.label).toBe("Details"); + expect(block.content[1].content[0].content[1].styles.italic).toBe(true); + }); + + it("getTextCursorPosition walks past intermediate combinator slot nodes to find the parent bnBlock", () => { + // Regression test: when the cursor is inside an inner block (paragraph) + // that lives inside a `tabs__item > tabs > blockContainer` chain, + // `getTextCursorPosition` used to walk up only one level looking for a + // bnBlock — and failed for combinator slots that introduce more than one + // intermediate node. The walk-up must keep going until it finds one. + editor.replaceBlocks(editor.document, [ + { + type: "demoTabsRich" as const, + content: [ + { + props: { label: "Only" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Inside body", styles: {} }], + }, + ], + }, + ] as any, + } as any, + ]); + + // Place the cursor at the start of the inner paragraph by id. + const tabsBlock = editor.document[0] as any; + const innerParagraph = tabsBlock.content[0].content[0]; + editor.setTextCursorPosition(innerParagraph.id, "start"); + + // Should not throw — the parent walk has to traverse `tabs__item` → `tabs` + // before reaching the outer blockContainer. + const cursor = editor.getTextCursorPosition(); + expect(cursor.block.id).toBe(innerParagraph.id); + // The parent block, walking up past the slot wrappers, is the tabs block. + expect(cursor.parentBlock?.type).toBe("demoTabsRich"); + }); + + it("supports per-tab edits via updateBlock without losing other tabs", () => { + editor.replaceBlocks(editor.document, [ + { + type: "demoTabsRich" as const, + content: [ + { + props: { label: "A" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Body A", styles: {} }], + }, + ], + }, + { + props: { label: "B" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Body B", styles: {} }], + }, + ], + }, + ] as any, + } as any, + ]); + + // Add a third tab + rename the first. + const original = editor.document[0] as any; + const current = original.content as any[]; + editor.updateBlock(original, { + content: [ + { + props: { label: "Renamed A" }, + content: current[0].content, + }, + current[1], + { + props: { label: "C" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Body C", styles: {} }], + }, + ], + }, + ] as any, + }); + + const updated = editor.document[0] as any; + expect(updated.content).toHaveLength(3); + expect(updated.content[0].props.label).toBe("Renamed A"); + expect(updated.content[0].content[0].content[0].text).toBe("Body A"); + expect(updated.content[1].props.label).toBe("B"); + expect(updated.content[2].props.label).toBe("C"); + }); +}); diff --git a/packages/core/src/schema/contentTypes/combinators/factory.ts b/packages/core/src/schema/contentTypes/combinators/factory.ts new file mode 100644 index 0000000000..e0460eae3e --- /dev/null +++ b/packages/core/src/schema/contentTypes/combinators/factory.ts @@ -0,0 +1,382 @@ +import { Node as TiptapNode } from "@tiptap/core"; +import type { Node as PMNode } from "prosemirror-model"; + +import { propsToAttributes } from "../../blocks/internal.js"; +import type { PropSchema } from "../../propTypes.js"; +import type { ContentType, ContentTypeContext } from "../types.js"; +import type { + BlocksSchema, + ContentSchema, + JSONOfSchema, + ListSchema, + PropsSchema, + RecordSchema, +} from "./types.js"; + +/** + * Compile a {@link ContentSchema} POJO into a {@link ContentType} instance: + * the Tiptap nodes the schema implies, plus bidirectional conversion between + * ProseMirror nodes and BlockNote JSON. + * + * The resulting `ContentType` plugs into `createBlockSpecFromTiptapNode` (or + * any factory that takes a content type), and the block's `Block.content` + * JSON shape is automatically derived as {@link JSONOfSchema} of the schema. + * + * Example: + * + * ```ts + * const alertContentType = combinatorContentType( + * "alert", + * c.record({ title: c.inline(), body: c.inline() }), + * ); + * ``` + * + * Currently supported combinators: `inline`, `none`, `record`, `list`, + * `blocks`, `props`. + */ +export function combinatorContentType( + blockTypeName: string, + schema: S, +): ContentType, JSONOfSchema> { + const compiled = compile(schema, blockTypeName, /* isContainer */ true, {}); + return { + name: blockTypeName, + containerNode: compiled.tiptapNode, + innerNodes: compiled.innerNodes, + nodeToJSON(node, ctx) { + return walkNodeToJSON(schema, node, ctx) as JSONOfSchema; + }, + jsonToNodes(json, ctx) { + return walkJSONToNodes(schema, json, ctx, blockTypeName); + }, + toJSON() { + return { __contentType: blockTypeName, schema }; + }, + }; +} + +interface CompiledSchema { + /** + * The Tiptap node corresponding to this schema position. For `props` (which + * has no node of its own) this is the inner schema's node with attrs added. + * For `none` this is `null` — the parent's content expression skips this + * position. + */ + tiptapNode: TiptapNode; + /** + * The name to use in the parent's content expression when referring to this + * position. For `none` this is empty (and the parent should skip it). + */ + contentExpressionAtom: string; + /** All transitively-referenced Tiptap nodes (excluding `tiptapNode`). */ + innerNodes: TiptapNode[]; +} + +function compile( + schema: ContentSchema, + nodeName: string, + isContainer: boolean, + accumulatedProps: PropSchema, +): CompiledSchema { + switch (schema.kind) { + case "inline": + return { + tiptapNode: TiptapNode.create({ + name: nodeName, + content: "inline*", + isolating: true, + ...(isContainer ? { group: "blockContent" } : {}), + addAttributes() { + return propsToAttributes(accumulatedProps); + }, + parseHTML() { + return [{ tag: `[data-content-name="${nodeName}"]` }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { ...HTMLAttributes, "data-content-name": nodeName }, 0]; + }, + }), + contentExpressionAtom: nodeName, + innerNodes: [], + }; + + case "none": + // Empty-content node; included so the parent's content expression can + // still refer to a fixed position. + return { + tiptapNode: TiptapNode.create({ + name: nodeName, + content: "", + isolating: true, + ...(isContainer ? { group: "blockContent" } : {}), + addAttributes() { + return propsToAttributes(accumulatedProps); + }, + parseHTML() { + return [{ tag: `[data-content-name="${nodeName}"]` }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { ...HTMLAttributes, "data-content-name": nodeName }]; + }, + }), + contentExpressionAtom: nodeName, + innerNodes: [], + }; + + case "record": { + const fieldEntries = Object.entries(schema.fields); + const childResults = fieldEntries.map(([fieldName, fieldSchema]) => { + const childNodeName = `${nodeName}__${fieldName}`; + const compiled = compile(fieldSchema, childNodeName, false, {}); + return { fieldName, compiled }; + }); + const contentExpression = childResults + .map((r) => r.compiled.contentExpressionAtom) + .join(" "); + const allInnerNodes = childResults.flatMap((r) => [ + r.compiled.tiptapNode, + ...r.compiled.innerNodes, + ]); + return { + tiptapNode: TiptapNode.create({ + name: nodeName, + content: contentExpression, + isolating: true, + ...(isContainer ? { group: "blockContent" } : {}), + addAttributes() { + return propsToAttributes(accumulatedProps); + }, + parseHTML() { + return [{ tag: `[data-content-name="${nodeName}"]` }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { ...HTMLAttributes, "data-content-name": nodeName }, 0]; + }, + }), + contentExpressionAtom: nodeName, + innerNodes: allInnerNodes, + }; + } + + case "list": { + // The list's children all share one item-shape, so we compile the item + // schema once and reference it `+` or `*`-many times in the parent's + // content expression. Item nodes carry no list-position attrs — order + // is implicit in PM child order — so the item's slot name is just + // `${listName}__item`. + const itemSchema = (schema as ListSchema).item; + const itemNodeName = `${nodeName}__item`; + const itemCompiled = compile(itemSchema, itemNodeName, false, {}); + // ProseMirror content expressions only natively support `*` (zero or + // more) or `+` (one or more). Higher minima would need custom validation + // beyond the schema; we keep the surface narrow for now. + const min = (schema as ListSchema).min ?? 0; + const itemQuantifier = min >= 1 ? "+" : "*"; + const contentExpression = `${itemCompiled.contentExpressionAtom}${itemQuantifier}`; + return { + tiptapNode: TiptapNode.create({ + name: nodeName, + content: contentExpression, + isolating: true, + ...(isContainer ? { group: "blockContent" } : {}), + addAttributes() { + return propsToAttributes(accumulatedProps); + }, + parseHTML() { + return [{ tag: `[data-content-name="${nodeName}"]` }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { ...HTMLAttributes, "data-content-name": nodeName }, 0]; + }, + }), + contentExpressionAtom: nodeName, + innerNodes: [itemCompiled.tiptapNode, ...itemCompiled.innerNodes], + }; + } + + case "blocks": { + // A `blocks` slot contains regular editor blocks. We piggyback on the + // editor's existing `blockContainer` schema (the same wrapper every top- + // level block lives in), so any block the editor supports — paragraphs, + // headings, custom blocks, even nested combinator blocks — can appear + // inside this slot without further plumbing. + const min = (schema as BlocksSchema).min ?? 0; + const itemQuantifier = min >= 1 ? "+" : "*"; + const contentExpression = `blockContainer${itemQuantifier}`; + return { + tiptapNode: TiptapNode.create({ + name: nodeName, + content: contentExpression, + isolating: true, + ...(isContainer ? { group: "blockContent" } : {}), + addAttributes() { + return propsToAttributes(accumulatedProps); + }, + parseHTML() { + return [{ tag: `[data-content-name="${nodeName}"]` }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { ...HTMLAttributes, "data-content-name": nodeName }, 0]; + }, + }), + contentExpressionAtom: nodeName, + // No new inner nodes — `blockContainer` is part of the core schema and + // is registered by the editor itself. + innerNodes: [], + }; + } + + case "props": { + // `props` adds attrs to the inner schema's node; no new Tiptap node. + // We carry the accumulated propSchema down into the compile of the + // inner schema, where it's merged with any inner props and emitted on + // the inner Tiptap node. + return compile( + schema.content, + nodeName, + isContainer, + { ...accumulatedProps, ...(schema as PropsSchema).propSchema }, + ); + } + } +} + +// ─── PM ↔ JSON conversion ───────────────────────────────────────────── + +function walkNodeToJSON( + schema: ContentSchema, + node: PMNode, + ctx: ContentTypeContext, +): unknown { + switch (schema.kind) { + case "inline": + return ctx.contentNodeToInlineContent(node); + + case "none": + return undefined; + + case "record": { + const out: Record = {}; + const fieldEntries = Object.entries(schema.fields); + // The record's PM node has its children in the same order as the + // declared fields. Walk in lockstep. + fieldEntries.forEach(([fieldName, fieldSchema], i) => { + const childNode = node.child(i); + out[fieldName] = walkNodeToJSON(fieldSchema, childNode, ctx); + }); + return out; + } + + case "list": { + const itemSchema = schema.item; + const items: unknown[] = []; + for (let i = 0; i < node.childCount; i++) { + items.push(walkNodeToJSON(itemSchema, node.child(i), ctx)); + } + return items; + } + + case "blocks": { + // Each child is a `blockContainer` PM node — defer to the editor's + // canonical conversion (`ctx.nodeToBlock`) so nested children, props, + // and other content types all flow through the same logic. + const blocks: unknown[] = []; + for (let i = 0; i < node.childCount; i++) { + blocks.push(ctx.nodeToBlock(node.child(i))); + } + return blocks; + } + + case "props": { + const propsObj: Record = {}; + for (const propName of Object.keys(schema.propSchema)) { + propsObj[propName] = node.attrs[propName]; + } + return { + props: propsObj, + content: walkNodeToJSON(schema.content, node, ctx), + }; + } + } +} + +function walkJSONToNodes( + schema: ContentSchema, + json: unknown, + ctx: ContentTypeContext, + nodeName: string, +): readonly PMNode[] { + const node = walkJSONToNode(schema, json, ctx, nodeName); + // The outer node IS the container; the dispatch in `blockToNode` wraps + // these nodes inside `schema.nodes[type].createChecked(props, …)`. So we + // return the *children* of the container, not the container itself. + return Array.from({ length: node.childCount }, (_, i) => node.child(i)); +} + +function walkJSONToNode( + schema: ContentSchema, + json: unknown, + ctx: ContentTypeContext, + nodeName: string, + attrs: Record = {}, +): PMNode { + const pmType = ctx.schema.nodes[nodeName]; + if (!pmType) { + throw new Error( + `Combinator content type expected ProseMirror node "${nodeName}" but it isn't registered. Did you forget to register the content type's inner nodes?`, + ); + } + + switch (schema.kind) { + case "inline": { + const inlineNodes = ctx.inlineContentToNodes( + json as never, + nodeName, + ); + return pmType.createChecked(attrs, inlineNodes); + } + + case "none": + return pmType.createChecked(attrs); + + case "record": { + const fieldEntries = Object.entries( + (schema as RecordSchema).fields, + ) as [string, ContentSchema][]; + const childNodes = fieldEntries.map(([fieldName, fieldSchema]) => { + const childJson = (json as Record)[fieldName]; + const childNodeName = `${nodeName}__${fieldName}`; + return walkJSONToNode(fieldSchema, childJson, ctx, childNodeName); + }); + return pmType.createChecked(attrs, childNodes); + } + + case "list": { + const itemSchema = (schema as ListSchema).item; + const itemNodeName = `${nodeName}__item`; + const items = (json as unknown[]) ?? []; + const childNodes = items.map((itemJson) => + walkJSONToNode(itemSchema, itemJson, ctx, itemNodeName), + ); + return pmType.createChecked(attrs, childNodes); + } + + case "blocks": { + const blocks = (json as unknown[]) ?? []; + const childNodes = blocks.map((b) => ctx.blockToNode(b)); + return pmType.createChecked(attrs, childNodes); + } + + case "props": { + const wrapped = json as { props?: Record; content: unknown }; + const mergedAttrs = { ...attrs, ...(wrapped.props ?? {}) }; + return walkJSONToNode( + (schema as PropsSchema).content, + wrapped.content, + ctx, + nodeName, + mergedAttrs, + ); + } + } +} diff --git a/packages/core/src/schema/contentTypes/combinators/types.ts b/packages/core/src/schema/contentTypes/combinators/types.ts new file mode 100644 index 0000000000..fb12265ae2 --- /dev/null +++ b/packages/core/src/schema/contentTypes/combinators/types.ts @@ -0,0 +1,158 @@ +import type { Block } from "../../../blocks/defaultBlocks.js"; +import type { + InlineContent, + InlineContentSchema, +} from "../../inlineContent/types.js"; +import type { PropSchema, Props } from "../../propTypes.js"; +import type { BlockSchema } from "../../blocks/types.js"; +import type { StyleSchema } from "../../styles/types.js"; + +/** + * A POJO description of a custom block-content shape, composed from a small + * set of combinator primitives. + * + * `ContentSchema` values are pure data — no methods, no closures — so they can + * be `JSON.stringify`'d, persisted alongside the document, validated server- + * side, and shipped between processes. This mirrors the property `propSchema` + * already has on `BlockConfig`. + * + * The discriminator is `kind`. Use the {@link c} factory namespace for + * ergonomic construction; handwritten POJOs are equally first-class. + */ +export type ContentSchema = + | InlineSchema + | NoneSchema + | RecordSchema + | ListSchema + | BlocksSchema + | PropsSchema; + +/** + * A single rich-text region. Maps to a Tiptap node with `content: "inline*"` + * and JSON `InlineContent[]`. + */ +export interface InlineSchema { + readonly kind: "inline"; +} + +/** No editable content. JSON shape: `undefined`. */ +export interface NoneSchema { + readonly kind: "none"; +} + +/** + * Fixed-shape struct of named child schemas. Maps to a Tiptap node whose + * content expression is the declared-order sequence of its children, and JSON + * `{ [fieldName]: JSONOfSchema }`. + */ +export interface RecordSchema< + F extends Record = Record, +> { + readonly kind: "record"; + readonly fields: F; +} + +/** + * Variable-arity sequence of identically-shaped child schemas. Maps to a + * Tiptap node whose content expression is `+` (or `*` if + * `min` is 0), and JSON `Array>`. + * + * Use this for tab groups, accordion panels, comment threads, stepper steps, + * and similar shapes where users add/remove/reorder items at runtime. + */ +export interface ListSchema { + readonly kind: "list"; + readonly item: I; + /** + * Minimum number of items the list must contain. When `0` (the default), the + * list may be empty (PM content expression `*`). When `1` or more, + * at least one item is always present (PM content expression `+`). + * Other values are clamped to {0, 1} at compile time — finer cardinalities + * aren't expressible in a single ProseMirror content expression. + */ + readonly min?: number; +} + +/** + * A region whose content is a sequence of full editor blocks (paragraphs, + * headings, lists, custom blocks, …). Maps to a Tiptap node whose content + * expression is `blockContainer+` (or `blockContainer*` if `min` is 0), and + * JSON `Block[]`. + * + * Use this for tab bodies, accordion panels, callouts, or any composite block + * whose interior is itself a stretch of editor blocks rather than a single + * inline region. + */ +export interface BlocksSchema { + readonly kind: "blocks"; + /** + * Minimum number of blocks the region must contain (clamped to `{0, 1}` — + * see {@link ListSchema.min}). + */ + readonly min?: number; +} + +/** + * Wraps an inner schema with named typed attributes. Adds Tiptap attrs to the + * inner node (no extra ProseMirror node). JSON shape: + * `{ props: Props

; content: JSONOfSchema }`. + */ +export interface PropsSchema< + P extends PropSchema = PropSchema, + Inner extends ContentSchema = ContentSchema, +> { + readonly kind: "props"; + readonly propSchema: P; + readonly content: Inner; +} + +/** + * Derives the canonical (full) BlockNote JSON shape from a {@link + * ContentSchema}. This is what `Block.content` becomes when a block declares + * `content: combinatorContentType(schema)`. + */ +export type JSONOfSchema< + S extends ContentSchema, + I extends InlineContentSchema = InlineContentSchema, + Sty extends StyleSchema = StyleSchema, +> = S extends InlineSchema + ? InlineContent[] + : S extends NoneSchema + ? undefined + : S extends RecordSchema + ? { [K in keyof F]: JSONOfSchema } + : S extends ListSchema + ? JSONOfSchema[] + : S extends BlocksSchema + ? Block[] + : S extends PropsSchema + ? { props: Props

; content: JSONOfSchema } + : never; + +/** + * Factory namespace for building {@link ContentSchema} values ergonomically. + * The factories are non-load-bearing — handwritten POJOs are equally valid. + */ +export const c = { + inline: (): InlineSchema => ({ kind: "inline" }), + none: (): NoneSchema => ({ kind: "none" }), + record: >( + fields: F, + ): RecordSchema => ({ kind: "record", fields }), + list: ( + item: I, + options: { min?: number } = {}, + ): ListSchema => ({ + kind: "list", + item, + ...(options.min !== undefined ? { min: options.min } : {}), + }), + blocks: (options: { min?: number } = {}): BlocksSchema => ({ + kind: "blocks", + ...(options.min !== undefined ? { min: options.min } : {}), + }), + props:

( + propSchema: P, + content: Inner, + ): PropsSchema => ({ kind: "props", propSchema, content }), +}; diff --git a/packages/core/src/schema/contentTypes/types.ts b/packages/core/src/schema/contentTypes/types.ts new file mode 100644 index 0000000000..99fa6738ea --- /dev/null +++ b/packages/core/src/schema/contentTypes/types.ts @@ -0,0 +1,133 @@ +import type { Node as TiptapNode } from "@tiptap/core"; +import type { Node as PMNode, Schema } from "prosemirror-model"; + +import type { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; +import type { + InlineContent, + InlineContentSchema, + PartialInlineContent, +} from "../inlineContent/types.js"; +import type { StyleSchema } from "../styles/types.js"; + +/** + * Helpers passed into a {@link ContentType}'s conversion callbacks. These give + * the content type access to the editor's schemas plus the standard inline- + * content conversion routines, so the callbacks don't need to import directly + * from the conversion layer. + */ +export interface ContentTypeContext { + schema: Schema; + inlineContentSchema: InlineContentSchema; + styleSchema: StyleSchema; + /** + * Convert a ProseMirror content node (something with `inline*` content) to a + * BlockNote `InlineContent[]` array. + */ + contentNodeToInlineContent: ( + node: PMNode, + ) => InlineContent[]; + /** + * Convert a BlockNote inline-content array back to ProseMirror nodes + * (text + marks + hard-breaks). The optional `blockType` is used to detect + * code blocks (which suppress hard-break parsing). + */ + inlineContentToNodes: ( + content: PartialInlineContent, + blockType?: string, + ) => PMNode[]; + /** + * Convert a `bnBlock`-group ProseMirror node (a `blockContainer` or column- + * style node) into a full BlockNote `Block` JSON object. Used by the + * `blocks` combinator to walk children when building JSON for a slot whose + * content is editor blocks rather than inline text. + */ + nodeToBlock: (node: PMNode) => unknown; + /** + * Convert a (partial) BlockNote `Block` JSON object into the corresponding + * `blockContainer` ProseMirror node. Inverse of {@link nodeToBlock}; used by + * the `blocks` combinator's reverse path. + */ + blockToNode: (block: unknown) => PMNode; +} + +/** + * Describes a custom shape of editable content within a block: the ProseMirror + * nodes that make up its internal structure, plus bidirectional conversion + * to/from BlockNote JSON. + * + * Today, `"inline"` and `"none"` are built-in content modes handled by hard- + * coded branches in the conversion layer; `"table"` is a hand-rolled special + * case. `ContentType` promotes the table-style customization into a first-class + * abstraction so any block can define its own structured content shape. + * + * The first consumer of this interface is the table block, which is being + * rebuilt on top of it without any change to its observable behavior or JSON + * shape. The interface is intentionally narrow at this stage; HTML import/ + * export hooks and richer context helpers can be added in subsequent phases. + */ +// Constrains TJSONOut <: TJSONIn so the canonical (full) JSON shape is provably +// assignable to the partial input shape — preserving the existing invariant +// that `Block` is assignable to `PartialBlock`. +export interface ContentType< + TJSONIn = unknown, + TJSONOut extends TJSONIn = TJSONIn, +> { + /** + * Unique identifier for this content type. + */ + readonly name: string; + + /** + * The Tiptap node whose content expression becomes the block's direct + * content. For the table content type, this is the `table` node with + * `content: "tableRow+"`. + */ + readonly containerNode: TiptapNode; + + /** + * Additional Tiptap nodes that the container references, transitively. For + * the table content type, this is `[tableRow, tableCell, tableHeader, + * tableParagraph]`. + */ + readonly innerNodes: readonly TiptapNode[]; + + /** + * Additional Tiptap extensions this content type needs at runtime + * (e.g. table column resizing, custom keyboard shortcuts that only make + * sense within this content shape). + */ + readonly extensions?: readonly (Extension | ExtensionFactoryInstance)[]; + + /** + * Convert a ProseMirror node of {@link containerNode}'s type into BlockNote + * JSON. Always produces the canonical (non-partial) JSON shape. + * + * Named `nodeToJSON` rather than `toJSON` to avoid colliding with the + * `JSON.stringify`/`pretty-format` `toJSON` convention, which would call + * this method with no arguments during incidental serialization. + */ + nodeToJSON(node: PMNode, ctx: ContentTypeContext): TJSONOut; + + /** + * Convert BlockNote JSON of this content type into ProseMirror child nodes + * suitable for placing inside the container node. Accepts the input-side + * JSON shape, which is typically a partial form of `TJSONOut` to match how + * `PartialBlock.content` relates to `Block.content` elsewhere. + * + * The caller wraps the result with + * `schema.nodes[containerNode.name].createChecked(props, …)`. + */ + jsonToNodes(json: TJSONIn, ctx: ContentTypeContext): readonly PMNode[]; + + /** + * Optional. Override the value used by `JSON.stringify` and snapshot-test + * pretty-printers. Defining a concise return value here keeps schema + * snapshots tidy by avoiding a full dump of the underlying Tiptap node tree. + * Has no effect on runtime PM ↔ JSON conversion (which goes through + * `nodeToJSON` / `jsonToNodes`). + */ + toJSON?(): unknown; +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 05585ab28b..aca0ce630d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,6 +1,9 @@ export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; +export * from "./contentTypes/types.js"; +export * from "./contentTypes/combinators/types.js"; +export * from "./contentTypes/combinators/factory.js"; export * from "./inlineContent/createSpec.js"; export * from "./inlineContent/internal.js"; export * from "./inlineContent/types.js"; diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 1ab1b43da8..5f6f52f757 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -6,7 +6,8 @@ import { BlockNoteEditor, BlockSpec, camelToDataKebab, - CustomBlockImplementation, + ContentType, + createExtension, Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, @@ -14,6 +15,7 @@ import { mergeCSSClasses, Props, PropSchema, + propsToAttributes, } from "@blocknote/core"; import { NodeViewProps, @@ -33,11 +35,16 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; -} & (Config["content"] extends "inline" - ? { +} & (Config["content"] extends "none" + ? object + : { + // Single content ref. For "inline" content this is the only editable + // region. For ContentType content (e.g. a combinator-built record) this + // is the parent container's contentDOM — the content type's child nodes + // mount as siblings inside it; the user's render typically positions + // them via CSS using each child's `data-content-name` attribute. contentRef: (node: HTMLElement | null) => void; - } - : object); + }); // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< @@ -45,11 +52,7 @@ export type ReactCustomBlockImplementation< Config extends ExtractBlockConfigFromConfigOrCreator = ExtractBlockConfigFromConfigOrCreator, > = Omit< - CustomBlockImplementation< - Config["type"], - Config["propSchema"], - Config["content"] - >, + BlockImplementation, "render" | "toExternalHTML" > & { render: FC>; @@ -63,10 +66,15 @@ export type ReactCustomBlockImplementation< }; export type ReactCustomBlockSpec< - B extends BlockConfig = BlockConfig< + B extends + BlockConfig< + string, + PropSchema, + "inline" | "none" | ContentType + > = BlockConfig< string, PropSchema, - "inline" | "none" + "inline" | "none" | ContentType >, > = { config: B; @@ -133,7 +141,7 @@ export function BlockContentWrapper< export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, - const TContent extends "inline" | "none", + const TContent extends "inline" | "none" | ContentType, const TOptions extends Record | undefined = undefined, >( blockConfigOrCreator: BlockConfig, @@ -159,7 +167,7 @@ export function createReactBlockSpec< export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, - const TContent extends "inline" | "none", + const TContent extends "inline" | "none" | ContentType, const BlockConf extends BlockConfig, const TOptions extends Partial>, >( @@ -188,10 +196,12 @@ export function createReactBlockSpec< export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, - const TContent extends "inline" | "none", + const TContent extends "inline" | "none" | ContentType, const TOptions extends Record | undefined = undefined, >( - blockConfigOrCreator: BlockConfigOrCreator, + blockConfigOrCreator: + | BlockConfig + | ((options: Partial) => BlockConfig), blockImplementationOrCreator: | ReactCustomBlockImplementation> | (TOptions extends undefined @@ -228,11 +238,74 @@ export function createReactBlockSpec< : extensionsOrCreator : undefined; - return { - config: blockConfig, - implementation: { - ...blockImplementation, - toExternalHTML(block, editor, context) { + // When `content` is a ContentType (e.g. a combinator-built record), the + // Tiptap node for the block is the content type's pre-built `containerNode`, + // extended with the block's own propSchema and a Tiptap node-view that + // delegates to the React render. The content type's `innerNodes` are + // automatically registered as Tiptap extensions so the block author doesn't + // have to wire them up by hand. + const content = blockConfig.content as + | "inline" + | "none" + | ContentType; + const isContentType = typeof content === "object" && content !== null; + const extraExtensions: (ExtensionFactoryInstance | Extension)[] = []; + let providedNode: ReturnType< + ContentType["containerNode"]["extend"] + > | undefined; + if (isContentType) { + const contentType = content as ContentType; + providedNode = contentType.containerNode.extend({ + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + addNodeView() { + return (props) => { + const editor = (this as any).options.editor; + const block = getBlockFromPos( + props.getPos, + editor, + (this as any).editor, + blockConfig.type, + ); + const blockContentDOMAttributes = + (this as any).options.domAttributes?.blockContent || {}; + const nodeView = ( + wrappedImplementation as unknown as { + render: (...a: any[]) => any; + } + ).render.call( + { blockContentDOMAttributes, props, renderType: "nodeView" }, + block as any, + editor as any, + ); + return nodeView; + }; + }, + }); + if (contentType.innerNodes.length > 0) { + extraExtensions.push( + createExtension({ + key: `${contentType.name}-content-type-nodes`, + tiptapExtensions: [...contentType.innerNodes], + }), + ); + } + if (contentType.extensions) { + extraExtensions.push(...contentType.extensions); + } + } + + const wrappedImplementation: BlockImplementation< + TName, + TProps, + TContent + > & { + node?: ReturnType["containerNode"]["extend"]>; + } = { + ...blockImplementation, + ...(providedNode ? { node: providedNode } : {}), + toExternalHTML(block, editor, context) { const BlockContent = blockImplementation.toExternalHTML || blockImplementation.render; const output = renderToDOMSpec((refCB) => { @@ -346,8 +419,12 @@ export function createReactBlockSpec< return output; } }, - }, - extensions: extensions, + }; + + return { + config: blockConfig, + implementation: wrappedImplementation, + extensions: [...(extensions ?? []), ...extraExtensions], }; }; } diff --git a/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts b/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts index 0ee05de3b1..e5b48cc247 100644 --- a/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts +++ b/packages/xl-ai/src/api/schema/schemaToJSONSchema.ts @@ -217,8 +217,8 @@ function blockSchemaToJSONSchema(schema: BlockSchema) { content: val.content === "inline" ? { $ref: "#/$defs/inlinecontent" } - : val.content === "table" - ? { type: "object", properties: {} } // TODO + : typeof val.content === "object" && val.content !== null + ? { type: "object", properties: {} } // TODO: per content-type JSON schema : undefined, // filter out default props (TODO: make option) props: propSchemaToJSONSchema(val.propSchema), diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index ae117ce392..750797b539 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1435,6 +1435,112 @@ }, "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": "multi-slot-alert-block", + "fullSlug": "custom-schema/multi-slot-alert-block", + "pathFromRoot": "examples/06-custom-schema/09-multi-slot-alert-block", + "config": { + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Combinator Content" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } as any + }, + "title": "Multi-Slot Alert Block", + "group": { + "pathFromRoot": "examples/06-custom-schema", + "slug": "custom-schema" + }, + "readme": "In this example, we create a custom `Alert` block whose content is a\n**combinator content schema** — a record of two inline regions, `title` and\n`body`. The block JSON exposes both slots as named keys, and the editor\ndisplays the document's JSON live so you can see the resulting shape.\n\nThis is the same alert idea as `01-alert-block`, but with a richer content\nshape: where the simple alert has a single inline region, this one has two\nindependently editable regions stored as named slots in the JSON.\n\n```ts\nconst alertContentType = combinatorContentType(\n \"alert\",\n c.record({\n title: c.inline(),\n body: c.inline(),\n }),\n);\n```\n\nThe block's content JSON is automatically derived from the schema:\n\n```json\n{\n \"type\": \"alert\",\n \"props\": { \"variant\": \"warning\" },\n \"content\": {\n \"title\": [{ \"type\": \"text\", \"text\": \"Heads up\", \"styles\": {} }],\n \"body\": [{ \"type\": \"text\", \"text\": \"Be careful.\", \"styles\": {} }]\n }\n}\n```\n\n**Try it out:** click the icon to change the alert variant, and edit the title\nand body inline. Watch the JSON panel below update in real time.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)" + }, + { + "projectSlug": "faq-block", + "fullSlug": "custom-schema/faq-block", + "pathFromRoot": "examples/06-custom-schema/10-faq-block", + "config": { + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Combinator Content", + "List Combinator" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } as any + }, + "title": "FAQ Block", + "group": { + "pathFromRoot": "examples/06-custom-schema", + "slug": "custom-schema" + }, + "readme": "A custom block whose content is a **variable-length list** of question/answer\npairs, built with the `c.list` and `c.record` combinators:\n\n```ts\nconst faqContentType = combinatorContentType(\n \"faq\",\n c.list(\n c.record({\n question: c.inline(),\n answer: c.inline(),\n }),\n ),\n);\n```\n\nThe block's JSON `content` is automatically derived as an array:\n\n```json\n[\n { \"question\": [...], \"answer\": [...] },\n { \"question\": [...], \"answer\": [...] }\n]\n```\n\nThe example renders all FAQ items in the block's chrome and has an\n**Add question** button that calls `editor.updateBlock` to append a new item\nto the list — demonstrating how arbitrary list mutations work today through\nthe existing block-update API.\n\n**Try it:** edit any question or answer and watch the JSON update; click\n\"Add question\" to see the array grow.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)" + }, + { + "projectSlug": "callout-block", + "fullSlug": "custom-schema/callout-block", + "pathFromRoot": "examples/06-custom-schema/11-callout-block", + "config": { + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Combinator Content", + "Blocks Combinator" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } as any + }, + "title": "Callout Block", + "group": { + "pathFromRoot": "examples/06-custom-schema", + "slug": "custom-schema" + }, + "readme": "A callout block whose content is a **sequence of editor blocks** rather than a\nsingle rich-text region. Built with the `c.blocks` combinator:\n\n```ts\nconst calloutContentType = combinatorContentType(\n \"callout\",\n c.blocks(),\n);\n```\n\nThe block's JSON `content` is automatically derived as `Block[]`:\n\n```json\n{\n \"type\": \"callout\",\n \"props\": { \"tone\": \"info\" },\n \"content\": [\n { \"type\": \"heading\", \"props\": { \"level\": 3 }, \"content\": [...] },\n { \"type\": \"paragraph\", \"content\": [...] },\n { \"type\": \"bulletListItem\", \"content\": [...] }\n ]\n}\n```\n\nInside the callout's body you can drop any block the editor knows about —\nheadings, paragraphs, lists, even other callouts. Try the slash menu (`/`)\nor hit Enter to add new blocks.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)" + }, + { + "projectSlug": "tab-group-block", + "fullSlug": "custom-schema/tab-group-block", + "pathFromRoot": "examples/06-custom-schema/12-tab-group-block", + "config": { + "playground": true, + "docs": true, + "author": "yousefed", + "tags": [ + "Advanced", + "Blocks", + "Custom Schemas", + "Combinator Content" + ], + "dependencies": { + "@mantine/core": "^8.3.11", + "react-icons": "^5.5.0" + } as any + }, + "title": "Tab Group Block", + "group": { + "pathFromRoot": "examples/06-custom-schema", + "slug": "custom-schema" + }, + "readme": "The motivating example for the combinator content schema design — a tab\ngroup that combines all three variable-shape combinators:\n\n```ts\nconst tabsContentType = combinatorContentType(\n \"tabs\",\n c.list(\n c.props(\n { label: { default: \"Tab\" } },\n c.blocks(),\n ),\n ),\n);\n```\n\n- `c.list` — variable-arity sequence of items\n- `c.props` — each item carries its own typed `label` attribute\n- `c.blocks` — each tab body is a stretch of full editor blocks\n\nThe block's JSON `content` shape is automatically derived from the schema:\n\n```json\n[\n {\n \"props\": { \"label\": \"Overview\" },\n \"content\": [\n { \"type\": \"heading\", \"props\": { \"level\": 3 }, \"content\": [...] },\n { \"type\": \"paragraph\", \"content\": [...] }\n ]\n },\n {\n \"props\": { \"label\": \"Details\" },\n \"content\": [...]\n }\n]\n```\n\n**Try it:**\n\n- Click a tab label to switch tabs (React state controls visibility; the\n underlying ProseMirror document holds all tabs).\n- Click \"+ Add tab\" to grow the list.\n- Edit the label by clicking it; press Enter to commit.\n- Inside a tab body, hit `/` for the slash menu, or just type — any block the\n editor knows about can live inside a tab body.\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 966cf9e887..d95e4e85bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3441,6 +3441,202 @@ importers: specifier: ^8.0.8 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/06-custom-schema/09-multi-slot-alert-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: ^8.3.11 + version: 8.3.18(@mantine/hooks@8.3.18(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: ^8.3.11 + version: 8.3.18(react@19.2.5) + '@mantine/utils': + specifier: ^6.0.22 + version: 6.0.22(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.8.3)) + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + + examples/06-custom-schema/10-faq-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: ^8.3.11 + version: 8.3.18(@mantine/hooks@8.3.18(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: ^8.3.11 + version: 8.3.18(react@19.2.5) + '@mantine/utils': + specifier: ^6.0.22 + version: 6.0.22(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.8.3)) + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + + examples/06-custom-schema/11-callout-block: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^8.3.11 + version: 8.3.18(@mantine/hooks@8.3.18(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: ^8.3.11 + version: 8.3.18(react@19.2.5) + '@mantine/utils': + specifier: ^6.0.22 + version: 6.0.22(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.8.3)) + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + + examples/06-custom-schema/12-tab-group-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: ^8.3.11 + version: 8.3.18(@mantine/hooks@8.3.18(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: ^8.3.11 + version: 8.3.18(react@19.2.5) + '@mantine/utils': + specifier: ^6.0.22 + version: 6.0.22(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.8.3)) + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': @@ -24078,8 +24274,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.2 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -24128,7 +24324,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -24139,7 +24335,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -24153,14 +24349,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -24201,7 +24397,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -24212,7 +24408,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/shared/formatConversionTestUtil.ts b/shared/formatConversionTestUtil.ts index 565f0f1cfb..5807f609f8 100644 --- a/shared/formatConversionTestUtil.ts +++ b/shared/formatConversionTestUtil.ts @@ -130,8 +130,12 @@ export function partialBlockToBlockForTesting< schema: BSchema, partialBlock: PartialBlock, ): Block { - const contentType: "inline" | "table" | "none" = - schema[partialBlock.type!].content; + const contentType = schema[partialBlock.type!].content; + // Until we have multiple `ContentType` instances in the system, any non-string + // content discriminator is the table content type. When more arrive, this + // utility will need a per-content-type default-value hook. + const isTableContent = + typeof contentType === "object" && contentType !== null; const withDefaults: Block = { id: "", @@ -140,7 +144,7 @@ export function partialBlockToBlockForTesting< content: contentType === "inline" ? [] - : contentType === "table" + : isTableContent ? { type: "tableContent", columnWidths: undefined, @@ -167,7 +171,7 @@ export function partialBlockToBlockForTesting< if (contentType === "inline") { const content = withDefaults.content as InlineContent[] | undefined; withDefaults.content = partialContentToInlineContent(content) as any; - } else if (contentType === "table") { + } else if (isTableContent) { const content = withDefaults.content as TableContent | undefined; withDefaults.content = { type: "tableContent", diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 142a5e7771..690b873620 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -521,7 +521,9 @@ }, "table": { "config": { - "content": "table", + "content": { + "__contentType": "table", + }, "propSchema": { "textColor": { "default": "default", @@ -532,6 +534,7 @@ "extensions": [ [Function], [Function], + [Function], ], "implementation": { "node": null, diff --git a/tests/src/unit/react/CombinatorContentReactRender.test.tsx b/tests/src/unit/react/CombinatorContentReactRender.test.tsx new file mode 100644 index 0000000000..0b465cd1fe --- /dev/null +++ b/tests/src/unit/react/CombinatorContentReactRender.test.tsx @@ -0,0 +1,151 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + c, + combinatorContentType, + defaultBlockSpecs, +} from "@blocknote/core"; +import { BlockNoteViewRaw, createReactBlockSpec } from "@blocknote/react"; +import { act } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, Root } from "react-dom/client"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// End-to-end test for combinator content schemas through the React render +// pipeline: +// - Build a custom block whose content is `record({ title, body })` of inlines +// compiled via `combinatorContentType`. +// - Define it via `createReactBlockSpec`, with a React render placing +// `contentRef` on the chrome's content area. +// - Mount it in a real BlockNoteViewRaw (jsdom + React) and verify both that +// the React chrome is present in the DOM, and that the parent record's +// slot children (`title`, `body`) mount inside the contentRef element. +// +// This proves the React extension wires the content type's `containerNode` +// into the React node-view path and that the user's `contentRef` becomes the +// parent record's contentDOM, with slot children mounted as siblings inside. + +const alertContentType = combinatorContentType( + "alertWithSlots", + c.record({ + title: c.inline(), + body: c.inline(), + }), +); + +const createAlertWithSlots = createReactBlockSpec( + { + type: "alertWithSlots", + propSchema: { + variant: { default: "warning", values: ["warning", "info"] as const }, + }, + content: alertContentType, + }, + { + render: (props) => ( +

+
+ ⚠️ +
+
+
+ ), + }, +); + +describe("combinator content + createReactBlockSpec end-to-end", () => { + let editor: BlockNoteEditor; + let div: HTMLElement; + let root: Root; + + beforeAll(() => { + div = document.createElement("div"); + document.body.appendChild(div); + + editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + alertWithSlots: createAlertWithSlots(), + }, + }), + trailingBlock: false, + }); + + root = createRoot(div); + flushSync(() => { + // eslint-disable-next-line testing-library/no-render-in-setup + root.render(); + }); + }); + + afterAll(() => { + root.unmount(); + editor._tiptapEditor.destroy(); + div.remove(); + }); + + it("registers the block's container and inner Tiptap nodes", () => { + const pmSchema = editor.pmSchema; + expect(pmSchema.nodes.alertWithSlots).toBeDefined(); + expect(pmSchema.nodes.alertWithSlots__title).toBeDefined(); + expect(pmSchema.nodes.alertWithSlots__body).toBeDefined(); + + expect(pmSchema.nodes.alertWithSlots.spec.content).toBe( + "alertWithSlots__title alertWithSlots__body", + ); + expect(pmSchema.nodes.alertWithSlots__title.spec.content).toBe("inline*"); + expect(pmSchema.nodes.alertWithSlots__body.spec.content).toBe("inline*"); + }); + + it("renders the React chrome and mounts slot children inside contentRef", async () => { + await act(async () => { + editor.replaceBlocks(editor.document, [ + { + type: "alertWithSlots" as const, + props: { variant: "info" }, + content: { + title: [{ type: "text", text: "Heads up", styles: { bold: true } }], + body: [{ type: "text", text: "This is the body.", styles: {} }], + } as any, + } as any, + ]); + }); + + // The user's React chrome is in the DOM… + const chrome = div.querySelector(".alert-with-slots"); + expect(chrome).not.toBeNull(); + expect(chrome!.getAttribute("data-variant")).toBe("info"); + + // …with the icon (non-editable)… + expect(chrome!.querySelector(".alert-icon")).not.toBeNull(); + + // …and the contentRef target hosts the parent record's slot children as + // siblings, each rendered as the combinator factory's default div with a + // `data-content-name` attribute. + const slots = chrome!.querySelector(".alert-slots"); + expect(slots).not.toBeNull(); + const titleSlot = slots!.querySelector( + '[data-content-name="alertWithSlots__title"]', + ); + const bodySlot = slots!.querySelector( + '[data-content-name="alertWithSlots__body"]', + ); + expect(titleSlot).not.toBeNull(); + expect(bodySlot).not.toBeNull(); + expect(titleSlot!.textContent).toBe("Heads up"); + expect(bodySlot!.textContent).toBe("This is the body."); + }); + + it("round-trips JSON content through the editor", () => { + const block = editor.document[0] as any; + expect(block.type).toBe("alertWithSlots"); + expect(block.props.variant).toBe("info"); + expect(block.content).toMatchObject({ + title: [{ type: "text", text: "Heads up", styles: { bold: true } }], + body: [{ type: "text", text: "This is the body.", styles: {} }], + }); + }); +});