From 225335cf2d0a6aaaaa691b6dfa9fe5f3145638b9 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 21 Aug 2026 21:52:15 +0200 Subject: [PATCH] docs: add container block docs and examples --- .../custom-schemas/container-blocks.mdx | 330 +++++++++++++++++ .../features/custom-schemas/custom-blocks.mdx | 6 + .../reference/editor/manipulating-content.mdx | 11 +- .../09-container-block/.bnexample.json | 15 + .../09-container-block/README.md | 22 ++ .../09-container-block/index.html | 14 + .../09-container-block/main.tsx | 11 + .../09-container-block/package.json | 31 ++ .../09-container-block/src/App.tsx | 118 ++++++ .../09-container-block/src/Callout.tsx | 104 ++++++ .../09-container-block/src/styles.css | 123 +++++++ .../09-container-block/tsconfig.json | 32 ++ .../09-container-block/vite-env.d.ts | 1 + .../09-container-block/vite.config.ts | 35 ++ .../12-container-table/.bnexample.json | 15 + .../12-container-table/README.md | 18 + .../12-container-table/index.html | 14 + .../12-container-table/main.tsx | 11 + .../12-container-table/package.json | 31 ++ .../12-container-table/src/App.tsx | 183 +++++++++ .../12-container-table/src/Table.tsx | 346 ++++++++++++++++++ .../12-container-table/src/styles.css | 100 +++++ .../12-container-table/tsconfig.json | 32 ++ .../12-container-table/vite-env.d.ts | 1 + .../12-container-table/vite.config.ts | 35 ++ playground/src/examples.gen.tsx | 54 +++ pnpm-lock.yaml | 92 +++++ 27 files changed, 1783 insertions(+), 2 deletions(-) create mode 100644 docs/content/docs/features/custom-schemas/container-blocks.mdx create mode 100644 examples/06-custom-schema/09-container-block/.bnexample.json create mode 100644 examples/06-custom-schema/09-container-block/README.md create mode 100644 examples/06-custom-schema/09-container-block/index.html create mode 100644 examples/06-custom-schema/09-container-block/main.tsx create mode 100644 examples/06-custom-schema/09-container-block/package.json create mode 100644 examples/06-custom-schema/09-container-block/src/App.tsx create mode 100644 examples/06-custom-schema/09-container-block/src/Callout.tsx create mode 100644 examples/06-custom-schema/09-container-block/src/styles.css create mode 100644 examples/06-custom-schema/09-container-block/tsconfig.json create mode 100644 examples/06-custom-schema/09-container-block/vite-env.d.ts create mode 100644 examples/06-custom-schema/09-container-block/vite.config.ts create mode 100644 examples/06-custom-schema/12-container-table/.bnexample.json create mode 100644 examples/06-custom-schema/12-container-table/README.md create mode 100644 examples/06-custom-schema/12-container-table/index.html create mode 100644 examples/06-custom-schema/12-container-table/main.tsx create mode 100644 examples/06-custom-schema/12-container-table/package.json create mode 100644 examples/06-custom-schema/12-container-table/src/App.tsx create mode 100644 examples/06-custom-schema/12-container-table/src/Table.tsx create mode 100644 examples/06-custom-schema/12-container-table/src/styles.css create mode 100644 examples/06-custom-schema/12-container-table/tsconfig.json create mode 100644 examples/06-custom-schema/12-container-table/vite-env.d.ts create mode 100644 examples/06-custom-schema/12-container-table/vite.config.ts diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx new file mode 100644 index 0000000000..6839c7544d --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,330 @@ +--- +title: Container Blocks +description: Learn how to create custom blocks that hold other blocks as their body +--- + +# Container Blocks + +A *container block* is a custom block that holds other blocks as its body: a Notion-style callout wrapping a paragraph and a code block, a toggle with a title and a body, or a multi-column layout. BlockNote's built-in multi-column blocks (`columnList` / `column`) are implemented with this same mechanism. + +## Declaring a Container Block + +Add the `children` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The only required field is `allow`, so the smallest container is: + +```typescript +import { createReactBlockSpec } from "@blocknote/react"; + +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: {}, + content: "none", + // Makes this a container: its body is other blocks. + children: { allow: "any" }, + }, + { + // Child blocks mount into the element you attach `contentRef` to. + render: (props) =>
, + }, +); +``` + +`children: { allow: "any" }` accepts any block, requires at least one, and never throws. When a container is created without children, BlockNote fills it with whatever its schema requires. + +At runtime the contained blocks live on `block.children`, the same field used for indented (nested) blocks. In fact, every regular block behaves as if it were declared with `children: { allow: "any", min: 0 }`; declaring `children` yourself is how you take control of the counts, the allowed types, and the rendering of that same field: + +```json +{ + "id": "callout-1", + "type": "callout", + "props": {}, + "children": [ + { + "id": "para-1", + "type": "paragraph", + "content": [{ "type": "text", "text": "Hello", "styles": {} }], + "children": [] + } + ] +} +``` + +### Where children render + +There is only one placement mechanism, and it is the one you already use for inline content. `contentRef` (React) / `contentDOM` (vanilla) marks the block's editable region. What goes in that region depends on the block: + +| block | `contentRef` element holds | +| --- | --- | +| `content: "inline"`, no `children` | its inline content | +| `content: "none"` + `children` | its child blocks | +| `content: "inline"` + `children` | its inline content, then its child blocks | +| `content: "plain"` + `children` | its plain-text content, then its child blocks | + +A `content: "none"` block *without* `children` is the only kind with nothing to place, and it's the only kind that isn't offered a `contentRef` at all. + +Container blocks own their entire outer DOM. BlockNote doesn't wrap them in the usual block element: whatever element your `render` returns *is* the block's element, and BlockNote stamps the attributes it relies on for parsing and UI positioning onto it (`data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute). You write a plain `
` and `data-flavor="info"` lands on it, in the live editor and in serialized HTML alike. + + + _The framework wrappers React puts above your element carry `display: + contents`, so they contribute no box and your element lays out exactly as if + it were the block's root. Selection is mirrored onto it as a `data-selected` + attribute, so `[data-selected]` is what you style for the selected state._ + + +The demo below puts this together: a callout block that can contain any other blocks. Its title is a regular `` backed by a string prop rather than document content, a pattern covered in [Editable fields that aren't document content](#editable-fields-that-arent-document-content): + + + +## Containers with their own content + +A container can have inline content *of its own* as well as children: a toggle's title with its body beneath it, a card header, or a callout whose first line is real rich text rather than a plain ``. Combine `content: "inline"` with `children`, and place both with the same single `contentRef`: + +```typescript +const createToggle = createReactBlockSpec( + { + type: "toggle", + propSchema: {}, + // The toggle's own title... + content: "inline", + // ...and its body. + children: { allow: "any", min: 0 }, + }, + { + render: (props) => ( +
+ +
+
+ ), + }, +); +``` + +This is purely additive: adding `children` to an existing block is one config line and no render changes. The block keeps its `Block` JSON shape, with `content` for its own content and `children` for its body, identical to any other nested block. + +`content: "plain"` combines with `children` the same way, for a head that is text but not *rich* text: no formatting marks and no inline nodes, like a code block's source. A file group whose header is a literal filename would use it: + +```typescript +{ + type: "fileGroup", + propSchema: {}, + // The group's filename: plain, unformattable text. + content: "plain", + // The files it groups. + children: { allow: "any" }, +} +``` + +### The two regions + +Inside the `contentRef` element, BlockNote renders two sibling elements with stable attributes derived from the block type: + +- `[data-content-type=""]` holds the block's own (inline or plain) content. +- `[data-children-of=""]` holds its child blocks. + +You never place these yourself; you style them. The host element between them carries `display: contents`, so a grid on your own root reaches them directly: + +```css +.toggle { display: grid; grid-template-columns: auto 1fr; } +.toggle-main { display: contents; } +.chevron { grid-column: 1; grid-row: 1; } +[data-content-type="toggle"] { grid-column: 2; grid-row: 1; } +[data-children-of="toggle"] { grid-column: 2; grid-row: 2; } +``` + + + _ProseMirror imposes two limits here: reading order is always + content-then-children, and your own markup cannot be interleaved between the + two regions or wrap only one of them. A grid (or `order`) can reorder them + *visually*; the DOM order is fixed._ + + +## `children` options + +| Option | Default | Description | +| --- | --- | --- | +| `allow` | (required) | What may appear as a child: `"any"`, `"blocks"`, `"containers"`, or an array of container block types. See [Restricting children](#restricting-children). | +| `min` / `max` | `1` / unbounded | How many children are allowed. Compiled into the editor schema. | +| `default` | none | Partial blocks to create the container with when it's inserted without an explicit `children` array, and the source of `"refill"` top-ups. Validated against the rest of the config when the schema is created. See [Defaults and refilling](#defaults-and-refilling). | +| `whenEmptied` | `"refill"` | What happens when fewer non-empty children remain than `min`: `"refill"` tops the container back up from `default`; `"unwrap"` replaces the container with its surviving children, or removes it entirely when none are left. Column lists use `"unwrap"` so emptied columns disappear and a one-column list dissolves. | +| `boundary` | `"isolated"` | What crosses the container's edge: the caret, selections, or nothing. See [Boundaries](#boundaries). | + +`placement` sits next to `children` on the block config rather than inside it, because it's a fact about *this* block rather than about its children: + +| Option | Default | Description | +| --- | --- | --- | +| `placement` | `"anywhere"` | `"containerOnly"` restricts the block to containers that name it in their `children.allow` array, like a `column`, which only makes sense inside a `columnList`. It also requires the block to be a container itself. `"anywhere"` is valid on any block; on a regular block it simply restates the default. | + +Purely behavioral options that apply to *every* block kind stay in the block implementation's `meta`: + +| Meta option | Default | Description | +| --- | --- | --- | +| `draggable` | `true` | Whether the block gets a side menu drag handle. A block that opts out is skipped when looking for a handle, so the handle falls through to the nearest draggable ancestor. | + + + _`whenEmptied` never destroys typed text. For a container with its own + content, neither value does anything while that content is non-empty._ + + +## Defaults and refilling + +`default` is an insertion template: a container inserted without an explicit `children` array is created with those blocks. Omit it and BlockNote fills the container with empty blocks its schema accepts. + +The same template drives `whenEmptied: "refill"`. When a refill container's non-empty children drop below `min`, say `k` remain, BlockNote appends `default[k]` through `default[min - 1]` at the end, falling back to empty blocks where `default` is absent or has no entry for a position. A checklist with `min: 2` and a two-entry `default` that loses its second item gets `default[1]` back, not a bare paragraph. + +## Boundaries + +`boundary` declares what may cross a container's edge: + +| Value | What crosses the edge | +| --- | --- | +| `"open"` | Everything: the caret, editing gestures, and text selections. A selection can start inside one child and end outside the container; `columnList` uses this so a selection can span columns. | +| `"isolated"` (default) | The caret and editing gestures cross, but a text selection cannot span the edge. | +| `"sealed"` | Nothing crosses implicitly: the caret doesn't wander in via arrow keys or Backspace, and from outside, the container selects and deletes as a single unit. | + +For editing gestures, `"open"` and `"isolated"` behave identically: the +editor moves blocks across the edge implicitly, which is the right feel for +flow regions. Backspace at the start of a container's first child moves that +child out; Backspace at the start of the block *after* a container moves it +inside; Delete mirrors both. Enter on an empty *last* child moves that block +out below the container, the "double-Enter escapes" gesture every list +editor has. An empty block mid-container never ejects; spacing inside a +block is Shift+Enter's job. Columns and callouts want exactly this. The two +values differ only in whether a *text selection* can reach across the edge. + +### Sealed containers + +A compartment, like a table cell, wants the opposite: whatever happens at +its edges, content stays where it is. Declaring `boundary: "sealed"` gets +that in one line: + +```typescript +// A cell: holds any blocks, but nothing crosses its edge implicitly. +children: { allow: "any", boundary: "sealed" }, +placement: "containerOnly", +``` + +Sealed means every *implicit* move across the edge is off, in both +directions. Without it, each of these would need a hand-written keyboard +handler: + +- Backspace at the start of the first child no longer moves it out; the + keystroke does nothing. Deeper in the container, Backspace behaves as + usual, merging into the previous sibling and un-indenting nested blocks. +- Delete at the end of the last child no longer pulls the next block in. +- Backspace after (or Delete before) a sealed container selects the + container instead of merging into it, so a second press deletes it as a + whole, deliberately. +- Arrow keys from outside treat the container as a unit rather than + stepping the caret inside; clicking inside still places the caret, and + editing within the container is unrestricted. +- Enter never moves the trailing block out: there is no double-Enter escape + from a sealed container. + +The setting is deliberately key-agnostic. It declares a fact about the +boundary, not a keybinding, so any gesture that would implicitly move the +caret or content across the edge consults it. + +Seals never bind the block manipulation API. `insertBlocks` and the rest +ignore `boundary` entirely. An API call is an intentional crossing, so it +can always place content inside a sealed container. + +## Restricting children + +`allow` takes one of four forms: + +```typescript +allow: "any" | "blocks" | "containers" | string[] +``` + +- `"any"`: any regular block, plus any container placeable anywhere. +- `"blocks"`: regular blocks only, no containers. +- `"containers"`: any anywhere-placeable container, no regular blocks. +- `string[]`: only the named container block types. + +The wildcard forms (`"any"`, `"containers"`) exclude `placement: "containerOnly"` types: a `column` never shows up inside your callout just because the callout accepts "any" block. A containerOnly type appears only where a parent names it in an array. + +The array form is exact because container blocks are each their own ProseMirror node type. Every regular block, whether paragraph, heading, or code block, is the *same* ProseMirror node internally, so "only headings" is not something the schema can enforce yet. Naming a regular block type in the array is a startup error; per-type filtering of regular blocks is not yet supported, and the array is where it will land later with no API change. + +This is exactly how the multi-column blocks are defined: + +```typescript +// The outer container: only columns, at least two of them; +// unwraps when it drops to one, and selections span its columns. +children: { + allow: ["column"], + min: 2, + whenEmptied: "unwrap", + boundary: "open", +} + +// The column: holds any blocks, but only lives inside a columnList. +children: { allow: "any" }, +placement: "containerOnly", +``` + +## Inserting into a container + +[`editor.insertBlocks`](/docs/reference/editor/manipulating-content#inserting-blocks) takes two nested placements alongside the sibling ones: + +```typescript +// Siblings of the reference block: +editor.insertBlocks([{ type: "paragraph" }], calloutId, "before"); +editor.insertBlocks([{ type: "paragraph" }], calloutId, "after"); + +// Nested inside it, as its first or last child: +editor.insertBlocks([{ type: "paragraph" }], calloutId, "start"); +editor.insertBlocks([{ type: "paragraph" }], calloutId, "end"); +``` + +The nested placements are what addresses a container with no children to point at. A `min: 0` container that is currently empty has no child block to insert before or after. Whether a block fits is answered by the schema, so it's your `children` config that decides. + +## Validation + +Configurations are checked when the schema is created, and fail up front with a message naming the block. Beyond unknown block types and impossible `default` children, this catches: + +- an `allow` that permits nothing: an empty array, or a wildcard form when no anywhere-placeable container exists; +- an `allow` array naming an unknown type, or naming a regular block type (per-type filtering of regular blocks is [not yet supported](#restricting-children)); +- `content: "table"` combined with `children`; +- a `placement: "containerOnly"` block that no container's `allow` array names, or `placement: "containerOnly"` on a regular block; +- container cycles: a container that (transitively) requires a child that requires it back could never be created. An `allow` that permits regular blocks breaks the cycle, since they're always satisfiable. + +## Parsing HTML into a container + +Containers go through the same parsing path as regular blocks. The default rule matches the marker BlockNote puts on the block's root, `[data-node-type=""]`, which is what makes HTML produced by BlockNote round-trip. + +To recognize *foreign* HTML, add `implementation.parse`, which returns the block's props, or `undefined` to decline: + +```typescript +{ + render: (props) =>
, + parse: (el) => + el.classList.contains("card") + ? { tone: el.getAttribute("data-tone") ?? undefined } + : undefined, +} +``` + +With no `parseContent`, ProseMirror parses the element's children with the normal block rules, so `

` becomes a card with a paragraph and a heading. Supply `parseContent` only if you need to build the body yourself; inline nodes it returns become paragraph children, except a leading inline run in a container that has its own content, which becomes that content. + +`runsBefore` orders your parse rule against other blocks'. On a container it may only name other containers. Container nodes register in a priority band below regular blocks, so a container can never be ordered ahead of one; its `tag: "*"` rule is always considered after every regular block's. Naming a regular block there is an error rather than a silent no-op. + + + _`allow` does not filter what a user pastes. Pasted HTML is parsed with + `blockGroup` as its top node, and ProseMirror's fitting algorithm places + content your container's expression rejects *after* the container rather than + dropping it. `allow` constrains the document model, not the parser._ + + +## Interop behavior + +- **HTML**: containers serialize to a `
` with their children nested inside and non-default props as `data-*` attributes, and parse back losslessly. A container with its own content serializes its two regions as `[data-content-type]` and `[data-children-of]` elements. +- **External HTML** (`blocksToHTMLLossy`, copy to another app) is intentionally semantic and lossy. Override `toExternalHTML` and return a `childrenDOM` to say where children belong in your own markup; this is how toggles export as `
`. +- **Markdown**: containers are flattened. Their children are exported in order, and Markdown import never produces containers. +- **Exporters** (`@blocknote/xl-docx-exporter`, `xl-pdf-exporter`, `xl-odt-exporter`, `xl-email-exporter`): container blocks require an explicit block mapping that places their children; a missing mapping throws a clear error. + +## Editable fields that aren't document content + +Not every editable field belongs in the document. A name, a URL, or a label doesn't need rich text formatting, comments, or multiplayer cursors: store it as a string prop and render a regular `` inside the block, in a `contentEditable={false}` wrapper, committing the value with `editor.updateBlock`. The callout demo on this page does exactly that for its title. + +Use a container's own `content: "inline"` when the field *is* prose, and a string prop when it's data. diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx index ff25cf838c..2e2479acf9 100644 --- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx @@ -72,6 +72,12 @@ type BlockConfig = { alert, so we set `content` to `"inline"`._ + + _Any block can also hold other blocks as its body by declaring the + `children` option, with or without inline content of its own. See [Container + Blocks](/docs/features/custom-schemas/container-blocks)._ + + `propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior. ```typescript diff --git a/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx index 1a9c97c222..873a186f17 100644 --- a/docs/content/docs/reference/editor/manipulating-content.mdx +++ b/docs/content/docs/reference/editor/manipulating-content.mdx @@ -141,11 +141,11 @@ editor.forEachBlock((block) => { insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before" + placement: "before" | "after" | "start" | "end" = "before" ): void ``` -Inserts new blocks relative to an existing block. +Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"start"` and `"end"` nest them inside it, as its first or last children. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container). ```typescript // Insert a paragraph before an existing block @@ -164,6 +164,13 @@ editor.insertBlocks( "existing-block-id", "after", ); + +// Insert a paragraph as the last child of a container block +editor.insertBlocks( + [{ type: "paragraph", content: "Nested paragraph" }], + "container-block-id", + "end", +); ``` ### Updating Blocks diff --git a/examples/06-custom-schema/09-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json new file mode 100644 index 0000000000..3de7330631 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "nickthesick", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md new file mode 100644 index 0000000000..070dd71987 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/README.md @@ -0,0 +1,22 @@ +# Container Block + +In this example, we create a custom `Callout` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block. + +The block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime. + +The callout's **title** demonstrates the complementary "string prop slot" pattern: a field that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block's own `content: "inline"` instead. + +We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks. + +**Try it out:** + +- Press the "/" key inside the callout's body and add a code block, heading, or list. +- Type a title into the title field. It's stored on `block.props.title`, not as document content. +- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`. +- Insert a new callout via the Slash Menu (search "callout"). + +**Relevant Docs:** + +- [Container Blocks](/docs/features/custom-schemas/container-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html new file mode 100644 index 0000000000..19321f77b5 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/index.html @@ -0,0 +1,14 @@ + + + + + Container Block + + + +
+ + + diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json new file mode 100644 index 0000000000..29778f9255 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "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": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx new file mode 100644 index 0000000000..3d6cf55ba1 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/App.tsx @@ -0,0 +1,118 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + filterSuggestionItems, + insertOrUpdateBlockForSlashMenu, +} from "@blocknote/core/extensions"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { useEffect, useState } from "react"; +import { RiChatQuoteLine } from "react-icons/ri"; + +import { createCallout } from "./Callout"; +import "./styles.css"; + +// Schema with the default blocks plus our custom Callout container block. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: createCallout(), + }, +}); + +// Slash menu item to insert a Callout. Because Callout is a container block, +// inserting one with no children causes BlockNote to seed it with the block's +// configured `children.default` (a single paragraph here). +const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Callout", + subtext: "Container block that wraps other blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "callout", + }), + aliases: ["callout", "container", "alert", "note", "tip", "info"], + group: "Basic blocks", + icon: , +}); + +type AppBlock = (typeof schema.BlockNoteEditor)["document"][number]; + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "Welcome! This demo shows the new container block kind.", + }, + { + type: "callout", + props: { flavor: "tip" }, + children: [ + { + type: "paragraph", + content: "Callouts can hold any block as their body.", + }, + { + type: "paragraph", + content: + "Try pressing '/' inside this callout to add a heading or code block.", + }, + ], + }, + { + type: "paragraph", + content: "Press '/' anywhere to insert a new Callout.", + }, + { + type: "paragraph", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ { + setBlocks(editor.document); + }} + > + { + const defaultItems = getDefaultReactSlashMenuItems(editor); + const lastBasicBlockIndex = defaultItems.findLastIndex( + (item) => item.group === "Basic blocks", + ); + defaultItems.splice( + lastBasicBlockIndex + 1, + 0, + insertCallout(editor), + ); + return filterSuggestionItems(defaultItems, query); + }} + /> + +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx new file mode 100644 index 0000000000..b150cead50 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -0,0 +1,104 @@ +import { createReactBlockSpec } from "@blocknote/react"; +import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md"; + +import "./styles.css"; + +// The flavors of callout the user can switch between. +export const calloutTypes = [ + { value: "tip", title: "Tip", icon: MdLightbulb }, + { value: "info", title: "Info", icon: MdInfo }, + { value: "warning", title: "Warning", icon: MdWarning }, + { value: "success", title: "Success", icon: MdCheckCircle }, +] as const; + +// The Callout block. Declared with `content: "none"` plus the `children` +// config: the block hosts arbitrary child blocks in its body, exposed at +// runtime as `block.children`. +// +// The callout's title shows a related pattern: content that shouldn't be +// part of the rich-text document (no formatting, comments, or multiplayer +// cursors needed) can live in a plain string prop, edited through a regular +// rendered inside the block. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + title: { + default: "", + }, + }, + content: "none", + // `children: { allow: "any" }` is the entire container declaration: any + // block is allowed, at least one is required, and BlockNote fills the + // callout with an empty paragraph when it's created. `min` / `max` / + // `default` / `whenEmptied` / `boundary` tune this. + children: { allow: "any" }, + }, + { + render: (props) => { + const flavor = + calloutTypes.find((c) => c.value === props.block.props.flavor) ?? + calloutTypes[0]; + const Icon = flavor.icon; + + const cycleFlavor = () => { + const idx = calloutTypes.findIndex( + (c) => c.value === props.block.props.flavor, + ); + const next = calloutTypes[(idx + 1) % calloutTypes.length]; + props.editor.updateBlock(props.block, { + type: "callout", + props: { flavor: next.value }, + }); + }; + + const commitTitle = (title: string) => { + if (title !== props.block.props.title) { + props.editor.updateBlock(props.block, { + type: "callout", + props: { title }, + }); + } + }; + + return ( +
+ +
+ {/* The title lives in a string prop, not in document content, + and is edited via a plain input. `contentEditable={false}` + keeps ProseMirror from treating typing here as document + input. */} +
+ commitTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> +
+
+
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css new file mode 100644 index 0000000000..8ecdb8f8b9 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/styles.css @@ -0,0 +1,123 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.item { + border-radius: 0.5rem; + flex: 1; + overflow: hidden; +} + +.item.bordered { + border: 1px solid gray; +} + +.item pre { + border-radius: 0.5rem; + height: 100%; + overflow: auto; + padding-block: 1rem; + padding-inline: 54px; + width: 100%; + white-space: pre-wrap; +} + +.callout { + display: flex; + align-items: flex-start; + gap: 12px; + flex-grow: 1; + border-radius: 6px; + padding: 12px 16px; + border-left: 4px solid var(--callout-accent, #888); + background-color: var(--callout-bg, #f3f4f6); +} + +/* `tip` is the prop's default value, and BlockNote only writes a `data-*` + attribute for props that differ from their default. A callout left on + `tip` carries no `data-flavor` at all, so style its absence alongside it. */ +.callout:not([data-flavor]), +.callout[data-flavor="tip"] { + --callout-accent: #d97706; + --callout-bg: #fff7ed; +} + +.callout[data-flavor="info"] { + --callout-accent: #507aff; + --callout-bg: #e6ebff; +} + +.callout[data-flavor="warning"] { + --callout-accent: #b91c1c; + --callout-bg: #fef2f2; +} + +.callout[data-flavor="success"] { + --callout-accent: #16a34a; + --callout-bg: #ecfdf5; +} + +[data-color-scheme="dark"] .callout:not([data-flavor]), +[data-color-scheme="dark"] .callout[data-flavor="tip"] { + --callout-bg: #432e0e; +} + +[data-color-scheme="dark"] .callout[data-flavor="info"] { + --callout-bg: #1e2a5c; +} + +[data-color-scheme="dark"] .callout[data-flavor="warning"] { + --callout-bg: #4a1212; +} + +[data-color-scheme="dark"] .callout[data-flavor="success"] { + --callout-bg: #0d3b21; +} + +.callout-icon-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--callout-accent, #888); + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; +} + +.callout-icon-button:hover { + opacity: 0.75; +} + +.callout-main { + flex-grow: 1; + min-width: 0; +} + +.callout-title-wrapper { + margin-bottom: 4px; +} + +.callout-title-input { + width: 100%; + border: none; + background: none; + outline: none; + font-weight: 600; + font-size: 1rem; + color: inherit; + padding: 0; +} + +.callout-title-input::placeholder { + color: var(--callout-accent, #888); + opacity: 0.5; +} + +.callout-body { + flex-grow: 1; + min-width: 0; +} diff --git a/examples/06-custom-schema/09-container-block/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/tsconfig.json @@ -0,0 +1,32 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true, + "paths": { + "@shared/*": ["../../../shared/*"] + } + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/09-container-block/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/09-container-block/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts new file mode 100644 index 0000000000..a96f1f04ff --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite.config.ts @@ -0,0 +1,35 @@ +// 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"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // The repo-wide alias for the shared test-utils directory (private, + // so it only resolves inside the monorepo). Harmless for examples + // that don't use it. + "@shared": path.resolve(__dirname, "../../../shared/"), + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/06-custom-schema/12-container-table/.bnexample.json b/examples/06-custom-schema/12-container-table/.bnexample.json new file mode 100644 index 0000000000..55cd80cdc6 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "nickthesick", + "tags": [ + "Advanced", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/12-container-table/README.md b/examples/06-custom-schema/12-container-table/README.md new file mode 100644 index 0000000000..3441f4aab3 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/README.md @@ -0,0 +1,18 @@ +# Table Built From Container Blocks + +In this example, we rebuild BlockNote's table as four container blocks: `table`, `tableRow`, `tableCell`, and `tableHeader`. There is no `prosemirror-tables` and no special `"table"` content type. A table is a container of rows, a row is a container of cells, and a cell is a container of arbitrary blocks, so cells can hold lists, headings, images, or even nested tables. The JSON shape is the same `children` array every other block uses. + +Cells declare `boundary: "sealed"`, which makes them behave like compartments: Backspace, Delete, and arrow keys never implicitly move content or the caret across a cell's edge, and Enter adds another block _inside_ the cell. Header cells are a distinct block type rather than table metadata, so toggling the header row is just `updateBlock` with a new type. All structural operations, from adding and removing rows and columns to Tab-to-next-cell, are plain calls to the public block manipulation API: `insertBlocks`, `removeBlocks`, `updateBlock`, `getParentBlock`, and `setTextCursorPosition`. + +**Try it out:** + +- Press Tab / Shift-Tab to move between cells. Tab in the last cell adds a new row. +- Press Enter inside a cell to stack more blocks in it, or "/" to add a list or heading. +- Hover the table to reveal the row/column controls, and watch the JSON panel update. + +**Relevant Docs:** + +- [Container Blocks](/docs/features/custom-schemas/container-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Manipulating Blocks](/docs/reference/editor/manipulating-content) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/12-container-table/index.html b/examples/06-custom-schema/12-container-table/index.html new file mode 100644 index 0000000000..f3c064f4c0 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/index.html @@ -0,0 +1,14 @@ + + + + + Table Built From Container Blocks + + + +
+ + + diff --git a/examples/06-custom-schema/12-container-table/main.tsx b/examples/06-custom-schema/12-container-table/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/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-container-table/package.json b/examples/06-custom-schema/12-container-table/package.json new file mode 100644 index 0000000000..8f6d3a1eec --- /dev/null +++ b/examples/06-custom-schema/12-container-table/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-table", + "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": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/06-custom-schema/12-container-table/src/App.tsx b/examples/06-custom-schema/12-container-table/src/App.tsx new file mode 100644 index 0000000000..d885917d04 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/src/App.tsx @@ -0,0 +1,183 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + filterSuggestionItems, + insertOrUpdateBlockForSlashMenu, +} from "@blocknote/core/extensions"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { useEffect, useState } from "react"; +import { TbTable } from "react-icons/tb"; + +import { + createTable, + createTableCell, + createTableHeader, + createTableRow, +} from "./Table"; +import "./styles.css"; + +// Drop the built-in table (the one with the special `"table"` content type) +// and replace it with our container-based implementation under the same +// `table` type name. +const { table: _defaultTable, ...remainingBlockSpecs } = defaultBlockSpecs; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...remainingBlockSpecs, + table: createTable(), + tableRow: createTableRow(), + tableCell: createTableCell(), + tableHeader: createTableHeader(), + }, +}); + +// Inserting a table with no explicit children seeds it from the block's +// configured `children.default`: a 3-column table with a header row. +const insertTable = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Table", + subtext: "Table built from container blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "table", + }), + aliases: ["table", "grid", "cells"], + group: "Basic blocks", + icon: , +}); + +type AppBlock = (typeof schema.BlockNoteEditor)["document"][number]; + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "This table is built entirely from container blocks, with no special table content type.", + }, + { + type: "table", + children: [ + { + type: "tableRow", + children: [ + { + type: "tableHeader", + children: [{ type: "paragraph", content: "Name" }], + }, + { + type: "tableHeader", + children: [{ type: "paragraph", content: "Notes" }], + }, + ], + }, + { + type: "tableRow", + children: [ + { + type: "tableCell", + children: [{ type: "paragraph", content: "Alice" }], + }, + { + type: "tableCell", + children: [ + { + type: "paragraph", + content: "Cells hold any blocks:", + }, + { + type: "bulletListItem", + content: "lists,", + }, + { + type: "bulletListItem", + content: "headings, images…", + }, + ], + }, + ], + }, + { + type: "tableRow", + children: [ + { + type: "tableCell", + children: [{ type: "paragraph", content: "Bob" }], + }, + { + type: "tableCell", + children: [ + { + type: "paragraph", + content: "Tab / Shift-Tab move between cells.", + }, + ], + }, + ], + }, + ], + }, + { + type: "paragraph", + content: + "Tab in the last cell adds a row. Press '/' to insert a new table.", + }, + { + type: "paragraph", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ { + setBlocks(editor.document); + }} + > + { + // Swap the built-in Table item (which inserts the old + // `tableContent` shape) for one that inserts our container + // table. + const defaultItems = getDefaultReactSlashMenuItems(editor).filter( + (item) => item.key !== "table", + ); + const lastBasicBlockIndex = defaultItems.findLastIndex( + (item) => item.group === "Basic blocks", + ); + defaultItems.splice( + lastBasicBlockIndex + 1, + 0, + insertTable(editor), + ); + return filterSuggestionItems(defaultItems, query); + }} + /> + +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/12-container-table/src/Table.tsx b/examples/06-custom-schema/12-container-table/src/Table.tsx new file mode 100644 index 0000000000..e63b916db4 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/src/Table.tsx @@ -0,0 +1,346 @@ +import { + createExtension, + type Block, + type BlockNoteEditor, +} from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +// A table built entirely out of container blocks, without `prosemirror-tables` +// or the special `"table"` content type. A table is a container of rows, a row +// is a container of cells, and a cell is a container of arbitrary blocks: +// +// table > tableRow > tableCell / tableHeader > (any blocks) +// +// The JSON shape is the same `children` array every other container block +// uses, and every structural operation (add/remove row or column, toggle the +// header row) is a plain `insertBlocks` / `removeBlocks` / `updateBlock` call. + +type AnyEditor = BlockNoteEditor; +type AnyBlock = Block; + +function isCellType(type: string): boolean { + return type === "tableCell" || type === "tableHeader"; +} + +// --------------------------------------------------------------------------- +// Cell navigation (Tab / Shift-Tab) +// --------------------------------------------------------------------------- + +// Finds the cell / row / table the text cursor is currently inside, by +// walking up the ancestor chain with `editor.getParentBlock`. Returns +// undefined when the cursor isn't in a table. +function getCellContext( + editor: AnyEditor, +): { cell: AnyBlock; row: AnyBlock; table: AnyBlock } | undefined { + let current: AnyBlock | undefined = editor.getTextCursorPosition().block; + while (current && !isCellType(current.type)) { + current = editor.getParentBlock(current); + } + if (!current) { + return undefined; + } + + const row = editor.getParentBlock(current); + if (!row || row.type !== "tableRow") { + return undefined; + } + const table = editor.getParentBlock(row); + if (!table || table.type !== "table") { + return undefined; + } + + return { cell: current, row, table }; +} + +// Places the cursor inside a cell. Descends through nested tables so the +// cursor always lands on a block that can actually hold it. +function placeCursorInCell( + editor: AnyEditor, + cell: AnyBlock, + placement: "start" | "end", +) { + let target = cell; + while ( + target.children.length > 0 && + (target.type === "table" || + target.type === "tableRow" || + isCellType(target.type)) + ) { + target = + placement === "start" + ? target.children[0] + : target.children[target.children.length - 1]; + } + editor.setTextCursorPosition(target, placement); +} + +function createRow( + numColumns: number, + cellType: "tableCell" | "tableHeader" = "tableCell", +) { + return { + type: "tableRow" as const, + children: Array.from({ length: numColumns }, () => ({ type: cellType })), + }; +} + +// Moves the cursor to the next/previous cell, wrapping across rows. Tab past +// the last cell grows the table by a row, like in a spreadsheet, with a +// single `insertBlocks` call. +function moveToAdjacentCell(editor: AnyEditor, direction: 1 | -1): boolean { + const context = getCellContext(editor); + if (!context) { + // Not in a table: let BlockNote's default Tab (indent) behavior run. + return false; + } + const { cell, row, table } = context; + + const rows = table.children; + const rowIndex = rows.findIndex((r) => r.id === row.id); + const cellIndex = row.children.findIndex((c) => c.id === cell.id); + + let targetRowIndex = rowIndex; + let targetCellIndex = cellIndex + direction; + if (targetCellIndex >= row.children.length) { + targetRowIndex += 1; + targetCellIndex = 0; + } else if (targetCellIndex < 0) { + targetRowIndex -= 1; + targetCellIndex = + targetRowIndex >= 0 ? rows[targetRowIndex].children.length - 1 : 0; + } + + // Shift-Tab at the very first cell: stay put (but consume the key so the + // cell's content isn't un-indented out of the table). + if (targetRowIndex < 0) { + return true; + } + + // Tab at the very last cell: append a new row and move into it. + if (targetRowIndex >= rows.length) { + editor.insertBlocks( + [createRow(row.children.length)], + rows[rows.length - 1], + "after", + ); + const updatedTable = editor.getBlock(table.id); + const newRow = updatedTable?.children[updatedTable.children.length - 1]; + if (newRow) { + placeCursorInCell(editor, newRow.children[0], "start"); + } + return true; + } + + placeCursorInCell( + editor, + rows[targetRowIndex].children[targetCellIndex], + direction === 1 ? "start" : "end", + ); + return true; +} + +// Registered on the `table` block spec, so the shortcuts are only added when +// the block is in the schema. Block-spec extensions run before BlockNote's +// default keyboard handlers, so Tab reaches us before the default indent. +const TableKeyboardExtension = createExtension({ + key: "containerTableKeyboard", + keyboardShortcuts: { + Tab: ({ editor }) => moveToAdjacentCell(editor, 1), + "Shift-Tab": ({ editor }) => moveToAdjacentCell(editor, -1), + }, +}); + +// --------------------------------------------------------------------------- +// Structural operations, using only the public block manipulation API +// --------------------------------------------------------------------------- + +function getTable(editor: AnyEditor, tableId: string): AnyBlock | undefined { + const table = editor.getBlock(tableId); + return table?.type === "table" ? table : undefined; +} + +export function addRow(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table) { + return; + } + const lastRow = table.children[table.children.length - 1]; + editor.insertBlocks([createRow(lastRow.children.length)], lastRow, "after"); +} + +export function removeRow(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table || table.children.length <= 1) { + return; + } + editor.removeBlocks([table.children[table.children.length - 1]]); +} + +export function addColumn(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table) { + return; + } + editor.transact(() => { + for (const row of table.children) { + const lastCell = row.children[row.children.length - 1]; + // Match the row's cell kind, so a header row grows a header cell. + editor.insertBlocks([{ type: lastCell.type }], lastCell, "after"); + } + }); +} + +export function removeColumn(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table || table.children.some((row) => row.children.length <= 1)) { + return; + } + editor.transact(() => { + for (const row of table.children) { + editor.removeBlocks([row.children[row.children.length - 1]]); + } + }); +} + +// Flips the first row between header cells and regular cells. Because header +// cells are a distinct block type (not table metadata), this is just +// `updateBlock` with a new type. Children are carried over automatically. +export function toggleHeaderRow(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table) { + return; + } + const firstRow = table.children[0]; + const allHeaders = firstRow.children.every((c) => c.type === "tableHeader"); + const type = allHeaders ? "tableCell" : "tableHeader"; + editor.transact(() => { + for (const cell of firstRow.children) { + editor.updateBlock(cell, { type }); + } + }); +} + +// --------------------------------------------------------------------------- +// The four block specs +// --------------------------------------------------------------------------- + +// The table itself: a container that only accepts rows. Inserting one with +// no explicit children seeds it from `children.default`: a header row plus +// two body rows, three columns wide. +export const createTable = createReactBlockSpec( + { + type: "table", + propSchema: {}, + content: "none", + children: { + allow: ["tableRow"], + default: [ + createRow(3, "tableHeader"), + createRow(3, "tableCell"), + createRow(3, "tableCell"), + ], + }, + }, + { + render: (props) => { + // `props.block` is captured at render time; the control handlers + // re-fetch the table by id so they always operate on fresh children. + const { editor } = props; + const tableId = props.block.id; + + // Keep focus (and the text selection) in the editor when clicking the + // controls. + const keepFocus = (event: React.MouseEvent) => event.preventDefault(); + + return ( +
+
+
+ + + + + +
+
+ ); + }, + // Recognizes pasted foreign HTML tables. + parse: (el) => (el.tagName === "TABLE" ? {} : undefined), + }, + [TableKeyboardExtension], +); + +// A row: only lives inside a table (`placement: "containerOnly"`), only +// holds cells. +export const createTableRow = createReactBlockSpec( + { + type: "tableRow", + propSchema: {}, + content: "none", + children: { allow: ["tableCell", "tableHeader"] }, + placement: "containerOnly", + }, + { + // No drag handle of its own; the side menu handle falls through to the + // table. + meta: { draggable: false }, + render: (props) => ( +
+ ), + parse: (el) => (el.tagName === "TR" ? {} : undefined), + }, +); + +// A cell: holds any blocks, and is `boundary: "sealed"`, so the caret and +// content never implicitly cross its edge (Backspace at the start of a cell +// does nothing, Delete at its end doesn't pull the next block in, arrow keys +// from outside treat the table as a unit). Enter inside a cell just adds +// another block to the cell. +export const createTableCell = createReactBlockSpec( + { + type: "tableCell", + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "sealed" }, + placement: "containerOnly", + }, + { + meta: { draggable: false }, + render: (props) => ( +
+ ), + parse: (el) => (el.tagName === "TD" ? {} : undefined), + }, +); + +// A header cell: identical to a regular cell, but a distinct block type. +// The structure itself encodes which cells are headers, instead of +// `headerRows` metadata on the table. +export const createTableHeader = createReactBlockSpec( + { + type: "tableHeader", + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "sealed" }, + placement: "containerOnly", + }, + { + meta: { draggable: false }, + render: (props) => ( +
+ ), + parse: (el) => (el.tagName === "TH" ? {} : undefined), + }, +); diff --git a/examples/06-custom-schema/12-container-table/src/styles.css b/examples/06-custom-schema/12-container-table/src/styles.css new file mode 100644 index 0000000000..cfb39149f8 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/src/styles.css @@ -0,0 +1,100 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.item { + border-radius: 0.5rem; + flex: 1; + overflow: hidden; +} + +.item.bordered { + border: 1px solid gray; +} + +.item pre { + border-radius: 0.5rem; + height: 100%; + overflow: auto; + padding-block: 1rem; + padding-inline: 54px; + width: 100%; + white-space: pre-wrap; +} + +/* The grid is plain CSS tables on divs. The React node-view wrappers between + the regions carry `display: contents`, so the row and cell boxes end up + direct children of the table box as far as layout is concerned. */ +.container-table { + flex-grow: 1; + min-width: 0; +} + +.container-table-rows { + display: table; + border-collapse: collapse; + width: 100%; + table-layout: fixed; +} + +.container-table-row { + display: table-row; +} + +.container-table-cell { + display: table-cell; + border: 1px solid #d0d0d0; + padding: 4px 8px; + vertical-align: top; +} + +.container-table-header { + background-color: #f3f4f6; + font-weight: 600; +} + +[data-color-scheme="dark"] .container-table-cell { + border-color: #4b4b4b; +} + +[data-color-scheme="dark"] .container-table-header { + background-color: #2e2e2e; +} + +.container-table-controls { + display: flex; + gap: 4px; + padding-top: 4px; + /* Only reveal the controls while working in the table. */ + opacity: 0; + transition: opacity 0.15s; +} + +.container-table:hover .container-table-controls, +.container-table:focus-within .container-table-controls { + opacity: 1; +} + +.container-table-controls button { + border: 1px solid #d0d0d0; + border-radius: 4px; + background: none; + color: inherit; + font-size: 0.75rem; + padding: 2px 8px; + cursor: pointer; +} + +.container-table-controls button:hover { + background-color: #f3f4f6; +} + +[data-color-scheme="dark"] .container-table-controls button { + border-color: #4b4b4b; +} + +[data-color-scheme="dark"] .container-table-controls button:hover { + background-color: #2e2e2e; +} diff --git a/examples/06-custom-schema/12-container-table/tsconfig.json b/examples/06-custom-schema/12-container-table/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/tsconfig.json @@ -0,0 +1,32 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true, + "paths": { + "@shared/*": ["../../../shared/*"] + } + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/12-container-table/vite-env.d.ts b/examples/06-custom-schema/12-container-table/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/12-container-table/vite.config.ts b/examples/06-custom-schema/12-container-table/vite.config.ts new file mode 100644 index 0000000000..a96f1f04ff --- /dev/null +++ b/examples/06-custom-schema/12-container-table/vite.config.ts @@ -0,0 +1,35 @@ +// 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"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // The repo-wide alias for the shared test-utils directory (private, + // so it only resolves inside the monorepo). Harmless for examples + // that don't use it. + "@shared": path.resolve(__dirname, "../../../shared/"), + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 155a460786..5f37849636 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1457,6 +1457,33 @@ export const examples = { readme: "In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)", }, + { + projectSlug: "container-block", + fullSlug: "custom-schema/container-block", + pathFromRoot: "examples/06-custom-schema/09-container-block", + config: { + playground: true, + docs: true, + author: "nickthesick", + tags: [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Container Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we create a custom `Callout` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block.\n\nThe block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime.\n\nThe callout\'s **title** demonstrates the complementary "string prop slot" pattern: a field that doesn\'t need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block\'s own `content: "inline"` instead.\n\nWe also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the callout\'s body and add a code block, heading, or list.\n- Type a title into the title field. It\'s stored on `block.props.title`, not as document content.\n- Watch the JSON panel on the right update as you edit; the callout\'s children appear in `block.children`.\n- Insert a new callout via the Slash Menu (search "callout").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + }, { projectSlug: "math-block", fullSlug: "custom-schema/math-block", @@ -1536,6 +1563,33 @@ export const examples = { readme: 'In this example, we build custom blocks on the source-with-preview pattern — the same building blocks behind BlockNote\'s math and diagram blocks. A custom "CSV table" block renders its comma-separated source as a table, and a custom "color" inline content renders a CSS color as a swatch. Both show the rendered preview in place, while the source is edited in a popup.\n\n**Try it out:** Click the table or a color chip to edit its source!\n\n**Relevant Docs:**\n\n- [Source with Preview Blocks](/docs/features/custom-schemas/source-with-preview)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content)', }, + { + projectSlug: "container-table", + fullSlug: "custom-schema/container-table", + pathFromRoot: "examples/06-custom-schema/12-container-table", + config: { + playground: true, + docs: true, + author: "nickthesick", + tags: [ + "Advanced", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Table Built From Container Blocks", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we rebuild BlockNote\'s table as four container blocks: `table`, `tableRow`, `tableCell`, and `tableHeader`. There is no `prosemirror-tables` and no special `"table"` content type. A table is a container of rows, a row is a container of cells, and a cell is a container of arbitrary blocks, so cells can hold lists, headings, images, or even nested tables. The JSON shape is the same `children` array every other block uses.\n\nCells declare `boundary: "sealed"`, which makes them behave like compartments: Backspace, Delete, and arrow keys never implicitly move content or the caret across a cell\'s edge, and Enter adds another block _inside_ the cell. Header cells are a distinct block type rather than table metadata, so toggling the header row is just `updateBlock` with a new type. All structural operations, from adding and removing rows and columns to Tab-to-next-cell, are plain calls to the public block manipulation API: `insertBlocks`, `removeBlocks`, `updateBlock`, `getParentBlock`, and `setTextCursorPosition`.\n\n**Try it out:**\n\n- Press Tab / Shift-Tab to move between cells. Tab in the last cell adds a new row.\n- Press Enter inside a cell to stack more blocks in it, or "/" to add a list or heading.\n- Hover the table to reveal the row/column controls, and watch the JSON panel update.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Manipulating Blocks](/docs/reference/editor/manipulating-content)\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 6decb347ed..d18218456e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3459,6 +3459,52 @@ importers: specifier: ^8.0.0 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.9.0) + examples/06-custom-schema/09-container-block: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + 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.9.0) + examples/06-custom-schema/09-math-block: dependencies: '@blocknote/ariakit': @@ -3609,6 +3655,52 @@ importers: specifier: ^8.0.0 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.9.0) + examples/06-custom-schema/12-container-table: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + 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.9.0) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit':