+`).
+ * 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: {} }],
+ });
+ });
+});