From ca44851b4fd5ec36de9f88330b237c394d491e06 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 20 Aug 2026 13:56:44 +0200 Subject: [PATCH 1/2] feat: add container block API for nested blocks Squash of the container-block work: core container/children config API, React rendering support, column migration to the container API, examples, docs, and tests. --- .../custom-schemas/container-blocks.mdx | 280 ++++++ .../features/custom-schemas/custom-blocks.mdx | 6 + .../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 | 102 ++ .../09-container-block/src/styles.css | 123 +++ .../09-container-block/tsconfig.json | 29 + .../09-container-block/vite-env.d.ts | 1 + .../09-container-block/vite.config.ts | 31 + .../12-table-container/.bnexample.json | 15 + .../12-table-container/README.md | 20 + .../12-table-container/index.html | 14 + .../12-table-container/main.tsx | 11 + .../12-table-container/package.json | 31 + .../12-table-container/src/App.tsx | 201 ++++ .../12-table-container/src/gridTableBlocks.ts | 245 +++++ .../src/gridTableNavigation.ts | 73 ++ .../src/gridTableOperations.ts | 219 ++++ .../12-table-container/src/styles.css | 95 ++ .../12-table-container/tsconfig.json | 32 + .../12-table-container/vite-env.d.ts | 1 + .../12-table-container/vite.config.ts | 35 + .../vanilla-custom-side-menu/package.json | 2 +- packages/core/package.json | 5 + .../commands/insertBlocks/insertBlocks.ts | 113 ++- .../insertBlocks/insertPlacement.test.ts | 238 +++++ .../commands/mergeBlocks/mergeBlocks.ts | 75 +- .../commands/moveBlocks/moveBlocks.test.ts | 2 +- .../commands/moveBlocks/moveBlocks.ts | 60 +- .../commands/nestBlock/nestBlock.ts | 36 +- .../commands/replaceBlocks/replaceBlocks.ts | 29 +- .../replaceBlocks/util/fixColumnList.ts | 173 ---- .../commands/splitBlock/splitBlock.test.ts | 2 +- .../commands/splitBlock/splitBlock.ts | 2 +- .../commands/updateBlock/updateBlock.test.ts | 12 +- .../commands/updateBlock/updateBlock.ts | 145 ++- .../__snapshots__/containers.test.ts.snap | 34 + .../containers/containerNav.ts | 163 +++ .../containers/containerUI.ts | 61 ++ .../containers/containers.browser.test.ts | 279 ++++++ .../containers/containers.fixture.ts | 106 ++ .../containers/containers.test.ts | 355 +++++++ .../contentContainers.browser.test.ts | 259 +++++ .../containers/contentContainers.fixture.ts | 94 ++ .../containers/contentContainers.test.ts | 358 +++++++ .../containers/fixContainer.ts | 282 ++++++ .../blockManipulation/selections/selection.ts | 7 +- .../selections/textCursorPosition.ts | 13 +- .../html/util/serializeBlocksExternalHTML.ts | 41 +- .../html/util/serializeBlocksInternalHTML.ts | 70 +- packages/core/src/api/getBlockInfoFromPos.ts | 80 +- .../src/api/nodeConversions/blockToNode.ts | 274 ++++- .../nodeConversions/contentContainers.test.ts | 429 ++++++++ .../api/nodeConversions/fragmentToBlocks.ts | 125 ++- .../src/api/nodeConversions/nodeToBlock.ts | 97 +- packages/core/src/api/pmUtil.ts | 12 +- .../ListItem/ListItemKeyboardShortcuts.ts | 2 +- .../NumberedListItem/IndexingPlugin.ts | 4 +- .../src/blocks/utils/listItemEnterHandler.ts | 2 +- packages/core/src/editor/BlockNoteEditor.ts | 18 +- .../core/src/editor/managers/BlockManager.ts | 11 +- .../managers/ExtensionManager/extensions.ts | 22 +- .../editor/managers/ExtensionManager/index.ts | 2 +- packages/core/src/editor/transformPasted.ts | 6 +- packages/core/src/exporter/Exporter.ts | 29 +- .../core/src/extensions/SideMenu/SideMenu.ts | 98 +- .../sideMenuContainerGeometry.browser.test.ts | 267 +++++ .../sideMenuContainerGeometry.test.ts | 112 +++ .../SideMenu/sideMenuContainerGeometry.ts | 164 +++ ...tDraggableBlockFromElement.browser.test.ts | 111 ++ .../getDraggableBlockFromElement.ts | 47 +- .../KeyboardShortcutsExtension.ts | 418 ++++++-- .../tiptap-extensions/UniqueID/UniqueID.ts | 9 +- packages/core/src/fonts/inter.css | 18 +- packages/core/src/index.ts | 6 +- packages/core/src/internal.ts | 75 ++ packages/core/src/pm-nodes/README.md | 89 +- .../schema/blocks/assertSchemaInvariants.ts | 62 ++ .../core/src/schema/blocks/children.test.ts | 134 +++ packages/core/src/schema/blocks/children.ts | 323 ++++++ .../src/schema/blocks/containerAttributes.ts | 99 ++ .../blocks/containerParse.browser.test.ts | 339 +++++++ packages/core/src/schema/blocks/createSpec.ts | 750 +++++++++++--- packages/core/src/schema/blocks/internal.ts | 32 +- packages/core/src/schema/blocks/types.ts | 199 +++- .../schema/blocks/validateChildren.test.ts | 252 +++++ .../src/schema/blocks/validateChildren.ts | 392 ++++++++ packages/core/src/schema/index.ts | 7 + packages/core/src/schema/schema.ts | 39 + .../y/extensions/AttributionExtension.test.ts | 17 +- .../core/src/yjs/extensions/FixUpSchema.ts | 10 +- packages/core/vite.config.ts | 1 + packages/core/vitestSetup.ts | 11 +- .../src/components/Popovers/BlockPopover.tsx | 24 +- packages/react/src/editor/styles.css | 7 + .../ReactBlockSpec.container.browser.test.tsx | 168 ++++ .../schema/ReactBlockSpec.container.test.tsx | 179 ++++ packages/react/src/schema/ReactBlockSpec.tsx | 322 ++++-- packages/react/src/schema/useNodeViewBlock.ts | 11 + packages/react/vite.config.ts | 5 +- packages/react/vitestSetup.ts | 59 +- .../formats/html-blocks/collabUpdate.test.ts | 2 +- packages/xl-ai/src/prosemirror/agent.test.ts | 10 +- .../xl-ai/src/prosemirror/rebaseTool.test.ts | 6 +- .../cases/combinedOperationsTestCases.ts | 2 +- .../cases/updateOperationTestCases.ts | 6 +- .../src/docx/docxExporter.test.ts | 77 ++ .../xl-docx-exporter/src/docx/docxExporter.ts | 4 +- .../src/react-email/reactEmailExporter.tsx | 18 + .../src/blocks/Columns/index.ts | 83 +- .../ColumnResize/ColumnResizeExtension.ts | 16 +- .../DropCursor/multiColumnDropCursor.ts | 18 +- .../DropCursor/multiColumnHandleDropPlugin.ts | 118 ++- .../xl-multi-column/src/pm-nodes/Column.ts | 91 -- .../src/pm-nodes/ColumnList.ts | 47 - ...test.ts.snap => fixContainer.test.ts.snap} | 14 +- ...lumnLists.test.ts => fixContainer.test.ts} | 38 +- .../multi-column/undefined/external.html | 2 +- .../multi-column/undefined/internal.html | 2 +- .../xl-odt-exporter/src/odt/odtExporter.tsx | 2 +- .../xl-pdf-exporter/src/pdf/pdfExporter.tsx | 2 +- playground/src/examples.gen.tsx | 54 + pnpm-lock.yaml | 945 +++++++++++++++++- .../end-to-end/containerblocks/CardApp.tsx | 107 ++ tests/src/end-to-end/containerblocks/card.css | 33 + .../containerblocks/containerblocks.test.tsx | 238 +++++ .../multicolumn/multicolumn.test.tsx | 57 ++ .../fixtures/suggestionFixture.tsx | 4 +- .../src/unit/react/useNodeViewBlock.test.tsx | 49 +- 133 files changed, 11726 insertions(+), 1079 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-table-container/.bnexample.json create mode 100644 examples/06-custom-schema/12-table-container/README.md create mode 100644 examples/06-custom-schema/12-table-container/index.html create mode 100644 examples/06-custom-schema/12-table-container/main.tsx create mode 100644 examples/06-custom-schema/12-table-container/package.json create mode 100644 examples/06-custom-schema/12-table-container/src/App.tsx create mode 100644 examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts create mode 100644 examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts create mode 100644 examples/06-custom-schema/12-table-container/src/gridTableOperations.ts create mode 100644 examples/06-custom-schema/12-table-container/src/styles.css create mode 100644 examples/06-custom-schema/12-table-container/tsconfig.json create mode 100644 examples/06-custom-schema/12-table-container/vite-env.d.ts create mode 100644 examples/06-custom-schema/12-table-container/vite.config.ts create mode 100644 packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts delete mode 100644 packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts create mode 100644 packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap create mode 100644 packages/core/src/api/blockManipulation/containers/containerNav.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containerUI.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containers.browser.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containers.fixture.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containers.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts create mode 100644 packages/core/src/api/blockManipulation/containers/contentContainers.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/fixContainer.ts create mode 100644 packages/core/src/api/nodeConversions/contentContainers.test.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts create mode 100644 packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts create mode 100644 packages/core/src/internal.ts create mode 100644 packages/core/src/schema/blocks/assertSchemaInvariants.ts create mode 100644 packages/core/src/schema/blocks/children.test.ts create mode 100644 packages/core/src/schema/blocks/children.ts create mode 100644 packages/core/src/schema/blocks/containerAttributes.ts create mode 100644 packages/core/src/schema/blocks/containerParse.browser.test.ts create mode 100644 packages/core/src/schema/blocks/validateChildren.test.ts create mode 100644 packages/core/src/schema/blocks/validateChildren.ts create mode 100644 packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx create mode 100644 packages/react/src/schema/ReactBlockSpec.container.test.tsx delete mode 100644 packages/xl-multi-column/src/pm-nodes/Column.ts delete mode 100644 packages/xl-multi-column/src/pm-nodes/ColumnList.ts rename packages/xl-multi-column/src/test/commands/util/__snapshots__/{fixColumnLists.test.ts.snap => fixContainer.test.ts.snap} (95%) rename packages/xl-multi-column/src/test/commands/util/{fixColumnLists.test.ts => fixContainer.test.ts} (91%) create mode 100644 tests/src/end-to-end/containerblocks/CardApp.tsx create mode 100644 tests/src/end-to-end/containerblocks/card.css create mode 100644 tests/src/end-to-end/containerblocks/containerblocks.test.tsx 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..9ad7e4916d --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,280 @@ +--- +title: Container Blocks +description: Learn how to create custom blocks that hold other blocks as their body +--- + +# Container Blocks + +A *container block* is a custom block that holds other blocks as its body — like a Notion-style callout wrapping a paragraph and a code block, 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. + +Take a look at the demo below, in which we add a custom callout block that can contain any other blocks: + + + +## Declaring a Container Block + +Add the `children` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). Everything about it is optional, so the smallest container is: + +```typescript +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: {}, + content: "none", + // Makes this a container: its body is other blocks. + children: {}, + }, + { + // Child blocks mount into the element you attach `contentRef` to. + render: (props) =>
, + }, +); +``` + +`children: {}` accepts any block, requires at least one, and can never throw: 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: + +```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 | + +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. + + + _Because the framework wrappers React puts above your element carry `display: + contents`, they contribute no box: 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._ + + +## 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, 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: { min: 0 }, + }, + { + render: (props) => ( +
+ +
+
+ ), + }, +); +``` + +This is purely additive: adding `children` to an existing block is one config line and **zero render changes**. The block keeps its `Block` JSON shape — `content` for its own content, `children` for its body — identical to any other nested block. + +### The two regions + +Inside the `contentRef` element, BlockNote renders two sibling elements with stable attributes derived from the block type: + +- `[data-content-type=""]` — the block's own inline content. +- `[data-children-of=""]` — 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; } +``` + + + **Two limits, both imposed by ProseMirror:** 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` | any block | What may appear as a child. See [Restricting children](#restricting-children). | +| `min` / `max` | `1` / unbounded | How many children are allowed. Compiled into the editor schema. | +| `sequence` | — | An *ordered* body instead of a uniform one. Mutually exclusive with `allow`/`min`/`max`. See [Ordered children](#ordered-children). | +| `default` | — | Partial blocks to create the container with when it's inserted without an explicit `children` array. Validated against the rest of the config when the schema is created. Omit it and BlockNote fills the container with whatever its schema requires. | +| `unwrapWhenEmptied` | `false` | As children are emptied out, drop the emptied ones and — once fewer non-empty children remain than `min` — replace the container with its survivors, or remove it entirely when none are left. Column lists use this so emptied columns disappear and a one-column list unwraps. | +| `exitOnEnter` | `true` | Pressing Enter on an empty block that is the last child moves that block out of the container, list-style. Disable it to keep the cursor inside (columns do this). | + +`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 `children.allow.containers` — like a `column`, which only makes sense inside a `columnList`. Only valid on container blocks. | + +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. | + + + _`unwrapWhenEmptied` never destroys typed text: for a container with its own + content, it does nothing at all while that content is non-empty. And without + it, a container that drops below `min` isn't unwrapped — ProseMirror refills + it with an empty child instead._ + + +## Restricting children + +`allow` has two fields, because the document schema can make exactly two distinctions: + +```typescript +allow?: { + // Whether regular (non-container) blocks are allowed. + blocks?: boolean; + // Which container-block types are allowed: `true`, `false`, or a list. + containers?: boolean | string[]; +} +``` + +Container blocks are their own ProseMirror node type, so naming them is exact. Every **regular** block — paragraph, heading, code block — is the *same* ProseMirror node internally, so "only headings" is not something the schema can enforce. Rather than offer an option that silently does nothing, `allow.blocks` is a boolean. Naming a regular block type in `allow.containers` is a hard error that says so. + +This is exactly how the multi-column blocks are defined: + +```typescript +// The outer container: only columns, at least two of them. +children: { + allow: { blocks: false, containers: ["column"] }, + min: 2, + unwrapWhenEmptied: true, + exitOnEnter: false, +} + +// The column: holds any blocks, but only lives inside a columnList. +children: { exitOnEnter: false }, +placement: "containerOnly", +``` + +## Ordered children + +`sequence` replaces `allow`/`min`/`max` with a list of *positions*. Each slot holds exactly one child unless it declares a `count`: + +```typescript +children: { + sequence: [ + { allow: { blocks: false, containers: ["cardHeader"] } }, + { allow: { blocks: false, containers: ["cardBody"] } }, + ], +} +``` + +That compiles to the ProseMirror content expression `cardHeader cardBody`, so the order is enforced by the document model itself: a card built as `[body, header]` is rejected before it can reach the document, and a card inserted with no children auto-fills one of each. + +`count` takes a number for an exact count, or `{ min, max }`: + +```typescript +children: { + sequence: [ + // Exactly one header. + { allow: { blocks: false, containers: ["cardHeader"] } }, + // Then one or more blocks. + { count: { min: 1 } }, + ], +} +``` + +The uniform form is exactly sugar for a one-slot sequence: `{ allow, min, max }` is `{ sequence: [{ allow, count: { min, max } }] }`. + +## Inserting into a container + +`editor.insertBlocks` 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, or `allow.containers` naming a regular block type; +- an empty `sequence`, and `content: "table"` combined with `children`; +- a `placement: "containerOnly"` block that no container accepts, or `placement` on a non-container; +- **container cycles** — a container that (transitively) requires a child that requires it back could never be created. Slots that allow regular blocks break 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` — it 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 — a container's `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. If a field doesn't need rich text formatting, comments, or multiplayer cursors — a name, a URL, a label — 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 at the top of this page does exactly that for its title. + +Reach for a container's own `content: "inline"` when the field *is* prose, and for 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..7f919322f4 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/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..9e4ee82060 --- /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 that can wrap a paragraph followed by a code block, or any combination of nested blocks. + +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 — anything goes. +- Type a title into the title field — it's stored on `block.props.title`, not as document content. +- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`. +- Insert a new callout via the Slash Menu (search "callout"). + +**Relevant Docs:** + +- [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..d92c915975 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "^0.1.24" + } +} diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx new file mode 100644 index 0000000000..8945f4a31a --- /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..3fb3a86da8 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -0,0 +1,102 @@ +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 new +// `children` config — the block hosts arbitrary child blocks in its body, +// exposed at runtime as `block.children`. +// +// The callout's title demonstrates the complementary "string prop slot" +// pattern: content that shouldn't be part of the rich-text document (no +// formatting, comments, or multiplayer cursors needed) can live in a plain +// string prop, edited through a regular rendered inside the block. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + title: { + default: "", + }, + }, + content: "none", + children: { + min: 1, + default: [{ type: "paragraph" }], + }, + }, + { + render: (props) => { + const flavor = + calloutTypes.find((c) => c.value === props.block.props.flavor) ?? + calloutTypes[0]; + const Icon = flavor.icon; + + const cycleFlavor = () => { + const idx = calloutTypes.findIndex( + (c) => c.value === props.block.props.flavor, + ); + const next = calloutTypes[(idx + 1) % calloutTypes.length]; + props.editor.updateBlock(props.block, { + type: "callout", + props: { flavor: next.value }, + }); + }; + + const commitTitle = (title: string) => { + if (title !== props.block.props.title) { + props.editor.updateBlock(props.block, { + type: "callout", + props: { title }, + }); + } + }; + + return ( +
+ +
+ {/* The title lives in a string prop, not in document content — + it's edited via a plain input. `contentEditable={false}` keeps + ProseMirror from treating typing here as document input. */} +
+ commitTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> +
+
+
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css new file mode 100644 index 0000000000..72d3d0283f --- /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 — so a callout left on + `tip` carries no `data-flavor` at all. 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..93fa81bee8 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/tsconfig.json @@ -0,0 +1,29 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/09-container-block/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts new file mode 100644 index 0000000000..bc2d8a36f3 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/09-container-block/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts new file mode 100644 index 0000000000..0133a6da9e --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite.config.ts @@ -0,0 +1,31 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/06-custom-schema/12-table-container/.bnexample.json b/examples/06-custom-schema/12-table-container/.bnexample.json new file mode 100644 index 0000000000..5f15de2aa8 --- /dev/null +++ b/examples/06-custom-schema/12-table-container/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": false, + "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-table-container/README.md b/examples/06-custom-schema/12-table-container/README.md new file mode 100644 index 0000000000..8ac3ae79e2 --- /dev/null +++ b/examples/06-custom-schema/12-table-container/README.md @@ -0,0 +1,20 @@ +# Table From Container Blocks + +In this example, we build a table out of three nested container blocks — `gridTable > gridRow > gridCell` — instead of a `content: "table"` block. The whole nesting structure comes from each block's `children` config, so the document schema enforces it, and because a cell's `children` allows any block, cells can hold lists, headings, code blocks, or another table. + +The blocks render real `` / `` / `
` elements: a container block owns its outer DOM, so `contentRef` / `contentDOM` places its children directly inside. They're written with the vanilla `createBlockSpec` rather than `createReactBlockSpec` for exactly that reason — a React block renders through a `NodeViewWrapper` div, and a `
` between a `` and its rows gives you a table that looks right but isn't one to the DOM. + +What the container API can't express is the grid _semantics_: nothing in `children` can say "every row has the same number of cells", so the row/column buttons and the Tab navigation are ordinary code written against `insertBlocks` / `removeBlocks`. Column spans, rectangular cell selection, and column resizing aren't implemented at all. + +**Try it out:** + +- Type in a cell, then press Tab and Shift-Tab to move between cells — Tab past the last cell adds a row. +- Use the `+ Row` / `+ Column` buttons below a table, and watch the shape readout next to them. +- Press "/" inside a cell and insert a heading, list, or even another Grid Table. +- Watch the JSON panel: a table is just `gridTable > gridRow > gridCell` in `block.children`. + +**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/12-table-container/index.html b/examples/06-custom-schema/12-table-container/index.html new file mode 100644 index 0000000000..f8e228611b --- /dev/null +++ b/examples/06-custom-schema/12-table-container/index.html @@ -0,0 +1,14 @@ + + + + + Table From Container Blocks + + + +
+ + + diff --git a/examples/06-custom-schema/12-table-container/main.tsx b/examples/06-custom-schema/12-table-container/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/12-table-container/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-table-container/package.json b/examples/06-custom-schema/12-table-container/package.json new file mode 100644 index 0000000000..8e91af71db --- /dev/null +++ b/examples/06-custom-schema/12-table-container/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-table-container", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vp dev", + "dev": "vp dev", + "build:prod": "tsc && vp build", + "preview": "vp preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite-plus": "^0.1.24" + } +} diff --git a/examples/06-custom-schema/12-table-container/src/App.tsx b/examples/06-custom-schema/12-table-container/src/App.tsx new file mode 100644 index 0000000000..58d626d3ab --- /dev/null +++ b/examples/06-custom-schema/12-table-container/src/App.tsx @@ -0,0 +1,201 @@ +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 { RiLayoutGridLine } from "react-icons/ri"; + +import { GridCellBlock, GridRowBlock, GridTableBlock } from "./gridTableBlocks"; +import "./styles.css"; + +// The three container blocks are ordinary members of the schema — the default +// `table` block (which uses `content: "table"`) is still here alongside them, +// so you can compare the two approaches in the same document. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + gridTable: GridTableBlock(), + gridRow: GridRowBlock(), + gridCell: GridCellBlock(), + }, +}); + +const insertGridTable = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Grid Table", + subtext: "Table built out of nested container blocks", + onItemClick: () => + // No `children` given, so BlockNote seeds the table from the block's + // `children.default` — a 2x2 grid with a header row. + insertOrUpdateBlockForSlashMenu(editor, { type: "gridTable" }), + aliases: ["grid", "gridtable", "container table"], + group: "Basic blocks", + icon: , +}); + +type AppBlock = (typeof schema.BlockNoteEditor)["document"][number]; +type AppPartialBlock = typeof schema.PartialBlock; + +// Two shorthands, so the table below reads as a table rather than as four +// levels of nested object literals. +function headerCell(text: string) { + return { + type: "gridCell" as const, + props: { header: true }, + children: [{ type: "paragraph" as const, content: text }], + }; +} + +function cell(...children: AppPartialBlock[]): AppPartialBlock { + // `cell()` with no arguments passes `children: []`, which the cell's + // `min: 1` fills back up to a single empty paragraph. + return { type: "gridCell", children }; +} + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "The table below is not a table block — it's three container blocks nested into each other.", + }, + { + type: "gridTable", + children: [ + { + type: "gridRow", + children: [ + headerCell("Block"), + headerCell("children config"), + headerCell("Renders as"), + ], + }, + { + type: "gridRow", + children: [ + cell({ type: "paragraph", content: "gridTable" }), + cell({ type: "codeBlock", content: 'containers: ["gridRow"]' }), + cell({ type: "paragraph", content: "
" }), + ], + }, + { + type: "gridRow", + children: [ + cell({ type: "paragraph", content: "gridRow" }), + cell({ type: "codeBlock", content: 'containers: ["gridCell"]' }), + cell({ type: "paragraph", content: "" }), + ], + }, + { + type: "gridRow", + children: [ + cell({ type: "paragraph", content: "gridCell" }), + cell({ type: "codeBlock", content: "blocks: true" }), + cell({ type: "paragraph", content: "
/ " }), + ], + }, + ], + }, + { + type: "paragraph", + content: + 'Because a cell allows any block, it can hold things a `content: "table"` cell can\'t:', + }, + { + type: "gridTable", + children: [ + { + type: "gridRow", + children: [ + headerCell("A list in a cell"), + headerCell("A nested table"), + ], + }, + { + type: "gridRow", + children: [ + cell( + { type: "bulletListItem", content: "First" }, + { + type: "bulletListItem", + content: "Second", + children: [{ type: "bulletListItem", content: "Nested" }], + }, + { + type: "heading", + props: { level: 3 }, + content: "And a heading", + }, + ), + cell({ + type: "gridTable", + children: [ + { + type: "gridRow", + children: [cell(), cell()], + }, + { + type: "gridRow", + children: [cell(), cell()], + }, + ], + }), + ], + }, + ], + }, + { 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, + insertGridTable(editor), + ); + return filterSuggestionItems(defaultItems, query); + }} + /> + +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts b/examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts new file mode 100644 index 0000000000..d577a540b2 --- /dev/null +++ b/examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts @@ -0,0 +1,245 @@ +import { createBlockSpec } from "@blocknote/core"; + +import { GridTableNavigationExtension } from "./gridTableNavigation"; +import { + addColumn, + addRow, + removeColumn, + removeRow, + shapeFrom, +} from "./gridTableOperations"; + +/** + * A table built entirely out of container blocks — no `content: "table"`, no + * custom node spec, no ProseMirror plugin. Three container blocks nest into + * each other: + * + * gridTable > gridRow+ (a table is a list of rows) + * gridRow > gridCell+ (a row is a list of cells) + * gridCell > any block (a cell holds arbitrary blocks) + * + * The `children` config on each is what the ProseMirror content expression is + * compiled from, so the nesting rules above are enforced by the document + * schema rather than by repair code. + * + * What the schema does *not* enforce is that every row has the same number of + * cells: a content expression is a regular language over one node's children, + * and "as many cells as your sibling has" relates siblings to each other. So + * that invariant lives in `gridTableOperations`, which is exactly the point + * this example is here to make visible. + * + * These are deliberately vanilla `createBlockSpec` blocks rather than + * `createReactBlockSpec` ones, even though the surrounding app is React: a + * React block is rendered through a `NodeViewWrapper` div, and a `
` + * between a `` and its ``s produces a table that *looks* right + * (`display: contents` generates no box) but isn't one to the DOM — + * `table.rows` comes back empty, and React logs a nesting error. A vanilla + * block's `dom` is handed to ProseMirror as-is, so the elements below really + * do form a table. + */ + +// The subset of a ProseMirror node the table's `update` hook needs, so the +// example doesn't have to depend on `prosemirror-model` directly. +type GridNode = { + type: { name: string }; + childCount: number; + child(index: number): GridNode; +}; + +// A cell holds any blocks at all — paragraphs, headings, lists, even another +// grid table. That falls out of `allow.blocks: true` and costs nothing. +export const GridCellBlock = createBlockSpec( + { + type: "gridCell" as const, + propSchema: { + header: { default: false }, + }, + content: "none", + children: { + allow: { blocks: true }, + min: 1, + default: [{ type: "paragraph" }], + // A cell is not a list: Enter in the last block of a cell should add a + // block to the cell, not escape it. + exitOnEnter: false, + }, + // A cell only ever exists inside a row. The schema rejects one anywhere + // else, including at the document root. + placement: "containerOnly", + }, + { + meta: { + // Dragging a cell out of its row would leave a ragged table, and there is + // nowhere else a cell may legally go anyway. + draggable: false, + }, + render: (block) => { + // Container blocks own their outer DOM — they are not wrapped in a + // `blockContainer` div — so this really is a table cell. + const dom = document.createElement(block.props.header ? "th" : "td"); + dom.className = "grid-cell"; + + return { dom, contentDOM: dom }; + }, + }, +); + +export const GridRowBlock = createBlockSpec( + { + type: "gridRow" as const, + propSchema: {}, + content: "none", + children: { + // Rows hold cells and nothing else. `blocks: false` keeps a stray + // paragraph out; the explicit container list keeps every other container + // type out. + allow: { blocks: false, containers: ["gridCell"] }, + min: 1, + default: [{ type: "gridCell" }], + exitOnEnter: false, + }, + placement: "containerOnly", + }, + { + meta: { + draggable: false, + }, + render: () => { + const dom = document.createElement("tr"); + dom.className = "grid-row"; + + return { dom, contentDOM: dom }; + }, + }, +); + +export const GridTableBlock = createBlockSpec( + { + type: "gridTable" as const, + propSchema: {}, + content: "none", + children: { + allow: { blocks: false, containers: ["gridRow"] }, + min: 1, + // Inserting a bare `{ type: "gridTable" }` (from the Slash Menu, say) + // seeds this 2x2 grid. + default: [ + { + type: "gridRow", + children: [ + { type: "gridCell", props: { header: true } }, + { type: "gridCell", props: { header: true } }, + ], + }, + { + type: "gridRow", + children: [{ type: "gridCell" }, { type: "gridCell" }], + }, + ], + unwrapWhenEmptied: true, + }, + }, + { + render: (block, editor) => { + const dom = document.createElement("div"); + dom.className = "grid-table-wrapper"; + + const table = document.createElement("table"); + table.className = "grid-table"; + const tbody = document.createElement("tbody"); + table.append(tbody); + + // Grid-shape operations are plain calls to the public block API — + // `insertBlocks` / `removeBlocks` over `block.children`. The container + // API has no notion of a row or a column, so keeping the table + // rectangular is this code's job, not the schema's. + const controls = document.createElement("div"); + controls.className = "grid-table-controls"; + // Keeps ProseMirror from treating clicks in here as document input. + controls.contentEditable = "false"; + + const shapeLabel = document.createElement("span"); + shapeLabel.className = "grid-table-shape"; + controls.append(shapeLabel); + + const button = ( + label: string, + run: (table: NonNullable>) => void, + ) => { + const element = document.createElement("button"); + element.type = "button"; + element.textContent = label; + // Without this the button steals the selection from the editor, and + // the operation would run against a document with no cursor in it. + element.addEventListener("mousedown", (event) => + event.preventDefault(), + ); + element.addEventListener("click", () => { + // Re-read the block rather than closing over the one this render was + // handed: the node view outlives any single document state. + const current = editor.getBlock(block.id); + if (current) { + run(current); + } + }); + controls.append(element); + + return element; + }; + + button("+ Row", (current) => addRow(editor, current)); + const removeRowButton = button("- Row", (current) => + removeRow(editor, current), + ); + button("+ Column", (current) => addColumn(editor, current)); + const removeColumnButton = button("- Column", (current) => + removeColumn(editor, current), + ); + + dom.append(table, controls); + + const refresh = (node: GridNode) => { + const shape = shapeFrom( + Array.from({ length: node.childCount }, (_, row) => { + return node.child(row).childCount; + }), + ); + + shapeLabel.textContent = `${shape.rows} x ${shape.columns}${ + shape.ragged ? " (ragged)" : "" + }`; + removeRowButton.disabled = shape.rows <= 1; + removeColumnButton.disabled = shape.columns <= 1; + }; + + refresh({ + type: { name: "gridTable" }, + childCount: block.children.length, + child: (row) => + ({ childCount: block.children[row].children.length }) as GridNode, + }); + + return { + dom, + // The table's editable region is its : that is where the row + // blocks mount. A container's `contentDOM` is "put my children here", + // so it can point at a nested element like this one. + contentDOM: tbody, + // Containers honor `update`, and a table has to: recreating the node + // view on every edit would remount — and blur — every cell. + update: (node: GridNode) => { + if (node.type.name !== "gridTable") { + return false; + } + + refresh(node); + + return true; + }, + }; + }, + }, + // Cell-to-cell Tab movement isn't something the container API can express, so + // it ships as an extension alongside the block. + [GridTableNavigationExtension()], +); diff --git a/examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts b/examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts new file mode 100644 index 0000000000..2b628e48d8 --- /dev/null +++ b/examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts @@ -0,0 +1,73 @@ +import { type BlockNoteEditor, createExtension } from "@blocknote/core"; + +import { + addRow, + adjacentCell, + cursorTargetIn, + findCell, +} from "./gridTableOperations"; + +/** + * Tab / Shift-Tab movement between cells. + * + * The container API's keyboard story is one-dimensional and list-shaped + * (`exitOnEnter`, plus the built-in Enter/Backspace handling of children), so + * moving *across* a grid is entirely custom. This extension is attached to the + * `gridTable` block spec, which registers it ahead of BlockNote's own Tab + * binding (which indents blocks) — so the handler must return `false` whenever + * the cursor isn't in a grid cell, or Tab would stop indenting everywhere else. + */ +export const GridTableNavigationExtension = createExtension({ + key: "gridTableNavigation", + keyboardShortcuts: { + Tab: ({ editor }) => moveCell(editor, 1), + "Shift-Tab": ({ editor }) => moveCell(editor, -1), + }, +}); + +function moveCell(editor: BlockNoteEditor, direction: 1 | -1) { + const { block } = editor.getTextCursorPosition(); + const position = findCell(editor.document, block.id); + + if (!position) { + return false; + } + + const target = adjacentCell(position, direction); + + if (!target) { + // Tab past the last cell grows the table, the way every other table editor + // behaves. Shift-Tab at the very first cell just does nothing. + if (direction === -1) { + return true; + } + + addRow(editor, position.table); + + const grown = editor.getBlock(position.table.id); + const newRow = grown?.children.at(-1); + const firstCellOfNewRow = newRow?.children.at(0); + const cursorTarget = + firstCellOfNewRow && cursorTargetIn(firstCellOfNewRow, "start"); + + if (cursorTarget) { + editor.setTextCursorPosition(cursorTarget, "start"); + } + + return true; + } + + const cursorTarget = cursorTargetIn( + target, + direction === 1 ? "start" : "end", + ); + + if (cursorTarget) { + editor.setTextCursorPosition( + cursorTarget, + direction === 1 ? "start" : "end", + ); + } + + return true; +} diff --git a/examples/06-custom-schema/12-table-container/src/gridTableOperations.ts b/examples/06-custom-schema/12-table-container/src/gridTableOperations.ts new file mode 100644 index 0000000000..dda53b8ffd --- /dev/null +++ b/examples/06-custom-schema/12-table-container/src/gridTableOperations.ts @@ -0,0 +1,219 @@ +import type { Block, BlockNoteEditor } from "@blocknote/core"; + +/** + * Grid operations for the container-block table. + * + * Everything here is written against the *public* block API — `insertBlocks`, + * `removeBlocks`, and the `children` arrays on `editor.document`. None of it is + * declared in the `children` config, and that is the honest result of the + * experiment: the container API can say "a row holds cells", but it cannot say + * "every row holds the same number of cells", because a content expression is a + * regular language over one node's children and rectangularity is a constraint + * *between* siblings. So the invariant lives in imperative code, the way + * `prosemirror-tables` keeps it in `fixTables` rather than in its schema. + */ + +// The example's blocks are added to a schema the caller assembles, so these +// helpers are written against the generic editor/block types rather than a +// concrete schema. +type AnyEditor = BlockNoteEditor; +type AnyBlock = Block; + +export type GridShape = { + rows: number; + /** The widest row's cell count. */ + columns: number; + /** Whether the rows disagree on their cell count. */ + ragged: boolean; +}; + +export function gridShape(table: AnyBlock): GridShape { + return shapeFrom(table.children.map((row) => row.children.length)); +} + +/** {@link gridShape}, for a caller holding cell counts rather than blocks. */ +export function shapeFrom(cellCounts: number[]): GridShape { + return { + rows: cellCounts.length, + columns: cellCounts.length > 0 ? Math.max(...cellCounts) : 0, + ragged: new Set(cellCounts).size > 1, + }; +} + +function newCell(header: boolean) { + return { type: "gridCell" as const, props: { header } }; +} + +export function addRow(editor: AnyEditor, table: AnyBlock) { + const { columns } = gridShape(table); + const lastRow = table.children.at(-1); + const newRow = { + type: "gridRow" as const, + children: Array.from({ length: Math.max(columns, 1) }, () => + newCell(false), + ), + }; + + editor.transact(() => { + // A table always has at least one row (`min: 1`), so `lastRow` is only + // missing if something upstream is broken — but `"end"` addresses the + // container itself, so there is a correct answer either way. + if (lastRow) { + editor.insertBlocks([newRow], lastRow, "after"); + } else { + editor.insertBlocks([newRow], table, "end"); + } + }); +} + +export function removeRow(editor: AnyEditor, table: AnyBlock) { + const lastRow = table.children.at(-1); + // The schema requires one row, so removing the last one would just be + // re-filled by ProseMirror with an empty one. + if (!lastRow || table.children.length <= 1) { + return; + } + + editor.removeBlocks([lastRow]); +} + +export function addColumn(editor: AnyEditor, table: AnyBlock) { + // One transaction for the whole column, so undo takes the column back as a + // unit rather than one cell at a time. + editor.transact(() => { + for (const row of table.children) { + const lastCell = row.children.at(-1); + // Inherit the row's header-ness from the cell we're appending after, so + // adding a column to a table with a header row keeps the header intact. + const cell = newCell(lastCell?.props.header === true); + + if (lastCell) { + editor.insertBlocks([cell], lastCell, "after"); + } else { + editor.insertBlocks([cell], row, "end"); + } + } + }); +} + +export function removeColumn(editor: AnyEditor, table: AnyBlock) { + const cellsToRemove = table.children + // Same reasoning as `removeRow`: a row must keep at least one cell. + .filter((row) => row.children.length > 1) + .map((row) => row.children.at(-1)) + .filter((cell) => cell !== undefined); + + if (cellsToRemove.length === 0) { + return; + } + + editor.transact(() => editor.removeBlocks(cellsToRemove)); +} + +/** Where a cell sits in its table, plus the table it sits in. */ +export type CellPosition = { + table: AnyBlock; + row: number; + column: number; +}; + +/** + * Finds the cell the text cursor is in, by walking the document for a + * `gridCell` that contains the given block. + * + * This walk is the second thing the container API doesn't give you: blocks know + * their children, not their grid coordinates. `getTextCursorPosition` reaches + * one level up (`parentBlock`), which is not enough once a cell holds a list or + * a nested container. + */ +export function findCell( + document: AnyBlock[], + blockId: string, +): CellPosition | undefined { + for (const block of document) { + // Depth-first, so a table nested inside a cell wins over the outer table + // the cursor is also technically inside of. + const nested = findCell(block.children, blockId); + if (nested) { + return nested; + } + + if (block.type === "gridTable") { + const found = findCellInTable(block, blockId); + if (found) { + return found; + } + } + } + + return undefined; +} + +function findCellInTable( + table: AnyBlock, + blockId: string, +): CellPosition | undefined { + for (const [row, rowBlock] of table.children.entries()) { + for (const [column, cell] of rowBlock.children.entries()) { + if (cell.id === blockId || containsBlock(cell.children, blockId)) { + return { table, row, column }; + } + } + } + + return undefined; +} + +function containsBlock(blocks: AnyBlock[], blockId: string): boolean { + return blocks.some( + (block) => block.id === blockId || containsBlock(block.children, blockId), + ); +} + +/** + * The cell one step forward (or back) in reading order, or `undefined` at + * either end of the table. + */ +export function adjacentCell( + position: CellPosition, + direction: 1 | -1, +): AnyBlock | undefined { + const { table, row, column } = position; + const nextColumn = column + direction; + const cellsInRow = table.children[row]?.children ?? []; + + if (nextColumn >= 0 && nextColumn < cellsInRow.length) { + return cellsInRow[nextColumn]; + } + + const nextRow = table.children[row + direction]; + if (!nextRow) { + return undefined; + } + + return direction === 1 ? nextRow.children.at(0) : nextRow.children.at(-1); +} + +/** + * The block the text cursor should land on when moving into a cell: its first + * (or last) block, descending through any *container* blocks on the way — a + * cell may itself hold a column list or another grid table, and neither can + * take a text cursor. + */ +export function cursorTargetIn( + container: AnyBlock, + edge: "start" | "end", +): AnyBlock | undefined { + const child = + edge === "start" ? container.children.at(0) : container.children.at(-1); + + if (!child) { + return undefined; + } + + // A block with no content of its own and children to show for it is a + // container; anything else can hold the cursor. + const isContainer = child.content === undefined && child.children.length > 0; + + return isContainer ? cursorTargetIn(child, edge) : child; +} diff --git a/examples/06-custom-schema/12-table-container/src/styles.css b/examples/06-custom-schema/12-table-container/src/styles.css new file mode 100644 index 0000000000..3cdfc70dfc --- /dev/null +++ b/examples/06-custom-schema/12-table-container/src/styles.css @@ -0,0 +1,95 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.item { + border-radius: 0.5rem; + flex: 1; + /* The example's document is taller than the pane it sits in. */ + overflow: auto; +} + +.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; +} + +.grid-table-wrapper { + flex-grow: 1; + min-width: 0; +} + +.grid-table { + border-collapse: collapse; + /* Fixed layout keeps the columns even, which matters because nothing in the + schema knows what a column is. */ + table-layout: fixed; + width: 100%; +} + +.grid-cell { + border: 1px solid var(--grid-border, #d4d4d8); + padding: 6px 10px; + vertical-align: top; + text-align: left; +} + +th.grid-cell { + background-color: var(--grid-header-bg, #f4f4f5); + font-weight: 600; +} + +/* Cells hold whole blocks, so the block's own vertical rhythm has to be reined + in a little to sit inside a table cell. */ +.grid-cell > * { + padding-inline: 0; +} + +.grid-table-controls { + align-items: center; + display: flex; + gap: 6px; + margin-top: 6px; + user-select: none; +} + +.grid-table-shape { + color: #71717a; + font-size: 0.75rem; + margin-right: 4px; +} + +.grid-table-controls button { + background: none; + border: 1px solid var(--grid-border, #d4d4d8); + border-radius: 4px; + color: inherit; + cursor: pointer; + font-size: 0.75rem; + padding: 2px 8px; +} + +.grid-table-controls button:hover:not(:disabled) { + background-color: var(--grid-header-bg, #f4f4f5); +} + +.grid-table-controls button:disabled { + cursor: default; + opacity: 0.4; +} + +[data-color-scheme="dark"] { + --grid-border: #3f3f46; + --grid-header-bg: #27272a; +} diff --git a/examples/06-custom-schema/12-table-container/tsconfig.json b/examples/06-custom-schema/12-table-container/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/12-table-container/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-table-container/vite-env.d.ts b/examples/06-custom-schema/12-table-container/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/12-table-container/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/12-table-container/vite.config.ts b/examples/06-custom-schema/12-table-container/vite.config.ts new file mode 100644 index 0000000000..2ee49b1f1e --- /dev/null +++ b/examples/06-custom-schema/12-table-container/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-plus"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // 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/vanilla-js/vanilla-custom-side-menu/package.json b/examples/vanilla-js/vanilla-custom-side-menu/package.json index a26d149f86..7487cf2429 100644 --- a/examples/vanilla-js/vanilla-custom-side-menu/package.json +++ b/examples/vanilla-js/vanilla-custom-side-menu/package.json @@ -25,6 +25,6 @@ "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "vite": "^8.0.0" + "vite-plus": "^0.1.24" } } diff --git a/packages/core/package.json b/packages/core/package.json index eb2700636d..8b9f1ee69b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,6 +72,11 @@ "import": "./dist/extensions.js", "require": "./dist/extensions.cjs" }, + "./internal": { + "types": "./types/src/internal.d.ts", + "import": "./dist/internal.js", + "require": "./dist/internal.cjs" + }, "./yjs": { "types": "./types/src/yjs/index.d.ts", "import": "./dist/yjs.js", diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index b41b268617..9e90e8e17f 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -1,4 +1,4 @@ -import { Fragment, Slice } from "prosemirror-model"; +import { Fragment, Node, NodeType, Slice } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; import { ReplaceStep } from "prosemirror-transform"; import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; @@ -8,10 +8,93 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { isContentContainerNode } from "../../../../schema/blocks/children.js"; import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; import { getPmSchema } from "../../../pmUtil.js"; +import { + descendToFirstInsertionPos, + descendToLastInsertionPos, +} from "../../containers/containerNav.js"; +import { isContainerNode } from "../../containers/fixContainer.js"; + +/** + * Where blocks go relative to a reference block. `"before"`/`"after"` make them + * siblings of it; `"start"`/`"end"` nest them inside it, as its first or last + * children. + * + * The nested placements are what addresses a container that has no children to + * point at — a `children: { min: 0 }` container that is currently empty has no + * child block to insert before or after. + */ +export type BlockPlacement = "before" | "after" | "start" | "end"; + +/** + * Resolves a `placement` against a reference block into the document position + * a node of `nodeType` should be inserted at, or `null` when the reference + * block cannot take it there. + * + * Both insertion and the move commands ask this same question — "does this + * block fit here?" — so they ask it in one place. The answer comes from the + * schema's content matches rather than from a hand-written rule, so a + * container's `children` config is what decides it. + * + * `wrapIn` is set when the position only becomes valid once the nodes are + * wrapped: a regular block with no children yet has no `blockGroup` for them + * to go in, so one is created around them. + */ +export function getInsertionPos( + doc: Node, + reference: { node: Node; posBeforeNode: number }, + placement: BlockPlacement, + nodeType: NodeType, +): { pos: number; wrapIn?: NodeType } | null { + const { node, posBeforeNode } = reference; + + if (placement === "before" || placement === "after") { + const pos = + placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize; + const $pos = doc.resolve(pos); + + return $pos.parent.contentMatchAt($pos.index()).matchType(nodeType) + ? { pos } + : null; + } + + // A container holds its children itself, or — when it has content of its own + // — in its generated `__children` node, which the descent helpers step into. + if (isContainerNode(node.type) || isContentContainerNode(node)) { + const pos = + placement === "start" + ? descendToFirstInsertionPos(node, posBeforeNode, nodeType) + : descendToLastInsertionPos(node, posBeforeNode, nodeType); + + return pos === null ? null : { pos }; + } + + // A regular block keeps its children in a `blockGroup` that only exists once + // it has some. + const blockGroupType = nodeType.schema.nodes["blockGroup"]; + if (node.type.name !== "blockContainer" || !blockGroupType) { + return null; + } + + const blockGroupPos = posBeforeNode + 1 + node.firstChild!.nodeSize; + + if (node.childCount < 2) { + return blockGroupType.contentMatch.matchType(nodeType) + ? { pos: blockGroupPos, wrapIn: blockGroupType } + : null; + } + + const pos = + placement === "start" + ? descendToFirstInsertionPos(node.lastChild!, blockGroupPos, nodeType) + : descendToLastInsertionPos(node.lastChild!, blockGroupPos, nodeType); + + return pos === null ? null : { pos }; +} export function insertBlocks< BSchema extends BlockSchema, @@ -21,7 +104,7 @@ export function insertBlocks< tr: Transaction, blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ): Block[] { const id = typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; @@ -37,14 +120,30 @@ export function insertBlocks< throw new Error(`Block with ID ${id} not found`); } - let pos = posInfo.posBeforeNode; - if (placement === "after") { - pos += posInfo.node.nodeSize; + if (nodesToInsert.length === 0) { + return []; } - tr.step( - new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)), + const target = getInsertionPos( + tr.doc, + posInfo, + placement, + nodesToInsert[0].type, ); + if (!target) { + throw new Error( + `Cannot insert a block of type "${blocksToInsert[0].type ?? "paragraph"}" ` + + (placement === "before" || placement === "after" + ? `${placement} block with ID ${id}: its parent does not accept it.` + : `at the ${placement} of block with ID ${id}: the block does not accept it as a child.`), + ); + } + + const fragment = target.wrapIn + ? Fragment.from(target.wrapIn.create(null, nodesToInsert)) + : Fragment.from(nodesToInsert); + + tr.step(new ReplaceStep(target.pos, target.pos, new Slice(fragment, 0, 0))); // Now that the `PartialBlock`s have been converted to nodes, we can // re-convert them into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts new file mode 100644 index 0000000000..27813aeef4 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment node +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../../../schema/blocks/createSpec.js"; + +// These blocks are never rendered — the editor stays headless — so `render` +// only has to exist for `createBlockSpec` to accept the spec. +const container = (type: string, config: Record) => + createBlockSpec({ type, propSchema: {}, ...config } as any, { + render: () => { + throw new Error("not rendered in this suite"); + }, + })(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + // The shape `"start"`/`"end"` exist for: a container that may legally hold + // nothing has no child block to address, so `"before"`/`"after"` cannot + // reach inside it. + box: container("box", { content: "none", children: { min: 0 } }), + titledBox: container("titledBox", { + content: "inline", + children: { min: 0 }, + }), + // A container that only accepts other containers, so an insertion has to + // descend a level to find a place for a regular block. + grid: container("grid", { + content: "none", + children: { allow: { blocks: false, containers: ["cell"] }, min: 2 }, + }), + cell: container("cell", { + content: "none", + children: {}, + placement: "containerOnly", + }), + // A container that is full once it has one child. + single: container("single", { content: "none", children: { max: 1 } }), + } as const, +}); + +let editor: BlockNoteEditor; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }) as any; +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +describe('insertBlocks "start" / "end"', () => { + it("inserts into a childless container", () => { + editor.replaceBlocks(editor.document, [ + { id: "b-0", type: "box" }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + expect(editor.getBlock("b-0")!.children).toHaveLength(0); + + editor.insertBlocks( + [{ id: "inserted", type: "paragraph", content: "Inserted" }], + "b-0", + "end", + ); + + const box = editor.getBlock("b-0")!; + expect(box.children.map((child) => child.id)).toEqual(["inserted"]); + }); + + it('inserts into a childless container with "start"', () => { + editor.replaceBlocks(editor.document, [ + { id: "b-0", type: "box" }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.insertBlocks( + [{ id: "inserted", type: "paragraph", content: "Inserted" }], + "b-0", + "start", + ); + + expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ + "inserted", + ]); + }); + + it("prepends and appends around existing children", () => { + editor.replaceBlocks(editor.document, [ + { + id: "b-0", + type: "box", + children: [{ id: "existing", type: "paragraph", content: "Existing" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start"); + editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end"); + + expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ + "first", + "existing", + "last", + ]); + }); + + it("inserts into a childless container that has its own content", () => { + editor.replaceBlocks(editor.document, [ + { id: "t-0", type: "titledBox", content: "Title" }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + expect(editor.getBlock("t-0")!.children).toHaveLength(0); + + editor.insertBlocks([{ id: "first", type: "paragraph" }], "t-0", "start"); + editor.insertBlocks([{ id: "last", type: "paragraph" }], "t-0", "end"); + + const toggle = editor.getBlock("t-0")!; + // The title is content, not a child — a nested insertion must not land + // in it, or before it. + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["first", "last"]); + }); + + it("descends into a nested container that accepts the block", () => { + editor.replaceBlocks(editor.document, [ + { + id: "g-0", + type: "grid", + children: [ + { id: "c-0", type: "cell" }, + { id: "c-1", type: "cell" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // `grid` itself only accepts `cell`s, so both placements have to find the + // leading/trailing cell rather than giving up. + editor.insertBlocks([{ id: "first", type: "paragraph" }], "g-0", "start"); + editor.insertBlocks([{ id: "last", type: "paragraph" }], "g-0", "end"); + + const grid = editor.getBlock("g-0")!; + expect(grid.children[0].children.map((child: any) => child.id)).toContain( + "first", + ); + expect(grid.children[1].children.map((child: any) => child.id)).toContain( + "last", + ); + }); + + it("nests under a regular block that has no children yet", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); + + editor.insertBlocks( + [{ id: "inserted", type: "paragraph", content: "Nested" }], + "p-0", + "end", + ); + + expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + "inserted", + ]); + }); + + it("nests under a regular block that already has children", () => { + editor.replaceBlocks(editor.document, [ + { + id: "p-0", + type: "paragraph", + content: "Paragraph 0", + children: [{ id: "existing", type: "paragraph" }], + }, + ]); + + editor.insertBlocks([{ id: "first", type: "paragraph" }], "p-0", "start"); + + expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + "first", + "existing", + ]); + }); + + it("throws when the container has no room for the block", () => { + editor.replaceBlocks(editor.document, [ + { + id: "s-0", + type: "single", + children: [{ id: "only", type: "paragraph" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + expect(() => + editor.insertBlocks([{ type: "paragraph" }], "s-0", "end"), + ).toThrow(/does not accept it as a child/); + }); + + it("throws when a sibling placement isn't allowed either", () => { + editor.replaceBlocks(editor.document, [ + { + id: "g-0", + type: "grid", + children: [ + { id: "c-0", type: "cell" }, + { id: "c-1", type: "cell" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // `grid`'s children are `cell`s only, so a paragraph can't become one's + // sibling. Previously this surfaced as a raw ProseMirror `ReplaceError`. + expect(() => + editor.insertBlocks([{ type: "paragraph" }], "c-0", "after"), + ).toThrow(/its parent does not accept it/); + }); +}); diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index ce1a9455db..fe2a0b9dd6 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,5 +1,5 @@ import { Node } from "prosemirror-model"; -import { EditorState } from "prosemirror-state"; +import { EditorState, TextSelection } from "prosemirror-state"; import { BlockInfo, @@ -91,7 +91,9 @@ export const getNextBlockInfo = (doc: Node, beforePos: number) => { * Then the bottom nested block returned is D. */ export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => { - while (blockInfo.childContainer) { + // A container that allows zero children can have an empty child container, + // in which case the block itself is the bottom one. + while (blockInfo.childContainer && blockInfo.childContainer.node.childCount) { const group = blockInfo.childContainer.node; const newPos = doc @@ -105,10 +107,10 @@ export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => { const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => { return ( - prevBlockInfo.isBlockContainer && + prevBlockInfo.isWrappedBlock && prevBlockInfo.blockContent.node.type.spec.content === "inline*" && prevBlockInfo.blockContent.node.childCount > 0 && - nextBlockInfo.isBlockContainer && + nextBlockInfo.isWrappedBlock && nextBlockInfo.blockContent.node.type.spec.content === "inline*" ); }; @@ -120,7 +122,7 @@ const mergeBlocks = ( nextBlockInfo: BlockInfo, ) => { // Un-nests all children of the next block. - if (!nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo.isWrappedBlock) { throw new Error( `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but next block is not a block container`, ); @@ -147,13 +149,17 @@ const mergeBlocks = ( // removing the closing tags of the first block and the opening tags of the // second one to stitch them together. if (dispatch) { - if (!prevBlockInfo.isBlockContainer) { + if (!prevBlockInfo.isWrappedBlock) { throw new Error( `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but previous block is not a block container`, ); } - // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v. + // Merging into or out of container blocks (columnLists, callouts, ...) + // is intentionally unsupported — `canMerge` refuses it above. The + // container-boundary Backspace/Delete branches in + // `KeyboardShortcutsExtension` handle those cases by moving blocks + // across the boundary instead of merging their content. dispatch( state.tr.delete( prevBlockInfo.blockContent.afterPos - 1, @@ -165,6 +171,61 @@ const mergeBlocks = ( return true; }; +/** + * Merges a container's first child into the container's own content — the + * Backspace-at-the-start-of-the-first-child case for a container that has a + * title of its own. The child's own children stay in the container, taking its + * place. + * + * Deliberately separate from `canMerge`/`mergeBlocks`: a *pure* container has + * no content to merge into, so those keep refusing container boundaries + * outright and the "move the block out" branch still handles them. Returns + * false — falling through to that branch — whenever either side isn't inline + * content. + */ +export const mergeIntoContainerContent = ( + state: EditorState, + dispatch: ((args?: any) => any) | undefined, + containerInfo: BlockInfo, + childInfo: BlockInfo, +) => { + if (!containerInfo.isWrappedBlock || !childInfo.isWrappedBlock) { + return false; + } + + const title = containerInfo.blockContent; + const childContent = childInfo.blockContent; + + if ( + title.node.type.spec.content !== "inline*" || + childContent.node.type.spec.content !== "inline*" + ) { + return false; + } + + if (dispatch) { + const tr = state.tr; + + // The title lies before the children, so none of these positions shift the + // ones used after them. + if (childInfo.childContainer?.node.childCount) { + tr.insert( + childInfo.bnBlock.afterPos, + childInfo.childContainer.node.content, + ); + } + tr.delete(childInfo.bnBlock.beforePos, childInfo.bnBlock.afterPos); + + const titleEndPos = title.afterPos - 1; + tr.insert(titleEndPos, childContent.node.content); + tr.setSelection(TextSelection.create(tr.doc, titleEndPos)); + + dispatch(tr); + } + + return true; +}; + export const mergeBlocksCommand = (posBetweenBlocks: number) => ({ diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts index 61964a49ee..f034506f44 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts @@ -18,7 +18,7 @@ const getEditor = setupTestEnv(); function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr)); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error( `Selection points to a ${blockInfo.blockNoteType} node, not a blockContainer node`, ); diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 71598b7d69..46794bff7f 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -14,7 +14,8 @@ import { getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; -import { insertBlocks } from "../insertBlocks/insertBlocks.js"; +import { flattenNonInsertableBlocks } from "../../containers/fixContainer.js"; +import { getInsertionPos, insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; type BlockSelectionData = ( @@ -131,16 +132,6 @@ function updateBlockSelectionFromData( tr.setSelection(selection); } -// Replaces top-level `column` blocks with their children, as a `column` is not -// a valid block outside a `columnList`. Other blocks are returned as-is. -function flattenColumns( - blocks: Block[], -): Block[] { - return blocks.flatMap((block) => - block.type === "column" ? block.children : [block], - ); -} - /** * Removes the given blocks from the editor, then inserts them before/after a * reference block. @@ -169,10 +160,12 @@ export function moveBlocks( // // When the non-empty block is moved up, the column is seen as empty and // collapsed in the removal step, so the following insertion fails. - removeAndInsertBlocks(tr, blocks, [], { fixColumns: false }); + removeAndInsertBlocks(tr, blocks, [], { fixContainers: false }); insertBlocks( tr, - flattenColumns(blocks), + // Blocks that can't stand on their own outside their container (e.g. a + // `column` outside its `columnList`) are replaced by their children. + flattenNonInsertableBlocks(blocks, editor.pmSchema), referenceBlock, placement, ); @@ -207,12 +200,33 @@ export function moveSelectedBlocksAndSelection( }); } -// Checks if a block is in a valid place after being moved. This check is -// primitive at the moment and only returns false if the block's parent is a -// `columnList` block. This is because regular blocks cannot be direct children -// of `columnList` blocks. -function checkPlacementIsValid(parentBlock?: Block): boolean { - return !parentBlock || parentBlock.type !== "columnList"; +// Checks if a regular block would be in a valid place after being moved +// before/after `referenceBlock`. A regular block nests under any non-container +// block (it goes into that block's `blockGroup`), but a container block (e.g. a +// `columnList`) only accepts what its content expression allows. +// +// Deferred to `getInsertionPos` so that "can a block go here?" has exactly one +// answer, shared with `insertBlocks` — and so that it comes from the schema +// rather than from a rule restated here. +function checkPlacementIsValid( + editor: BlockNoteEditor, + referenceBlock: Block, + placement: "before" | "after", +): boolean { + return editor.transact((tr) => { + const posInfo = getNodeById(referenceBlock.id, tr.doc); + if (!posInfo) { + return false; + } + return ( + getInsertionPos( + tr.doc, + posInfo, + placement, + editor.pmSchema.nodes["blockContainer"], + ) !== null + ); + }); } // Gets the placement for moving a block up. This has 3 cases: @@ -253,8 +267,8 @@ function getMoveUpPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveUpPlacement( editor, placement === "after" @@ -305,8 +319,8 @@ function getMoveDownPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveDownPlacement( editor, placement === "before" diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index a0f76fdff0..a0a09d0099 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -19,9 +19,7 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -163,9 +161,7 @@ export function liftItem( const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -195,14 +191,36 @@ export function canNestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); - return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null; + // Mirrors `sinkItem`'s precondition: nesting is only possible under a + // previous sibling that is itself a `blockContainer`. (A previous sibling + // of another type — e.g. a container block — made this return true while + // `nestBlock` did nothing.) + return ( + tr.doc.resolve(blockContainer.beforePos).nodeBefore?.type === + editor.pmSchema.nodes["blockContainer"] + ); }); } export function canUnnestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); + const { $from, $to } = tr.selection; + + // Mirrors `liftItem`'s preconditions instead of approximating with + // depth — a block whose depth > 1 because it sits inside a container + // (e.g. a column) is not un-nestable, only a block nested under another + // `blockContainer` is. + const range = $from.blockRange( + $to, + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), + ); + if (!range) { + return false; + } - return tr.doc.resolve(blockContainer.beforePos).depth > 1; + return ( + $from.node(range.depth - 1).type === + editor.pmSchema.nodes["blockContainer"] + ); }); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d9e1e72981..84be8fa9fc 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -11,7 +11,8 @@ import type { import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getPmSchema } from "../../../pmUtil.js"; -import { fixColumnList } from "./util/fixColumnList.js"; +import { fixContainersById } from "../../containers/fixContainer.js"; +import { getAncestorContainers } from "../../containers/containerNav.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +23,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixContainers?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,7 +44,10 @@ export function removeAndInsertBlocks< ), ); const removedBlocks: Block[] = []; - const columnListPositions = new Set(); + // Ancestor containers of removed blocks, to repair afterwards. Tracked by + // node id (not position) since the removals — and earlier repairs — shift + // positions; recorded with their depth so repairs run deepest-first. + const containersToFix: { id: string; depth: number }[] = []; const idOfFirstBlock = typeof blocksToRemove[0] === "string" @@ -84,10 +88,10 @@ export function removeAndInsertBlocks< const $pos = tr.doc.resolve(pos - removedSize); - if ($pos.node().type.name === "column") { - columnListPositions.add($pos.before(-1)); - } else if ($pos.node().type.name === "columnList") { - columnListPositions.add($pos.before()); + for (const container of getAncestorContainers($pos.doc, $pos.pos)) { + if (!containersToFix.some((c) => c.id === container.id)) { + containersToFix.push(container); + } } if ( @@ -119,11 +123,12 @@ export function removeAndInsertBlocks< ); } - // Collapses empty columns/columnLists. Callers where the removal isn't a - // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere - // and deliberately leaves emptied columns as-is. - if (options.fixColumns !== false) { - columnListPositions.forEach((pos) => fixColumnList(tr, pos)); + // Repairs the containers the removed blocks lived in (e.g. collapses + // emptied columns/columnLists), deepest-first. Callers where the removal + // isn't a deletion can opt out - e.g. `moveBlocks` re-inserts the blocks + // elsewhere and deliberately leaves emptied containers as-is. + if (options.fixContainers !== false) { + fixContainersById(tr, containersToFix); } // Converts the nodes created from `blocksToInsert` into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts deleted file mode 100644 index 3097851f47..0000000000 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Slice, type Node } from "prosemirror-model"; -import { type Transaction } from "prosemirror-state"; -import { ReplaceAroundStep } from "prosemirror-transform"; - -/** - * Checks if a `column` node is empty, i.e. if it has only a single empty - * paragraph. - * @param column The column to check. - * @returns Whether the column is empty. - */ -export function isEmptyColumn(column: Node) { - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - const blockContainer = column.firstChild; - if (!blockContainer) { - throw new Error("Invalid column: does not have child node."); - } - - const blockContent = blockContainer.firstChild; - if (!blockContent) { - throw new Error("Invalid blockContainer: does not have child node."); - } - - return ( - column.childCount === 1 && - blockContainer.childCount === 1 && - blockContent.type.name === "paragraph" && - blockContent.content.content.length === 0 - ); -} - -/** - * Removes all empty `column` nodes in a `columnList`. A `column` node is empty - * if it has only a single empty block. If, however, removing the `column`s - * leaves the `columnList` that has fewer than two, ProseMirror will re-add - * empty columns. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos The position just before the `columnList` node. - */ -export function removeEmptyColumns(tr: Transaction, columnListPos: number) { - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - for ( - let columnIndex = columnList.childCount - 1; - columnIndex >= 0; - columnIndex-- - ) { - const columnPos = tr.doc - .resolve($columnListPos.pos + 1) - .posAtIndex(columnIndex); - const $columnPos = tr.doc.resolve(columnPos); - const column = $columnPos.nodeAfter; - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - if (isEmptyColumn(column)) { - tr.delete(columnPos, columnPos + column.nodeSize); - } - } -} - -/** - * Fixes potential issues in a `columnList` node after a - * `blockContainer`/`column` node is (re)moved from it: - * - * - Removes all empty `column` nodes. A `column` node is empty if it has only - * a single empty block. - * - If all but one `column` nodes are empty, replaces the `columnList` with - * the content of the non-empty `column`. - * - If all `column` nodes are empty, removes the `columnList` entirely. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos - * @returns The position just before the `columnList` node. - */ -export function fixColumnList(tr: Transaction, columnListPos: number) { - removeEmptyColumns(tr, columnListPos); - - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - if (columnList.childCount > 2) { - // Do nothing if the `columnList` has more than two non-empty `column`s. In - // the case that the `columnList` has exactly two columns, we may need to - // still remove it, as it's possible that one or both columns are empty. - // This is because after `removeEmptyColumns` is called, if the - // `columnList` has fewer than two `column`s, ProseMirror will re-add empty - // `column`s until there are two total, in order to fit the schema. - return; - } - - if (columnList.childCount < 2) { - // Throw an error if the `columnList` has fewer than two columns. After - // `removeEmptyColumns` is called, if the `columnList` has fewer than two - // `column`s, ProseMirror will re-add empty `column`s until there are two - // total, in order to fit the schema. So if there are fewer than two here, - // either the schema, or ProseMirror's internals, must have changed. - throw new Error("Invalid columnList: contains fewer than two children."); - } - - const firstColumnBeforePos = columnListPos + 1; - const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); - const firstColumn = $firstColumnBeforePos.nodeAfter; - - const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; - const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); - const lastColumn = $lastColumnAfterPos.nodeBefore; - - if (!firstColumn || !lastColumn) { - throw new Error("Invalid columnList: does not contain children."); - } - - const firstColumnEmpty = isEmptyColumn(firstColumn); - const lastColumnEmpty = isEmptyColumn(lastColumn); - - if (firstColumnEmpty && lastColumnEmpty) { - // Removes `columnList` - tr.delete(columnListPos, columnListPos + columnList.nodeSize); - - return; - } - - if (firstColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of last `column`. - lastColumnAfterPos - lastColumn.nodeSize + 1, - lastColumnAfterPos - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } - - if (lastColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of first `column`. - firstColumnBeforePos + 1, - firstColumnBeforePos + firstColumn.nodeSize - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } -} diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts index ab02a865f0..9a83857cd1 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -35,7 +35,7 @@ function setSelectionWithOffset( const info = getBlockInfo(posInfo); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("Target block is not a block container"); } diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index 1e73471d23..ef74f8e898 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -36,7 +36,7 @@ export const splitBlockTr = ( const info = getBlockInfo(nearestBlockContainerPos); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { return false; } const schema = getPmSchema(tr); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts index e44e4a6380..c695de98ae 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts @@ -181,7 +181,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("heading-with-everything is not a block container"); } @@ -210,7 +210,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("heading-with-everything is not a block container"); } @@ -240,7 +240,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("heading-with-everything is not a block container"); } @@ -273,7 +273,7 @@ describe("Test updateBlock", () => { getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("table-0 is not a block container"); } @@ -303,7 +303,7 @@ describe("Test updateBlock", () => { getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("table-0 is not a block container"); } @@ -940,7 +940,7 @@ describe("Test updateBlock minimal steps", () => { editor.prosemirrorState.doc, )!, ); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("paragraph-with-styled-content is not a block container"); } diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 6edfc434d5..244e13094f 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -2,6 +2,7 @@ import { Fragment, type NodeType, type Node as PMNode, + type Schema, Slice, } from "prosemirror-model"; import { TextSelection, Transaction } from "prosemirror-state"; @@ -27,7 +28,12 @@ import { } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; -import { getPmSchema } from "../../../pmUtil.js"; +import { getBlockSchema, getPmSchema } from "../../../pmUtil.js"; +import { + getContentContainerNodeTypes, + isContainerType, + isContentContainerNode, +} from "../../../../schema/blocks/children.js"; // for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface export const updateBlockCommand = < @@ -82,40 +88,75 @@ export function updateBlockTr< // Adds blockGroup node with child blocks if necessary. - const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType]; - const newNodeType = pmSchema.nodes[block.type || blockInfo.blockNoteType]; + const newBlockType = block.type || blockInfo.blockNoteType; + const newNodeType = pmSchema.nodes[newBlockType]; const newBnBlockNodeType = newNodeType.isInGroup("bnBlock") ? newNodeType : pmSchema.nodes["blockContainer"]; - if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) { - const replaceFromOffset = - replaceFromPos !== undefined && - replaceFromPos > blockInfo.blockContent.beforePos && - replaceFromPos < blockInfo.blockContent.afterPos - ? replaceFromPos - blockInfo.blockContent.beforePos - 1 - : undefined; - - const replaceToOffset = - replaceToPos !== undefined && - replaceToPos > blockInfo.blockContent.beforePos && - replaceToPos < blockInfo.blockContent.afterPos - ? replaceToPos - blockInfo.blockContent.beforePos - 1 - : undefined; + // The dispatch below is about *content* nodes, not block nodes. A container + // with its own content keeps that content in a generated node rather than in + // its own, so routing on the block's node type would send an update of its + // content to the full-replace arm — where it used to be silently dropped. + const isContentContainer = isContentContainerNode(blockInfo.bnBlock.node); + + const replaceFromOffset = + blockInfo.blockContent && + replaceFromPos !== undefined && + replaceFromPos > blockInfo.blockContent.beforePos && + replaceFromPos < blockInfo.blockContent.afterPos + ? replaceFromPos - blockInfo.blockContent.beforePos - 1 + : undefined; + + const replaceToOffset = + blockInfo.blockContent && + replaceToPos !== undefined && + replaceToPos > blockInfo.blockContent.beforePos && + replaceToPos < blockInfo.blockContent.afterPos + ? replaceToPos - blockInfo.blockContent.beforePos - 1 + : undefined; + if ( + blockInfo.isWrappedBlock && + blockInfo.bnBlock.node.type.name === "blockContainer" && + newNodeType.isInGroup("blockContent") + ) { updateChildren(block, tr, blockInfo); // The code below determines the new content of the block. // or "keep" to keep as-is updateBlockContentNode( block, tr, - oldNodeType, + pmSchema.nodes[blockInfo.blockNoteType], newNodeType, blockInfo, replaceFromOffset, replaceToOffset, ); - } else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) { + } else if ( + blockInfo.isWrappedBlock && + isContentContainer && + newBlockType === blockInfo.blockNoteType + ) { + // Same container, so its generated content node stays as it is — only what + // that node holds may change. + const contentNodeType = blockInfo.blockContent.node.type; + + updateChildren(block, tr, blockInfo); + updateBlockContentNode( + block, + tr, + contentNodeType, + contentNodeType, + blockInfo, + replaceFromOffset, + replaceToOffset, + ); + } else if ( + !blockInfo.isWrappedBlock && + newNodeType.isInGroup("bnBlock") && + !getContentContainerNodeTypes(pmSchema, newBlockType) + ) { updateChildren(block, tr, blockInfo); // old node was a bnBlock type (like column or columnList) and new block as well // No op, we just update the bnBlock below (at end of function) and have already updated the children @@ -128,9 +169,21 @@ export function updateBlockTr< // for this, we do a nodeToBlock on the existing block to get the children. // it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case const existingBlock = nodeToBlock(blockInfo.bnBlock.node, tr.doc); + const carried = carryOverContent( + existingBlock.content, + newBlockType, + pmSchema, + ); + // If no children are passed in, use the existing block's — but only when + // there actually are some. `nodeToBlock` always emits an array, and an + // empty one would read as "explicitly childless", suppressing the seeding a + // container needs when converting from a childless block. + const children = [...carried.children, ...existingBlock.children]; + const replacementNode = blockToNode( { - children: existingBlock.children, // if no children are passed in, use existing children + ...(carried.content ? { content: carried.content } : {}), + ...(children.length > 0 ? { children } : {}), ...block, }, pmSchema, @@ -158,6 +211,54 @@ export function updateBlockTr< } } +/** + * What a block's existing content becomes when the block is replaced wholesale + * by one of a different shape. + * + * Inline content survives wherever the target has somewhere to put it: a + * regular block's or a content-bearing container's `content`, or — for a *pure* + * container, which has no content of its own — a leading child block. It's + * dropped only when there is genuinely nowhere for it to go: a contentless + * block, or across a table boundary (table content fits nothing else, and + * nothing else fits a table). + */ +function carryOverContent( + existingContent: Block["content"], + newBlockType: string, + pmSchema: Schema, +): { + content?: PartialBlock["content"]; + children: PartialBlock[]; +} { + const nothing = { children: [] }; + + // `Array.isArray` also rules out table content, which only fits a block that + // holds table content — and such a block accepts nothing else. + if (!existingContent || !Array.isArray(existingContent)) { + return nothing; + } + if (existingContent.length === 0) { + return nothing; + } + + const targetConfig = getBlockSchema(pmSchema)[newBlockType]; + if (!targetConfig) { + return nothing; + } + + if (targetConfig.content === "inline" || targetConfig.content === "plain") { + return { content: existingContent, children: [] }; + } + + if (targetConfig.content === "none" && isContainerType(targetConfig)) { + return { + children: [{ type: "paragraph", content: existingContent } as any], + }; + } + + return nothing; +} + function updateBlockContentNode< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -521,7 +622,7 @@ function updateChildren< Fragment.from(childNodes), ); } else { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } // Inserts a new blockGroup containing the child nodes created earlier. @@ -637,7 +738,7 @@ function restoreCellAnchor( // 1) Resolve the table node in the current document let tablePos = -1; - if (blockInfo.isBlockContainer) { + if (blockInfo.isWrappedBlock) { // Prefer the blockContent position when available (points directly at the PM table node) tablePos = tr.mapping.map(blockInfo.blockContent.beforePos); } else { diff --git a/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap new file mode 100644 index 0000000000..f8e3f971e1 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap @@ -0,0 +1,34 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`children repair > unwraps a repair-configured container when only one non-empty child remains 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "cell-b-p", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "trailing", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts new file mode 100644 index 0000000000..4d79f8fec0 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -0,0 +1,163 @@ +import type { Node, NodeType } from "prosemirror-model"; + +import { isContentContainerNode } from "../../../schema/blocks/children.js"; +import { isContainerNode } from "./fixContainer.js"; + +/** + * Position-based helpers for navigating container blocks. These generalize + * what the keyboard handlers used to hard-code for the exact + * `columnList > column > blockContainer` shape: they recurse through + * arbitrarily nested containers and consult the schema's content matches + * instead of assuming two levels. + */ + +/** + * Finds the deepest position inside `container` where a node of `nodeType` + * can be appended at the end, descending through trailing nested containers + * (e.g. into the last `column` of a `columnList`, which itself doesn't accept + * `blockContainer` children). Returns null if no level accepts the node. + * @param container The container node. + * @param containerBeforePos The position just before `container`. + * @param nodeType The node type to find an insertion position for. + */ +export function descendToLastInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + const endPos = containerBeforePos + 1 + container.content.size; + if (container.contentMatchAt(container.childCount).matchType(nodeType)) { + return endPos; + } + const lastChild = container.lastChild; + if (lastChild && isContainerNode(lastChild.type)) { + return descendToLastInsertionPos( + lastChild, + endPos - lastChild.nodeSize, + nodeType, + ); + } + return null; +} + +/** + * Mirror of `descendToLastInsertionPos`: the deepest position inside + * `container` where a node of `nodeType` can be prepended at the start. + */ +export function descendToFirstInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + // A container with its own content leads with that content, so the start of + // its *children* is one node further in. `descendToLastInsertionPos` needs no + // such case: it reaches the generated `__children` node as the last child, + // which is itself a container node. + if (isContentContainerNode(container)) { + return descendToFirstInsertionPos( + container.lastChild!, + containerBeforePos + 1 + container.firstChild!.nodeSize, + nodeType, + ); + } + + const startPos = containerBeforePos + 1; + if (container.contentMatchAt(0).matchType(nodeType)) { + return startPos; + } + const firstChild = container.firstChild; + if (firstChild && isContainerNode(firstChild.type)) { + return descendToFirstInsertionPos(firstChild, startPos, nodeType); + } + return null; +} + +/** + * Descends through leading nested containers to the first non-container child + * (e.g. the first `blockContainer` inside the first `column` of a + * `columnList`). Returns null for a container whose leading chain has no + * such child. + * @param container The container node. + * @param containerBeforePos The position just before `container`. + */ +export function getFirstLeafBlock( + container: Node, + containerBeforePos: number, +): { node: Node; beforePos: number } | null { + // A container with its own content leads with that content, which is not a + // block — its children are one node further in. + if (isContentContainerNode(container)) { + return getFirstLeafBlock( + container.lastChild!, + containerBeforePos + 1 + container.firstChild!.nodeSize, + ); + } + + const firstChild = container.firstChild; + if (!firstChild) { + return null; + } + const firstChildBeforePos = containerBeforePos + 1; + if (isContainerNode(firstChild.type)) { + return getFirstLeafBlock(firstChild, firstChildBeforePos); + } + return { node: firstChild, beforePos: firstChildBeforePos }; +} + +/** + * Climbs upward from a position until one is found where a node of + * `nodeType` may be inserted, moving to just before each enclosing container + * in turn (e.g. from before a first `column` — where only `column` nodes are + * allowed — to before the enclosing `columnList`). Returns null when an + * enclosing non-container parent still doesn't accept the node. + * @param doc The document to resolve positions in. + * @param pos The position to start climbing from. + * @param nodeType The node type to find an insertion position for. + */ +export function ascendToInsertablePos( + doc: Node, + pos: number, + nodeType: NodeType, +): number | null { + for (;;) { + const $pos = doc.resolve(pos); + const parent = $pos.node(); + if (parent.contentMatchAt($pos.index()).matchType(nodeType)) { + return pos; + } + if (isContainerNode(parent.type) && $pos.depth > 0) { + pos = $pos.before(); + continue; + } + return null; + } +} + +/** + * Collects the chain of container-node ancestors at a position (deepest + * first) as `{ id, depth }` entries. Containers are re-located by id when + * repairing, since repairs shift positions. + * @param doc The document to resolve the position in. + * @param pos A position inside the containers of interest. + */ +export function getAncestorContainers( + doc: Node, + pos: number, +): { id: string; depth: number }[] { + const $pos = doc.resolve(pos); + const containers: { id: string; depth: number }[] = []; + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + // A container with its own content is not itself a container node (its + // generated `__children` node is), but it's the node repair acts on — and + // the one carrying the id. `__children` has no id, so it's skipped here + // and reached through its owner instead. + if ( + (isContainerNode(ancestor.type) || isContentContainerNode(ancestor)) && + ancestor.attrs.id + ) { + containers.push({ id: ancestor.attrs.id, depth }); + } + } + return containers; +} diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts new file mode 100644 index 0000000000..b4633d5ead --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts @@ -0,0 +1,61 @@ +import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { isContainerType } from "../../../schema/blocks/children.js"; + +export type ContainerUIInfo = { + containerTypes: ReadonlySet; + draggableContainerTypes: ReadonlySet; + /** + * Regular (non-container) block types whose spec sets `meta.draggable: + * false`. Container types are tracked separately in + * `draggableContainerTypes`, because they're identified in the DOM by + * `data-node-type` while regular blocks all share the `blockContainer` node + * and are identified by their content's `data-content-type`. + */ + nonDraggableBlockTypes: ReadonlySet; + containerSelector: string | null; +}; + +function buildSelector(types: ReadonlySet): string | null { + if (types.size === 0) { + return null; + } + return [...types].map((type) => `[data-node-type="${type}"]`).join(","); +} + +export function getContainerUIInfo( + editor: Pick, "schema">, +): ContainerUIInfo { + const containerTypes = new Set(); + const draggableContainerTypes = new Set(); + const nonDraggableBlockTypes = new Set(); + + for (const [type, spec] of Object.entries( + editor.schema.blockSpecs as Record< + string, + { + config: any; + implementation?: { meta?: { draggable?: boolean } }; + } + >, + )) { + const draggable = spec.implementation?.meta?.draggable !== false; + + if (!isContainerType(spec.config)) { + if (!draggable) { + nonDraggableBlockTypes.add(type); + } + continue; + } + containerTypes.add(type); + if (draggable) { + draggableContainerTypes.add(type); + } + } + + return { + containerTypes, + draggableContainerTypes, + nonDraggableBlockTypes, + containerSelector: buildSelector(containerTypes), + }; +} diff --git a/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts new file mode 100644 index 0000000000..c8ba730811 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts @@ -0,0 +1,279 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { containerSchema } from "./containers.fixture.js"; + +// The halves of the container-block story that need a real browser, split off +// from the (node) `containers.test.ts`: +// +// - the keymap, which tiptap can only reach through a mounted view. These used +// to synthesize a `KeyboardEvent` and hand it to `handleKeyDown` directly, +// which passes whether or not a real keypress ever gets there. Here the +// editor is mounted and focused and the keys are pressed for real. +// - HTML/markdown serialization, which builds and parses real DOM. + +const schema = containerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +let div: HTMLElement; + +beforeAll(() => { + div = document.createElement("div"); + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +/** Puts the caret where the test wants it and presses the key for real. */ +async function pressKey( + key: string, + at: { block: string; placement: "start" | "end" }, +) { + editor.setTextCursorPosition(at.block, at.placement); + editor.focus(); + await userEvent.keyboard(`{${key}}`); +} + +describe("children keyboard handling", () => { + it("Enter on an empty last child escapes the container", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "Hello" }, + { id: "c-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "c-p-1", placement: "end" }); + + // The empty block has moved out of the callout, becoming its next sibling. + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]); + expect(editor.document.map((block) => block.type)).toEqual([ + "callout", + "paragraph", + "paragraph", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "c-0", + "c-p-1", + "trailing", + ]); + // The caret came with it, so typing continues outside the container. + expect(editor.getTextCursorPosition().block.id).toBe("c-p-1"); + }); + + it("Enter does not escape a container with exitOnEnter: false", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "lockedBox", + id: "l-0", + children: [ + { id: "l-p-0", type: "paragraph", content: "Hello" }, + { id: "l-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "l-p-1", placement: "end" }); + + // Still exactly one top-level lockedBox followed by the trailing + // paragraph; the new block was created *inside* the container. Asserted as + // a change (2 children -> 3) rather than as "nothing moved out", so the + // test can't pass by the keypress never arriving at all. + expect(editor.document.map((block) => block.type)).toEqual([ + "lockedBox", + "paragraph", + ]); + const children = editor.getBlock("l-0")!.children; + expect(children.map((child) => child.id).slice(0, 2)).toEqual([ + "l-p-0", + "l-p-1", + ]); + expect(children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe(children[2].id); + }); + + it("Backspace at the start of a container's first child moves it out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "c-p-0", placement: "start" }); + + // The first child has moved out, above the callout, with its text intact. + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "c-p-0", + "c-0", + ]); + expect(editor.getBlock("c-p-0")!.content).toEqual([ + { type: "text", text: "First", styles: {} }, + ]); + }); + + it("Backspace at the start of a block after a container moves it inside", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + expect(editor.getBlock("after")!.content).toEqual([ + { type: "text", text: "After", styles: {} }, + ]); + }); + + it("Delete at the end of a block before a container pulls its first child out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Delete", { block: "before", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "c-p-0", + "c-0", + ]); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + }); + + it("Delete at the end of a container's last child pulls the next block in", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Delete", { block: "c-p-0", placement: "end" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + }); +}); + +describe("children conversion", () => { + it("round-trips a container through full (internal) HTML", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout" as const, + id: "c-0", + props: { flavor: "warning" as const }, + children: [ + { id: "c-p-0", type: "paragraph" as const, content: "In callout" }, + ], + }, + ]); + + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain('data-node-type="callout"'); + + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed[0].type).toBe("callout"); + expect((parsed[0].props as any).flavor).toBe("warning"); + expect(parsed[0].children).toHaveLength(1); + expect(parsed[0].children[0].type).toBe("paragraph"); + }); + + it("exports containers to external HTML with type + prop attributes", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + props: { flavor: "warning" }, + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + ]); + + const html = editor.blocksToHTMLLossy(editor.document); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain('data-flavor="warning"'); + // Container output is not wrapped in a blockContent div. + expect(html).not.toContain("bn-block-content"); + }); + + it("flattens containers to their children in markdown export", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "In callout" }, + { id: "c-p-1", type: "heading", content: "Heading in callout" }, + ], + }, + ]); + + const markdown = editor.blocksToMarkdownLossy(editor.document); + expect(markdown).toContain("In callout"); + expect(markdown).toContain("# Heading in callout"); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts new file mode 100644 index 0000000000..faaea8df7c --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -0,0 +1,106 @@ +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +// The container schema shared by `containers.test.ts` (node: document model) +// and `containers.browser.test.ts` (real browser: keymap + serialization), so +// both halves are describing the same blocks. + +// A vanilla (non-React) container block accepting any children, with +// `default` seeding — the callout from the container-block example. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + children: { + min: 1, + default: [{ type: "paragraph" }], + }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.className = "callout"; + dom.setAttribute("data-node-type", "callout"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +// A container that never exits on Enter (like columns). +const LockedBox = createBlockSpec( + { + type: "lockedBox" as const, + propSchema: {}, + content: "none", + children: { min: 1, exitOnEnter: false }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "lockedBox"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +// A columnList-like pair: a horizontal container restricted to `gridCell` +// children (min 2) with column-style repair, and a non-top-level cell. +// Proves the "custom table-like structure" story is buildable. +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + children: { + allow: { blocks: false, containers: ["gridCell"] }, + min: 2, + unwrapWhenEmptied: true, + }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "grid"); + dom.setAttribute("data-id", block.id); + dom.style.display = "flex"; + return { dom, contentDOM: dom }; + }, + }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + children: {}, + placement: "containerOnly", + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "gridCell"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +export const containerSchema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + lockedBox: LockedBox, + grid: Grid, + gridCell: GridCell, + } as const, +}); diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts new file mode 100644 index 0000000000..747e6325a3 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,355 @@ +// @vitest-environment node +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { containerSchema } from "./containers.fixture.js"; + +type PartialBlock = (typeof containerSchema)["PartialBlock"]; + +// Document-model behaviour of container blocks: seeding, schema enforcement, +// repair and selection. All of it is `Block` JSON in and `Block` JSON out, so +// the editor stays headless and this suite runs with no DOM at all. +// +// The halves that genuinely need one — the keymap (which tiptap can only reach +// through a mounted view) and HTML/markdown serialization (which builds real +// DOM) — live in `containers.browser.test.ts`. + +const schema = containerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +describe("children insertion & seeding", () => { + it("seeds `default` when inserted without children", () => { + editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"); + + const callout = editor.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + }); + + // The minimal container config used to be a landmine: `min` defaults to 1, + // nothing seeded it, and the most obvious insert threw a raw ProseMirror + // `RangeError: Invalid content for node ...`. + it("fills a container that has no `default` rather than throwing", () => { + expect(() => + editor.insertBlocks([{ type: "lockedBox", id: "b-0" }], "p-1", "after"), + ).not.toThrow(); + + const box = editor.getBlock("b-0")!; + expect(box.children).toHaveLength(1); + expect(box.children[0].type).toBe("paragraph"); + }); + + it("gives auto-filled children real ids", () => { + // Auto-filled nodes come straight from the schema with `id: null`, and the + // UniqueID plugin never sees them — `insertBlocks` converts back through + // `nodeToBlock` before the transaction is dispatched. + editor.insertBlocks([{ type: "lockedBox", id: "b-0" }], "p-1", "after"); + + const child = editor.getBlock("b-0")!.children[0]; + expect(child.id).toBeTruthy(); + expect(editor.getBlock(child.id)).toBeDefined(); + }); + + it("does not re-seed a container round-tripped through the document", () => { + editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"); + const inserted = editor.getBlock("c-0")!; + + // `nodeToBlock` always emits an array, so a round-trip must not read an + // empty one as "unspecified" and seed on top of it. + editor.replaceBlocks([inserted], [inserted]); + + expect(editor.getBlock("c-0")!.children).toHaveLength( + inserted.children.length, + ); + }); + + // `children: []` is a caller asking for a container with no children, which + // a `min: 1` container cannot be. It used to be taken at face value, which + // built a node below its minimum: `insertBlocks` then threw a raw + // `Invalid content for node callout: <>` from its `node.check()`. + it("fills an explicitly empty `children` array up to `min`", () => { + expect(() => + editor.insertBlocks( + [{ type: "callout", id: "c-0", children: [] }], + "p-1", + "after", + ), + ).not.toThrow(); + + const callout = editor.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].id).toBeTruthy(); + }); + + it("does not pad explicit children that already satisfy the config", () => { + editor.insertBlocks( + [ + { + type: "grid", + id: "g-0", + children: [{ type: "gridCell" }, { type: "gridCell" }], + }, + ], + "p-1", + "after", + ); + + expect(editor.getBlock("g-0")!.children).toHaveLength(2); + }); + + // A container that unwraps as it empties out is the one case explicit + // children are *not* padded: adding a second column to a one-column + // columnList would invent content the next repair pass deletes anyway. + it("refuses rather than pads a container that unwraps when emptied", () => { + expect(() => + editor.insertBlocks( + [{ type: "grid", id: "g-1", children: [{ type: "gridCell" }] }], + "p-1", + "after", + ), + ).toThrow(); + }); + + // A pure container has no content of its own, but the block it replaces did + // — so that content becomes its first child rather than being dropped. + it("carries content into the first child when converting via updateBlock", () => { + editor.updateBlock("p-1", { type: "callout" }); + + const callout = editor.document[1]; + expect(callout.type).toBe("callout"); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].content).toEqual([ + { type: "text", text: "Paragraph 1", styles: {} }, + ]); + }); + + it("seeds `default` when converting an empty block via updateBlock", () => { + editor.updateBlock("p-1", { content: [] }); + editor.updateBlock("p-1", { type: "callout" }); + + const callout = editor.document[1]; + expect(callout.type).toBe("callout"); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].content).toEqual([]); + }); + + it("accepts arbitrary block children, including nested containers", () => { + editor.insertBlocks( + [ + { + type: "callout", + id: "c-0", + children: [ + { type: "heading", content: "In callout" }, + { + type: "callout", + id: "c-1", + children: [{ type: "paragraph", content: "Nested" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.type)).toEqual([ + "heading", + "callout", + ]); + expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph"); + }); + + it("rejects non-allowed children for a restricted container", () => { + expect(() => + editor.insertBlocks( + [ + { + type: "grid", + children: [ + { type: "paragraph", content: "not a cell" }, + { type: "paragraph", content: "not a cell" }, + ], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); + + it("accepts allowed children for a restricted container", () => { + editor.insertBlocks( + [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + children: [{ type: "paragraph", content: "Cell A" }], + }, + { + type: "gridCell", + children: [{ type: "paragraph", content: "Cell B" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const grid = editor.getBlock("g-0")!; + expect(grid.children.map((child) => child.type)).toEqual([ + "gridCell", + "gridCell", + ]); + }); + + it("rejects inserting a containerOnly block at the document root", () => { + expect(() => + editor.insertBlocks( + [{ type: "gridCell", children: [{ type: "paragraph" }] }], + "p-1", + "after", + ), + ).toThrow(); + }); +}); + +// `initialContent` is the only path that builds a document without validating +// it: `blockToNode` is deliberately lenient, and `createDocument` builds from +// JSON. So blocks `insertBlocks` rejects used to load happily, and a container +// below its `min` stayed below it for the life of the document. +describe("initialContent enforcement", () => { + const createWith = (initialContent: PartialBlock[]) => { + return BlockNoteEditor.create({ schema, initialContent }); + }; + + it("fills an explicitly empty `children` array up to `min`", () => { + const loaded = createWith([{ type: "callout", id: "c-0", children: [] }]); + + const callout = loaded.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + + loaded._tiptapEditor.destroy(); + }); + + it("rejects a container it cannot legally fill", () => { + expect(() => + createWith([ + { type: "grid", id: "g-0", children: [{ type: "gridCell" }] }, + ]), + ).toThrow(/initialContent/); + }); +}); + +describe("children repair", () => { + it("keeps a default container when its only child is removed (refilled)", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["c-p-0"]); + + const callout = editor.getBlock("c-0")!; + expect(callout).toBeDefined(); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].content).toEqual([]); + }); + + it("unwraps a repair-configured container when only one non-empty child remains", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "cell-a", + children: [{ id: "cell-a-p", type: "paragraph", content: "A" }], + }, + { + type: "gridCell", + id: "cell-b", + children: [{ id: "cell-b-p", type: "paragraph", content: "B" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["cell-a-p"]); + + expect(editor.document).toMatchSnapshot(); + // The grid has been unwrapped: cell B's content replaced it. + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "cell-b-p", + "trailing", + ]); + }); +}); + +describe("children selection", () => { + it("getSelectionCutBlocks handles selections reaching into a container", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "c-p-0"); + + // Previously threw "unexpected" for any partial selection touching a + // container (breaking comments/AI selection handling). + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.length).toBeGreaterThanOrEqual(1); + expect(result.blocks.map((block) => block.id)).toContain("before"); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts new file mode 100644 index 0000000000..5ac50d0fab --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts @@ -0,0 +1,259 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { contentContainerSchema } from "./contentContainers.fixture.js"; + +// The keymap half of the content-bearing container story, split off from the +// (node) `contentContainers.test.ts`. Tiptap can only reach `handleKeyDown` +// through a mounted view, and these used to synthesize a `KeyboardEvent` and +// call the handler directly — which passes whether or not a real keypress ever +// gets there. Here the editor is mounted and focused and the keys are pressed +// for real. +// +// Not ported: "Enter at the end of the title creates a new first child". That +// exact behaviour, caret included, is already covered against a real app in +// `tests/src/end-to-end/containerblocks/containerblocks.test.tsx` +// ("Creates a first child on Enter at the end of a container's content"). + +const schema = contentContainerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +let div: HTMLElement; + +beforeAll(() => { + div = document.createElement("div"); + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +/** Puts the caret where the test wants it and presses the key for real. */ +async function pressKey( + key: string, + at: { block: string; placement: "start" | "end"; offset?: number }, +) { + editor.setTextCursorPosition(at.block, at.placement); + if (at.offset) { + editor._tiptapEditor.commands.setTextSelection( + editor._tiptapEditor.state.selection.from + at.offset, + ); + } + editor.focus(); + await userEvent.keyboard(`{${key}}`); +} + +describe("content-bearing container: keyboard", () => { + it("Backspace at the start of the title unwraps the container", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "t-0", placement: "start" }); + + // The title became a paragraph's content and the children came along as + // that paragraph's children — nothing was destroyed. + const unwrapped = editor.document[1]; + expect(unwrapped.type).toBe("paragraph"); + expect(unwrapped.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(unwrapped.children.map((child) => child.id)).toEqual([ + "t-p-0", + "t-p-1", + ]); + }); + + it("Backspace at the start of the first child merges it into the title", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "t-p-0", placement: "start" }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "TitleFirst", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-1"]); + }); + + it("Backspace at the start of a pure container's first child still moves it out", async () => { + // The control for the two cases above: a container with no title of its own + // must keep the old behaviour. + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "c-p-0", placement: "start" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + expect(editor.document.map((block) => block.id)[1]).toBe("c-p-0"); + }); + + it("Enter mid-title splits, with the tail becoming the first child", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "TitleTail", + children: [{ id: "t-p-0", type: "paragraph", content: "First" }], + }, + ]); + + await pressKey("Enter", { block: "t-0", placement: "start", offset: 5 }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(toggle.children[0].content).toEqual([ + { type: "text", text: "Tail", styles: {} }, + ]); + expect(toggle.children[1].id).toBe("t-p-0"); + }); + + it("Enter on an empty last child still escapes the container", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "t-p-1", placement: "end" }); + + expect(editor.getBlock("t-0")!.children.map((child) => child.id)).toEqual([ + "t-p-0", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "t-0", + "t-p-1", + "trailing", + ]); + // The container kept its own title through the escape. + expect(editor.getBlock("t-0")!.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + }); + + it("Delete at the end of the title pulls the first child's content up", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Delete", { block: "t-0", placement: "end" }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "TitleFirst", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-1"]); + }); + + it("Delete at the end of the title of a container that must keep a child", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Only" }], + }, + ]); + + await pressKey("Delete", { block: "t-0", placement: "end" }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "TitleOnly", styles: {} }, + ]); + // `min: 1` — the schema refills the emptied children node. + expect(toggle.children).toHaveLength(1); + expect(toggle.children[0].content).toEqual([]); + }); + + it("Delete in a childless container does not throw", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "optionalToggle", + id: "t-0", + content: "Title", + children: [], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Delete", { block: "t-0", placement: "end" }); + + // Delete at the end of a childless container's title reaches past it to the + // next block. What matters is that it doesn't throw; asserted as a real + // change so the test can't pass by the keypress never arriving. + expect(editor.getBlock("t-0")!.content).toEqual([ + { type: "text", text: "TitleAfter", styles: {} }, + ]); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts new file mode 100644 index 0000000000..cac5500d73 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts @@ -0,0 +1,94 @@ +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +// The content-bearing container schema shared by `contentContainers.test.ts` +// (node: document model) and `contentContainers.browser.test.ts` (real browser: +// keymap), so both halves are describing the same blocks. + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// The toggle shape: a container with its own inline content (its "title") as +// well as children. `min: 1`, so it always keeps at least one child. +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: { open: { default: true } }, + content: "inline", + children: {}, + }, + { render: renderDiv }, +)(); + +// The same, but allowed to hold no children at all — the `min: 0` shape that +// has no addressable child to fall back on. +const OptionalToggle = createBlockSpec( + { + type: "optionalToggle" as const, + propSchema: {}, + content: "inline", + children: { min: 0 }, + }, + { render: renderDiv }, +)(); + +// A content-bearing container that unwraps as it empties out, paired with the +// pure container below. Repair has to treat these two identically apart from +// the title. +const TitledGrid = createBlockSpec( + { + type: "titledGrid" as const, + propSchema: {}, + content: "inline", + children: { min: 2, unwrapWhenEmptied: true }, + }, + { render: renderDiv }, +)(); + +const PureGrid = createBlockSpec( + { + type: "pureGrid" as const, + propSchema: {}, + content: "none", + children: { min: 2, unwrapWhenEmptied: true }, + }, + { render: renderDiv }, +)(); + +// A pure container, for the "never regress" half of every pair. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: {}, + content: "none", + children: { min: 1, default: [{ type: "paragraph" }] }, + }, + { render: renderDiv }, +)(); + +// A pure container allowed to hold nothing — the shape that has no child to +// place a text cursor in. +const EmptyBox = createBlockSpec( + { + type: "emptyBox" as const, + propSchema: {}, + content: "none", + children: { min: 0 }, + }, + { render: renderDiv }, +)(); + +export const contentContainerSchema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + toggle: Toggle, + optionalToggle: OptionalToggle, + titledGrid: TitledGrid, + pureGrid: PureGrid, + callout: Callout, + emptyBox: EmptyBox, + } as const, +}); diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts new file mode 100644 index 0000000000..0b423bc10b --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts @@ -0,0 +1,358 @@ +// @vitest-environment node +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { contentContainerSchema } from "./contentContainers.fixture.js"; + +// Document-model behaviour of content-bearing containers (the "toggle" shape: +// a container with its own inline content as well as children): repair, +// childless handling, `updateBlock` and selection. All `Block` JSON in and +// `Block` JSON out, so the editor stays headless and this suite needs no DOM. +// +// The keymap half lives in `contentContainers.browser.test.ts`: tiptap can only +// reach `handleKeyDown` through a mounted view. + +const schema = contentContainerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +describe("content-bearing container: repair", () => { + // The whole point of the title is that it holds text the user typed. + // Unwrapping the container throws its node — and therefore its title — away, + // so repair must refuse rather than silently destroy it. + it("does not unwrap a container whose title has content", () => { + editor.replaceBlocks(editor.document, [ + { + type: "titledGrid", + id: "g-0", + content: "Kept title", + children: [ + { id: "g-p-0", type: "paragraph", content: "A" }, + { id: "g-p-1", type: "paragraph", content: "B" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["g-p-0"]); + + const grid = editor.getBlock("g-0"); + expect(grid).toBeDefined(); + expect(grid!.content).toEqual([ + { type: "text", text: "Kept title", styles: {} }, + ]); + // `min: 2`, so ProseMirror refills the removed child rather than letting + // the container drop below what its content expression requires. + expect(grid!.children).toHaveLength(2); + expect(grid!.children.map((child) => child.id)).toContain("g-p-1"); + }); + + it("unwraps a container whose title is empty", () => { + editor.replaceBlocks(editor.document, [ + { + type: "titledGrid", + id: "g-0", + content: "", + children: [ + { id: "g-p-0", type: "paragraph", content: "A" }, + { id: "g-p-1", type: "paragraph", content: "B" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["g-p-0"]); + + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "g-p-1", + "trailing", + ]); + }); + + it("unwraps the equivalent pure container the same way", () => { + editor.replaceBlocks(editor.document, [ + { + type: "pureGrid", + id: "g-0", + children: [ + { id: "g-p-0", type: "paragraph", content: "A" }, + { id: "g-p-1", type: "paragraph", content: "B" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["g-p-0"]); + + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "g-p-1", + "trailing", + ]); + }); + + it("deletes a titleless container that empties out completely", () => { + editor.replaceBlocks(editor.document, [ + { + type: "titledGrid", + id: "g-0", + content: "", + children: [ + { id: "g-p-0", type: "paragraph", content: "A" }, + { id: "g-p-1", type: "paragraph", content: "B" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["g-p-0", "g-p-1"]); + + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual(["trailing"]); + }); +}); + +describe("content-bearing container: childless container", () => { + it("setTextCursorPosition on a childless pure container does not throw", () => { + // A pure container that allows zero children has no child to descend into. + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); + editor.insertBlocks( + [{ type: "emptyBox", id: "b-0", children: [] } as any], + "p-0", + "after", + ); + + expect(() => editor.setTextCursorPosition("b-0", "start")).not.toThrow(); + expect(() => editor.setTextCursorPosition("b-0", "end")).not.toThrow(); + }); + + it("setTextCursorPosition on a childless content-bearing container works", () => { + editor.replaceBlocks(editor.document, [ + { + type: "optionalToggle", + id: "t-0", + content: "Title", + children: [], + }, + ]); + + editor.setTextCursorPosition("t-0", "end"); + expect(editor.getTextCursorPosition().block.id).toBe("t-0"); + }); +}); + +describe("content-bearing container: updateBlock", () => { + it("updates the title in place", () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("t-0", { content: "New title" }); + + const toggle = editor.getBlock("t-0")!; + expect(toggle.content).toEqual([ + { type: "text", text: "New title", styles: {} }, + ]); + // Children (and their ids) are untouched. + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]); + }); + + it("updates props without touching content or children", () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("t-0", { props: { open: false } }); + + const toggle = editor.getBlock("t-0")!; + expect((toggle.props as any).open).toBe(false); + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]); + }); + + it("carries content and children from a paragraph into a container", () => { + editor.replaceBlocks(editor.document, [ + { + id: "p-0", + type: "paragraph", + content: "Title", + children: [{ id: "p-c-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("p-0", { type: "toggle" }); + + // The full-replace path builds a fresh node, so the block is addressed by + // position rather than by id here. + const toggle = editor.document[0]; + expect(toggle.type).toBe("toggle"); + expect(toggle.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(toggle.children.map((child) => child.id)).toEqual(["p-c-0"]); + }); + + it("carries content and children from a container back to a paragraph", () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("t-0", { type: "paragraph" }); + + const paragraph = editor.document[0]; + expect(paragraph.type).toBe("paragraph"); + expect(paragraph.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(paragraph.children.map((child) => child.id)).toEqual(["t-p-0"]); + }); + + it("carries content into a pure container's first child", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Some text" }, + ]); + + editor.updateBlock("p-0", { type: "callout" }); + + const callout = editor.document[0]; + expect(callout.type).toBe("callout"); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].content).toEqual([ + { type: "text", text: "Some text", styles: {} }, + ]); + }); + + it("drops content that has nowhere to go", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Some text" }, + ]); + + editor.updateBlock("p-0", { type: "image" }); + + expect(editor.document[0].type).toBe("image"); + }); + + it("an explicit `content` in the update wins over the carried one", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Old" }, + ]); + + editor.updateBlock("p-0", { type: "toggle", content: "New" }); + + expect(editor.document[0].content).toEqual([ + { type: "text", text: "New", styles: {} }, + ]); + }); + + it("treats `children: []` as inert, not as a clear", () => { + editor.replaceBlocks(editor.document, [ + { + type: "toggle", + id: "t-0", + content: "Title", + children: [{ id: "t-p-0", type: "paragraph", content: "Child" }], + }, + ]); + + editor.updateBlock("t-0", { children: [], props: { open: false } }); + + const toggle = editor.getBlock("t-0")!; + expect((toggle.props as any).open).toBe(false); + expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]); + }); +}); + +describe("content-bearing container: selection", () => { + it("getSelectionCutBlocks handles a selection reaching into the container", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "t-p-0"); + + const result = editor.getSelectionCutBlocks(); + // The container is partially covered, so its included children are + // spliced in rather than the container being returned whole. + expect(result.blocks.map((block) => block.id)).toEqual(["before", "t-p-0"]); + }); + + it("getSelectionCutBlocks handles a selection ending inside the title", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "toggle", + id: "t-0", + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph", content: "First" }, + { id: "t-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "t-0"); + + // The selection ends inside the container's own title, before any of its + // children — so the generated `__children` node is absent from the slice. + // Converting the container must not throw; it comes back as a cut block + // (its title, no children) rather than recursing into its content node. + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.map((block) => block.id)).toEqual(["before", "t-0"]); + expect(result.blockCutAtEnd).toBe("t-0"); + const toggle = result.blocks.find((block) => block.id === "t-0")!; + expect(toggle.children).toEqual([]); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts new file mode 100644 index 0000000000..cd124b92f0 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,282 @@ +import { Fragment, Slice, type Node, type NodeType } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; +import type { Schema } from "prosemirror-model"; + +import { + blockTypeOfContainerChildrenNode, + getChildrenConfig, + isContentContainerNode, + resolveChildren, +} from "../../../schema/blocks/children.js"; +import { getNodeById } from "../../nodeUtil.js"; +import { getBlockSchema, getPmSchema } from "../../pmUtil.js"; + +export function isContainerNode(type: NodeType): boolean { + return type.isInGroup("childContainer") && type.name !== "blockGroup"; +} + +/** + * A container child is "empty" when it contributes no meaningful content: + * a blockContainer with just an empty paragraph, or a nested container + * whose only child is itself empty (recursively). + * + * Multi-child nodes are never empty — collapsing user-built structure would + * be destructive. + */ +export function isEmptyContainerChild(node: Node): boolean { + if (node.type.name === "blockContainer") { + const blockContent = node.firstChild; + return ( + node.childCount === 1 && + !!blockContent && + blockContent.type.name === "paragraph" && + blockContent.childCount === 0 + ); + } + if (isContainerNode(node.type)) { + return node.childCount === 1 && isEmptyContainerChild(node.firstChild!); + } + return false; +} + +export function removeEmptyChildren(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + for ( + let childIndex = container.childCount - 1; + childIndex >= 0; + childIndex-- + ) { + const childPos = tr.doc.resolve(containerPos + 1).posAtIndex(childIndex); + const child = tr.doc.resolve(childPos).nodeAfter; + if (!child) { + throw new Error("Invalid childPos: does not point to a child node."); + } + + if (isEmptyContainerChild(child)) { + tr.delete(childPos, childPos + child.nodeSize); + } + } +} + +function isInsertableChild(node: Node): boolean { + return ( + node.type.name === "blockContainer" || + node.type.isInGroup("blockGroupChild") + ); +} + +/** + * The two nodes repair works on. For a pure container they're the same node — + * it holds its children directly. A container that also has its own content + * keeps its children in a generated `__children` node, so children are counted + * there while unwrapping still has to remove the block's own node. + */ +type ContainerRepairTarget = { + /** The block type whose `children` config governs the repair. */ + blockType: string; + /** Position just before the block's own node — what unwrapping replaces. */ + blockPos: number; + blockNode: Node; + /** Position just before the node holding the children. */ + childrenPos: number; + /** The container's own content node, when it has one. */ + contentNode: Node | undefined; +}; + +function getContainerRepairTarget( + doc: Node, + containerPos: number, +): ContainerRepairTarget | undefined { + const node = doc.resolve(containerPos).nodeAfter; + if (!node) { + return undefined; + } + + if (isContentContainerNode(node)) { + const contentNode = node.firstChild!; + return { + blockType: node.type.name, + blockPos: containerPos, + blockNode: node, + childrenPos: containerPos + 1 + contentNode.nodeSize, + contentNode, + }; + } + + if (!isContainerNode(node.type)) { + return undefined; + } + + // A generated `__children` node is a container node, but it isn't a block — + // its name isn't in the block schema and unwrapping it would strand the + // title. Normalize to the block that owns it. + if (blockTypeOfContainerChildrenNode(node.type.name)) { + return getContainerRepairTarget(doc, doc.resolve(containerPos).before()); + } + + return { + blockType: node.type.name, + blockPos: containerPos, + blockNode: node, + childrenPos: containerPos, + contentNode: undefined, + }; +} + +/** + * Repairs a container after children were removed, per `unwrapWhenEmptied`: + * drops empty children, then unwraps the container if fewer non-empty children + * remain than it requires. + */ +export function fixContainer(tr: Transaction, containerPos: number) { + const target = getContainerRepairTarget(tr.doc, containerPos); + if (!target) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + const blockConfig = getBlockSchema(getPmSchema(tr))[target.blockType]; + const childrenConfig = blockConfig + ? getChildrenConfig(blockConfig) + : undefined; + const config = childrenConfig ? resolveChildren(childrenConfig) : undefined; + + if (!config?.unwrapWhenEmptied) { + return; + } + + // Unwrapping removes the container's own node, and with it whatever the user + // typed into its content. Repair is a background pass triggered by unrelated + // edits, so it stops here rather than silently destroying that text. + if (target.contentNode && target.contentNode.content.size > 0) { + return; + } + + removeEmptyChildren(tr, target.childrenPos); + + const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter; + if (!refreshedBlock || refreshedBlock.type !== target.blockNode.type) { + return; + } + + // Where the (possibly shrunk) children now live, and where they start. + const refreshedChildren = target.contentNode + ? refreshedBlock.lastChild! + : refreshedBlock; + const childrenStart = target.contentNode + ? target.blockPos + 1 + refreshedBlock.firstChild!.nodeSize + 1 + : target.blockPos + 1; + + const min = config.minCount; + + const nonEmptyChildren: { child: Node; offset: number }[] = []; + refreshedChildren.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + nonEmptyChildren.push({ child, offset }); + } + }); + + if (nonEmptyChildren.length >= min) { + return; + } + + const blockEnd = target.blockPos + refreshedBlock.nodeSize; + + if (nonEmptyChildren.length === 0) { + tr.delete(target.blockPos, blockEnd); + return; + } + + // Unwrap: replace the container with its remaining non-empty children. + if (nonEmptyChildren.length === 1) { + const { child, offset } = nonEmptyChildren[0]; + const childStart = childrenStart + offset; + + const [gapFrom, gapTo] = isInsertableChild(child) + ? [childStart, childStart + child.nodeSize] + : [childStart + 1, childStart + child.nodeSize - 1]; + + tr.step( + new ReplaceAroundStep( + target.blockPos, + blockEnd, + gapFrom, + gapTo, + Slice.empty, + 0, + false, + ), + ); + return; + } + + // Several survivors but still below `min`: rebuild replacement content. + const replacement: Node[] = []; + for (const { child } of nonEmptyChildren) { + if (isInsertableChild(child)) { + replacement.push(child); + } else { + child.forEach((grandChild) => replacement.push(grandChild)); + } + } + tr.replaceWith(target.blockPos, blockEnd, Fragment.from(replacement)); +} + +/** + * Runs `fixContainer` on containers by id, deepest first. + * Re-locates each container before repair since positions shift. + */ +export function fixContainersById( + tr: Transaction, + containers: { id: string; depth: number }[], +) { + [...containers] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (!target) { + return; + } + fixContainer(tr, target.posBeforeNode); + }); +} + +/** + * Flattens blocks that can't be inserted at the top level (e.g. `column`) + * into their children, recursively. + */ +export function flattenNonInsertableBlocks< + T extends { type?: string; content?: unknown; children?: T[] }, +>(blocks: T[], pmSchema: Schema): T[] { + return blocks.flatMap((block) => { + const nodeType = block.type ? pmSchema.nodes[block.type] : undefined; + if ( + nodeType && + nodeType.isInGroup("bnBlock") && + !nodeType.isInGroup("blockGroupChild") + ) { + const children = flattenNonInsertableBlocks( + block.children ?? [], + pmSchema, + ); + // Flattening drops the block itself. If it had content of its own — + // a container's title, text the user typed — that leads as a paragraph + // rather than disappearing with it. + return Array.isArray(block.content) && block.content.length > 0 + ? [ + { type: "paragraph", content: block.content } as unknown as T, + ...children, + ] + : children; + } + return [block]; + }); +} diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index d6229a3f0a..466845d94a 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -169,15 +169,12 @@ export function setSelection( headBlockInfo.blockNoteType as keyof typeof schema.blockSchema ]; - if ( - !anchorBlockInfo.isBlockContainer || - anchorBlockConfig.content === "none" - ) { + if (!anchorBlockInfo.isWrappedBlock || anchorBlockConfig.content === "none") { throw new Error( `Attempting to set selection anchor in block without content (id ${startBlockId})`, ); } - if (!headBlockInfo.isBlockContainer || headBlockConfig.content === "none") { + if (!headBlockInfo.isWrappedBlock || headBlockConfig.content === "none") { throw new Error( `Attempting to set selection anchor in block without content (id ${endBlockId})`, ); diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts index b0b2cc078d..38ad256457 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -74,7 +74,7 @@ export function setTextCursorPosition( const contentType: "none" | "inline" | "table" | "plain" = schema.blockSchema[info.blockNoteType]!.content; - if (info.isBlockContainer) { + if (info.isWrappedBlock) { const blockContent = info.blockContent; if (contentType === "none") { tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos)); @@ -110,8 +110,15 @@ export function setTextCursorPosition( } else { const child = placement === "start" - ? info.childContainer.node.firstChild! - : info.childContainer.node.lastChild!; + ? info.childContainer.node.firstChild + : info.childContainer.node.lastChild; + + if (!child) { + // A container allowed to hold no children has no text to put a cursor + // in, so the container itself is selected instead. + tr.setSelection(NodeSelection.create(tr.doc, info.bnBlock.beforePos)); + return; + } setTextCursorPosition(tr, getNodeId(child, tr.doc), placement); } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index e2274140f7..e1e72cf696 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -6,8 +6,10 @@ import { BlockImplementation, BlockSchema, InlineContentSchema, + isContainerType, StyleSchema, } from "../../../../schema/index.js"; +import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -270,6 +272,21 @@ function serializeBlock< } elementFragment.append(...Array.from(ret.dom.childNodes)); } else { + // Asked of the block config rather than of its ProseMirror node — see the + // same check in `serializeBlocksInternalHTML`. + if (isContainerType(editor.schema.blockSchema[block.type as any])) { + // Container blocks own their outer DOM. Make sure the attributes + // needed to parse the HTML back (the type marker and non-default + // props, in the same `data-*` convention `propsToAttributes` reads) + // are present even when the block's render didn't add them. + // Author-set attributes win. + fillContainerAttributes( + ret.dom as HTMLElement, + block.type!, + props, + editor.schema.blockSchema[block.type as any].propSchema, + ); + } elementFragment.append(ret.dom); if (nestingLevel > 0) { (ret.dom as HTMLElement).setAttribute( @@ -297,15 +314,23 @@ function serializeBlock< // round trip, we fill their content with a placeholder character that the // parser strips out again (see `EMPTY_BLOCK_PLACEHOLDER`). // - // Only applies to blocks that hold inline content: containers (columns, - // tables) fill their `contentDOM` with child blocks later on, and code - // blocks would turn the placeholder into literal content. + // Only applies to blocks that hold inline content: pure containers + // (columns, tables) fill their `contentDOM` with child blocks later on, + // and code blocks would turn the placeholder into literal content. + // + // A container that has its *own* content needs the placeholder for a + // second reason, and its outer node isn't `inlineContent` so it needs its + // own check: that node's content is `__content __children`, so + // a parser reading a block element first has nothing to satisfy the + // content node with and cannot open the children node — every child then + // lands *after* the container instead of inside it. A leading text node is + // what opens the content node. const blockNodeType = editor.pmSchema.nodes[block.type as any]; - if ( - blockNodeType?.inlineContent && - !blockNodeType.spec.code && - ret.contentDOM.childNodes.length === 0 - ) { + const blockConfig = editor.schema.blockSchema[block.type as any]; + const needsPlaceholder = blockNodeType?.inlineContent + ? !blockNodeType.spec.code + : isContainerType(blockConfig) && blockConfig.content !== "none"; + if (needsPlaceholder && ret.contentDOM.childNodes.length === 0) { ret.contentDOM.appendChild(doc.createTextNode(EMPTY_BLOCK_PLACEHOLDER)); } } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..04113a013e 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -5,8 +5,10 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { BlockSchema, InlineContentSchema, + isContainerType, StyleSchema, } from "../../../../schema/index.js"; +import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -126,6 +128,30 @@ export function serializeInlineContentInternalHTML< return fragment; } +/** + * Appends the two region elements a content-bearing container's generated + * `__content` / `__children` nodes render, so that internal HTML matches what + * the editor puts in the DOM (and what the generated parse rules match). + */ +function createContainerRegions( + contentDOM: HTMLElement, + blockType: string, + options?: { document?: Document }, +): { content: HTMLElement; children: HTMLElement } { + const doc = options?.document ?? document; + + const content = doc.createElement("div"); + content.className = "bn-inline-content"; + content.setAttribute("data-content-type", blockType); + + const children = doc.createElement("div"); + children.setAttribute("data-children-of", blockType); + + contentDOM.append(content, children); + + return { content, children }; +} + function serializeBlock< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -159,7 +185,28 @@ function serializeBlock< editor as any, ); - if (ret.contentDOM && block.content) { + // Asked of the block config rather than of its ProseMirror node: a container + // that has its own content compiles to an outer node holding a separate + // children node, so the outer node is not itself a `childContainer` — but + // the block is still a container and still owns its outer DOM. + const blockConfig = editor.schema.blockSchema[block.type as any]; + const isContainer = isContainerType(blockConfig); + + // A container with its own content holds two nodes — the generated + // `__content` and `__children` — and so renders two region elements inside + // its content host. They are not decoration: without them only the *first* + // child parses back inside the container. ProseMirror has to invent the + // `__children` wrapping while parsing, and `blockContainer`'s `blockOuter` + // skip rule re-syncs the parse context to the container afterwards, closing + // that invented wrapping again. + const regions = + isContainer && ret.contentDOM && blockConfig.content !== "none" + ? createContainerRegions(ret.contentDOM, block.type!, options) + : undefined; + + const contentHost = regions?.content ?? ret.contentDOM; + + if (contentHost && block.content) { const ic = serializeInlineContentInternalHTML( editor, block.content as any, // TODO @@ -167,12 +214,25 @@ function serializeBlock< block.type, options, ); - ret.contentDOM.appendChild(ic); + contentHost.appendChild(ic); } - const pmType = editor.pmSchema.nodes[block.type as any]; + if (isContainer) { + // Container blocks own their outer DOM. Internal HTML must round-trip + // losslessly, so make sure the attributes the generated parse rules read + // (the type marker and non-default props as `data-*`) are present even + // when the block's render didn't add them. Author-set attributes win. + fillContainerAttributes( + ret.dom as HTMLElement, + block.type!, + props, + blockConfig.propSchema, + ); - if (pmType.isInGroup("bnBlock")) { + // A pure container holds its children directly in its `contentDOM`; one + // with its own content puts them in the children region, after the content + // region — the reading order the document model itself imposes. + const childrenHost = regions?.children ?? ret.contentDOM; if (block.children && block.children.length > 0) { const fragment = serializeBlocks( editor, @@ -181,7 +241,7 @@ function serializeBlock< options, ); - ret.contentDOM?.append(fragment); + childrenHost?.append(fragment); } return ret.dom; } diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 04ed789c98..667e32673d 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,6 +1,11 @@ import { Node, ResolvedPos } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; +import { + CHILD_CONTAINER_GROUP, + CONTAINER_CONTENT_GROUP, +} from "../schema/blocks/children.js"; + type SingleBlockInfo = { node: Node; beforePos: number; @@ -20,13 +25,16 @@ export type BlockInfo = { blockNoteType: string; } & ( | { - // In case we're not dealing with a BlockContainer, we're dealing with a "wrapper node" (like a Column or ColumnList), so it will always have children + // A container block (Column, ColumnList, a custom container): its own node + // holds its children directly, and it has no `blockContent` of its own. /** - * The Prosemirror node that holds block.children. For non-blockContainer, this node will be the same as bnBlock. + * The Prosemirror node that holds block.children. For a container block, + * this node is the same as bnBlock. */ childContainer: SingleBlockInfo; - isBlockContainer: false; + blockContent?: undefined; + isWrappedBlock: false; } | { /** @@ -38,9 +46,16 @@ export type BlockInfo = { */ blockContent: SingleBlockInfo; /** - * Whether bnBlock is a blockContainer node + * Whether `bnBlock` wraps the block's content in a node of its own — + * either a `blockContainer` (an ordinary block wrapped for nesting), or + * a container block that has its own content as well as children. Both + * have the same shape: a content node, then an optional child container. + * + * Note this is roughly the *opposite* of "is a container block": a + * column has `isWrappedBlock: false`. Sites that need "is this literally + * a `blockContainer`" should read `bnBlock.node.type.name`. */ - isBlockContainer: true; + isWrappedBlock: true; } ); @@ -183,48 +198,49 @@ export function getBlockInfoWithManualOffset( afterPos: bnBlockAfterPos, }; - if (bnBlockNode.type.name === "blockContainer") { + // A container block that has its own content is shaped like a + // `blockContainer`: a content node followed by a node holding its children. + // Discriminating on that shape rather than on the node's name is what lets + // every branch written against `blockContainer` cover it too. + const isContentContainer = !!bnBlockNode.firstChild?.type.isInGroup( + CONTAINER_CONTENT_GROUP, + ); + + if (bnBlockNode.type.name === "blockContainer" || isContentContainer) { let blockContent: SingleBlockInfo | undefined; - let blockGroup: SingleBlockInfo | undefined; + let childContainer: SingleBlockInfo | undefined; bnBlockNode.forEach((node, offset) => { - if (node.type.spec.group === "blockContent") { - // console.log(beforePos, offset); - const blockContentNode = node; - const blockContentBeforePos = bnBlockBeforePos + offset + 1; - const blockContentAfterPos = blockContentBeforePos + node.nodeSize; - - blockContent = { - node: blockContentNode, - beforePos: blockContentBeforePos, - afterPos: blockContentAfterPos, - }; - } else if (node.type.name === "blockGroup") { - const blockGroupNode = node; - const blockGroupBeforePos = bnBlockBeforePos + offset + 1; - const blockGroupAfterPos = blockGroupBeforePos + node.nodeSize; + const beforePos = bnBlockBeforePos + offset + 1; + const afterPos = beforePos + node.nodeSize; - blockGroup = { - node: blockGroupNode, - beforePos: blockGroupBeforePos, - afterPos: blockGroupAfterPos, - }; + if ( + node.type.spec.group === "blockContent" || + node.type.isInGroup(CONTAINER_CONTENT_GROUP) + ) { + blockContent = { node, beforePos, afterPos }; + } else if (node.type.isInGroup(CHILD_CONTAINER_GROUP)) { + childContainer = { node, beforePos, afterPos }; } }); if (!blockContent) { throw new Error( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `blockContainer node does not contain a blockContent node in its children: ${bnBlockNode}`, + `${bnBlockNode.type.name} node does not contain a content node in its children: ${bnBlockNode}`, ); } return { - isBlockContainer: true, + isWrappedBlock: true, bnBlock, blockContent, - childContainer: blockGroup, - blockNoteType: blockContent.node.type.name, + childContainer, + // A `blockContainer` is a generic wrapper, so its type comes from the + // content node inside it. A container block *is* its own type. + blockNoteType: isContentContainer + ? bnBlockNode.type.name + : blockContent.node.type.name, }; } else { if (!bnBlock.node.type.isInGroup("childContainer")) { @@ -235,7 +251,7 @@ export function getBlockInfoWithManualOffset( } return { - isBlockContainer: false, + isWrappedBlock: false, bnBlock: bnBlock, childContainer: bnBlock, blockNoteType: bnBlock.node.type.name, diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index af5c0ba1b7..2ecc0f8974 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -1,4 +1,11 @@ -import { Attrs, Fragment, Mark, Node, Schema } from "@tiptap/pm/model"; +import { + Attrs, + Fragment, + Mark, + Node, + NodeType, + Schema, +} from "@tiptap/pm/model"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { @@ -16,10 +23,20 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +import { + getChildrenConfig, + getContentContainerNodeTypes, + resolveChildren, +} from "../../schema/blocks/children.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; import { getColspan, isPartialTableCell } from "../../util/table.js"; import { UnreachableCaseError } from "../../util/typescript.js"; import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js"; -import { getStyleSchema, isPlainContentNodeType } from "../pmUtil.js"; +import { + getBlockSchema, + getStyleSchema, + isPlainContentNodeType, +} from "../pmUtil.js"; /** * Convert a StyledText inline element to a @@ -334,6 +351,194 @@ function blockOrInlineContentToContentNode( return contentNode; } +const EMPTY_SEEDING: ReadonlySet = new Set(); + +function unwrapsWhenEmptied(blockType: string, schema: Schema): boolean { + const blockConfig = getBlockSchema(schema)[blockType]; + const children = blockConfig ? getChildrenConfig(blockConfig) : undefined; + return !!children && resolveChildren(children).unwrapWhenEmptied; +} + +/** + * Assigns ids to any block nodes that don't have one. + * + * `createAndFill` builds missing children straight from the schema, and those + * carry `id: null`. The `UniqueID` plugin never gets a chance to fix them, + * because callers like `insertBlocks` convert the node back through + * `nodeToBlock` before the transaction is ever dispatched. + */ +function withGeneratedIds(node: Node): Node { + if (node.isText) { + return node; + } + + const children: Node[] = []; + let childChanged = false; + node.forEach((child) => { + const next = withGeneratedIds(child); + childChanged ||= next !== child; + children.push(next); + }); + + const needsId = node.type.isInGroup("bnBlock") && node.attrs.id === null; + if (!needsId && !childChanged) { + return node; + } + + return node.type.create( + needsId ? { ...node.attrs, id: UniqueID.options.generateID() } : node.attrs, + childChanged ? Fragment.from(children) : node.content, + node.marks, + ); +} + +/** + * Produces child nodes from a container's `default` config, guarding against + * self-referential cycles. Returns `undefined` when seeding is not needed (no + * defaults, a container allowed to be empty, or the caller already supplied + * children). + */ +function seedDefaultChildren( + blockType: string, + schema: Schema, + styleSchema: StyleSchema, + seedingTypes: ReadonlySet, +): Node[] | undefined { + const blockSchemaConfig = getBlockSchema(schema)[blockType]; + const childrenConfig = blockSchemaConfig + ? getChildrenConfig(blockSchemaConfig) + : undefined; + + if (!childrenConfig) { + return undefined; + } + + const defaultChildren = resolveChildren(childrenConfig).default; + if (!defaultChildren || defaultChildren.length === 0) { + return undefined; + } + + if (seedingTypes.has(blockType)) { + throw new Error( + `Seeding "${blockType}" ends up seeding it again (${[...seedingTypes, blockType].join(" -> ")}). ` + + "Give the cyclic default explicit children, or remove the self-reference.", + ); + } + + const nextSeeding = new Set(seedingTypes).add(blockType); + return defaultChildren.map((child) => + blockToNode( + child as PartialBlock, + schema, + styleSchema, + nextSeeding, + ), + ); +} + +/** + * The nodes a block's inline content becomes, for a block whose content node + * isn't the block's own node (a container with its own content). + */ +function partialContentToInlineNodes( + block: PartialBlock, + contentNodeName: string, + schema: Schema, + styleSchema: StyleSchema, +): Node[] { + if (block.content === undefined) { + return []; + } + if (typeof block.content === "string" || Array.isArray(block.content)) { + return inlineContentToNodes( + typeof block.content === "string" ? [block.content] : block.content, + schema, + contentNodeName, + styleSchema, + ); + } + + throw new Error( + `Block "${block.type}" cannot have content of type "${block.content.type}".`, + ); +} + +/** + * Creates the node holding a container's children when the caller didn't + * specify any: seeds the container's `default`, then lets ProseMirror supply + * whatever the content expression still requires. That's what keeps the + * minimal `children: {}` container from being created empty and throwing a raw + * ProseMirror error at the caller's `node.check()`. + * + * For a pure container that node is the block's own node (hence `attrs`); for + * a container with its own content it's the generated children node. + */ +function createContainerChildrenNode( + blockType: string, + type: NodeType, + schema: Schema, + styleSchema: StyleSchema, + seedingTypes: ReadonlySet, + attrs: Attrs | null = null, +): Node { + const seeded = seedDefaultChildren( + blockType, + schema, + styleSchema, + seedingTypes, + ); + + // ...except for a container that unwraps as it empties out. Filling one of + // those produces a block that the next repair pass immediately deletes, so + // an empty one is refused rather than silently created and then lost. + if (!seeded && unwrapsWhenEmptied(blockType, schema)) { + return type.create(attrs); + } + + const node = type.createAndFill(attrs, seeded); + if (!node) { + throw new Error( + `Cannot create block "${blockType}": its \`default\` children don't fit its \`children\` config ` + + `(it accepts \`${type.spec.content}\`).`, + ); + } + + return node; +} + +/** + * The node for a container whose children the caller gave explicitly. + * + * The children are kept exactly as given, but still filled out to what the + * content expression requires. `children: []` on a `min: 1` container is a + * caller asking for something the schema cannot represent, so it gets the same + * empty paragraph the omitted-children path would have produced — rather than + * an under-filled node that only the call sites which happen to run + * `node.check()` reject, and that `initialContent` lets straight through. + * + * Two cases are deliberately left unfilled: + * + * - A container that unwraps as it empties out. Padding one of those back up + * to its minimum adds a child the next repair pass immediately removes, so a + * one-column columnList stays a one-column columnList and unwraps. + * - Children that cannot be made to fit at all — a `sequence` given in the + * wrong order, say. Returning the unfilled node lets the caller's + * `node.check()` report what is actually wrong instead of a fill failure. + */ +function createExplicitChildrenNode( + blockType: string, + type: NodeType, + schema: Schema, + children: Node[], + attrs: Attrs | null = null, +): Node { + if (unwrapsWhenEmptied(blockType, schema)) { + return type.create(attrs, children); + } + + return type.createAndFill(attrs, children) ?? type.create(attrs, children); +} + /** * Converts a BlockNote block to a Prosemirror node. */ @@ -341,6 +546,7 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + seedingTypes: ReadonlySet = EMPTY_SEEDING, ) { let id = block.id; @@ -352,7 +558,7 @@ export function blockToNode( if (block.children) { for (const child of block.children) { - children.push(blockToNode(child, schema, styleSchema)); + children.push(blockToNode(child, schema, styleSchema, seedingTypes)); } } @@ -360,9 +566,11 @@ export function blockToNode( !block.type || // can happen if block.type is not defined (this should create the default node) schema.nodes[block.type].isInGroup("blockContent"); - if (isBlockContent) { - // Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer + const contentContainerTypes = block.type + ? getContentContainerNodeTypes(schema, block.type) + : undefined; + if (isBlockContent) { const contentNode = blockOrInlineContentToContentNode( block, schema, @@ -381,15 +589,53 @@ export function blockToNode( }, groupNode ? [contentNode, groupNode] : contentNode, ); - } else if (schema.nodes[block.type].isInGroup("bnBlock")) { - // `create` (not `createChecked`) so partial container blocks pass through; - // callers that mutate the doc validate via `node.check()` before inserting. - return schema.nodes[block.type].create( - { - id: id, - ...block.props, - }, - children, + } else if (contentContainerTypes) { + // A container with its own content: the content and the children each get + // a node of their own, since a ProseMirror node holds either inline + // content or block content but never both. + const { contentType, childrenType } = contentContainerTypes; + + const contentNode = contentType.createChecked( + null, + partialContentToInlineNodes(block, contentType.name, schema, styleSchema), + ); + + const childrenNode = + block.children !== undefined + ? createExplicitChildrenNode(block.type, childrenType, schema, children) + : createContainerChildrenNode( + block.type, + childrenType, + schema, + styleSchema, + seedingTypes, + ); + + return withGeneratedIds( + schema.nodes[block.type].create({ id: id, ...block.props }, [ + contentNode, + childrenNode, + ]), + ); + } else if (isContainerNode(schema.nodes[block.type])) { + const type = schema.nodes[block.type]; + const attrs = { id: id, ...block.props }; + + if (block.children !== undefined) { + return withGeneratedIds( + createExplicitChildrenNode(block.type, type, schema, children, attrs), + ); + } + + return withGeneratedIds( + createContainerChildrenNode( + block.type, + type, + schema, + styleSchema, + seedingTypes, + attrs, + ), ); } else { throw new Error( diff --git a/packages/core/src/api/nodeConversions/contentContainers.test.ts b/packages/core/src/api/nodeConversions/contentContainers.test.ts new file mode 100644 index 0000000000..9bd12a90a5 --- /dev/null +++ b/packages/core/src/api/nodeConversions/contentContainers.test.ts @@ -0,0 +1,429 @@ +// @vitest-environment node +import type { Node, Schema } from "@tiptap/pm/model"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../schema/blocks/createSpec.js"; +import { + getBottomNestedBlockInfo, + getPrevBlockInfo, +} from "../blockManipulation/commands/mergeBlocks/mergeBlocks.js"; +import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js"; +import { blockToNode } from "./blockToNode.js"; +import { nodeToBlock } from "./nodeToBlock.js"; + +// A container block with its own inline content: the toggle shape. Its node +// holds a generated content node and a generated children node, which is what +// makes its `Block` JSON identical to a nested regular block's. +// Nothing here is ever rendered — this suite works on nodes and a headless +// editor's schema — so `render` only has to exist for the spec to be accepted. +const notRendered = () => { + throw new Error("not rendered in this suite"); +}; + +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: { open: { default: true } }, + content: "inline", + children: {}, + }, + { render: notRendered }, +)(); + +// The same, but allowed to have no children at all. +const OptionalToggle = createBlockSpec( + { + type: "optionalToggle" as const, + propSchema: {}, + content: "inline", + children: { min: 0 }, + }, + { render: notRendered }, +)(); + +// A pure container, to pair each content-bearing container against. +const containerSpec = ( + type: TName, + config: { content: "none" | "inline"; children: any; placement?: any }, +) => + createBlockSpec( + { + type, + propSchema: {}, + ...config, + } as any, + { render: notRendered }, + )(); + +// Pairs of (pure container, content-bearing container) sharing one `children` +// config. Their content expressions must match: the same generator runs for +// both, so every `allow`/`min`/`max`/`sequence` option enforces identically. +const CHILDREN_CONFIGS = { + Default: {}, + Bounded: { min: 0, max: 3 }, + Restricted: { allow: { blocks: false, containers: ["cell"] }, min: 2 }, + Sequenced: { + sequence: [ + { allow: { blocks: false, containers: ["cell"] } }, + { count: { min: 1 } }, + ], + }, +} as const; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + toggle: Toggle, + optionalToggle: OptionalToggle, + cell: containerSpec("cell", { content: "none", children: {} }), + pureDefault: containerSpec("pureDefault", { + content: "none", + children: CHILDREN_CONFIGS.Default, + }), + contentDefault: containerSpec("contentDefault", { + content: "inline", + children: CHILDREN_CONFIGS.Default, + }), + pureBounded: containerSpec("pureBounded", { + content: "none", + children: CHILDREN_CONFIGS.Bounded, + }), + contentBounded: containerSpec("contentBounded", { + content: "inline", + children: CHILDREN_CONFIGS.Bounded, + }), + pureRestricted: containerSpec("pureRestricted", { + content: "none", + children: CHILDREN_CONFIGS.Restricted, + }), + contentRestricted: containerSpec("contentRestricted", { + content: "inline", + children: CHILDREN_CONFIGS.Restricted, + }), + pureSequenced: containerSpec("pureSequenced", { + content: "none", + children: CHILDREN_CONFIGS.Sequenced, + }), + contentSequenced: containerSpec("contentSequenced", { + content: "inline", + children: CHILDREN_CONFIGS.Sequenced, + }), + } as const, +}); + +let editor: BlockNoteEditor; +let pmSchema: Schema; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }) as any; + pmSchema = editor.pmSchema; +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +// `nodeToBlock(node, doc)` takes the containing document as its second +// argument, so blocks built in isolation need a minimal valid doc around them. +const wrapInDoc = (...blocks: Node[]): Node => + pmSchema.nodes["doc"].createChecked( + null, + pmSchema.nodes["blockGroup"].createChecked(null, blocks), + ); + +describe("content-bearing container: node shape", () => { + it("builds a content node and a children node inside the block's node", () => { + const node = blockToNode( + { + id: "t-0", + type: "toggle", + props: { open: false }, + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + + expect(node.type.name).toBe("toggle"); + expect(node.type.isInGroup("bnBlock")).toBe(true); + expect(node.type.isInGroup("blockGroupChild")).toBe(true); + // The block's node is not itself a child container — the generated + // children node is. + expect(node.type.isInGroup("childContainer")).toBe(false); + + expect(node.childCount).toBe(2); + + const contentNode = node.child(0); + expect(contentNode.type.name).toBe("toggle__content"); + expect(contentNode.type.isInGroup("containerContent")).toBe(true); + // Deliberately not `blockContent`: that group is what `blockContainer` + // accepts, so a paste could otherwise produce + // `blockContainer > toggle__content`. + expect(contentNode.type.isInGroup("blockContent")).toBe(false); + expect(contentNode.textContent).toBe("Title"); + + const childrenNode = node.child(1); + expect(childrenNode.type.name).toBe("toggle__children"); + expect(childrenNode.type.isInGroup("childContainer")).toBe(true); + expect(childrenNode.childCount).toBe(1); + expect(childrenNode.child(0).type.name).toBe("blockContainer"); + + // The node is valid against the schema. + expect(() => node.check()).not.toThrow(); + }); + + it("keeps all props (and the id) on the outer node", () => { + const node = blockToNode( + { + id: "t-0", + type: "toggle", + props: { open: false }, + content: "Title", + } as any, + pmSchema, + ); + + expect(node.attrs.id).toBe("t-0"); + expect(node.attrs.open).toBe(false); + expect("open" in node.child(0).attrs).toBe(false); + expect("id" in node.child(0).attrs).toBe(false); + }); + + it("auto-fills children when none are given", () => { + const node = blockToNode( + { id: "t-0", type: "toggle", content: "Title" } as any, + pmSchema, + ); + + const childrenNode = node.child(1); + expect(childrenNode.childCount).toBe(1); + // Auto-filled nodes come from the schema with `id: null`; they must be + // given real ids before they're converted back to blocks. + expect(childrenNode.child(0).attrs.id).toBeTruthy(); + }); +}); + +describe("content-bearing container: Block JSON", () => { + it("round-trips identically to a nested regular block", () => { + const toggleNode = blockToNode( + { + id: "b-0", + type: "toggle", + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + const paragraphNode = blockToNode( + { + id: "b-0", + type: "paragraph", + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + + const toggleBlock = nodeToBlock(toggleNode, wrapInDoc(toggleNode)); + const paragraphBlock = nodeToBlock(paragraphNode, wrapInDoc(paragraphNode)); + + expect(Object.keys(toggleBlock)).toEqual([ + "id", + "type", + "props", + "content", + "children", + ]); + expect(Object.keys(toggleBlock)).toEqual(Object.keys(paragraphBlock)); + + // Everything but the block's own type and props is structurally identical + // to the nested paragraph's. + const { type: _toggleType, props: _toggleProps, ...toggle } = toggleBlock; + const { + type: _paragraphType, + props: _paragraphProps, + ...paragraph + } = paragraphBlock; + expect(toggle).toEqual(paragraph); + + expect(toggleBlock).toEqual({ + id: "b-0", + type: "toggle", + props: { open: true }, + content: [{ type: "text", text: "Title", styles: {} }], + children: [ + { + id: "c-0", + type: "paragraph", + props: (paragraphBlock.children as any[])[0].props, + content: [{ type: "text", text: "Child", styles: {} }], + children: [], + }, + ], + }); + }); + + it("round-trips an empty container with no content", () => { + const node = blockToNode( + { id: "t-0", type: "optionalToggle", children: [] } as any, + pmSchema, + ); + const block = nodeToBlock(node, wrapInDoc(node)); + + expect(block.content).toEqual([]); + expect(block.children).toEqual([]); + }); +}); + +describe("content-bearing container: BlockInfo", () => { + it("is a wrapped block, with the content and children nodes resolved", () => { + const node = blockToNode( + { + id: "t-0", + type: "toggle", + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + + const info = getBlockInfoWithManualOffset(node, 0); + + // Structurally identical to a `blockContainer`, so every keyboard branch + // written against one covers this too. + expect(info.isWrappedBlock).toBe(true); + expect(info.blockContent!.node.type.name).toBe("toggle__content"); + expect(info.childContainer!.node.type.name).toBe("toggle__children"); + // The type comes from the outer node — a `blockContainer` is a generic + // wrapper, but a container block *is* its own type. + expect(info.blockNoteType).toBe("toggle"); + + // Positions are those of the nodes themselves. + expect(info.bnBlock.beforePos).toBe(0); + expect(info.blockContent!.beforePos).toBe(1); + expect(info.blockContent!.afterPos).toBe(1 + node.child(0).nodeSize); + expect(info.childContainer!.beforePos).toBe(1 + node.child(0).nodeSize); + }); + + it("still reads a blockContainer's type from its content node", () => { + const node = blockToNode( + { id: "p-0", type: "paragraph", content: "Hello" } as any, + pmSchema, + ); + + const info = getBlockInfoWithManualOffset(node, 0); + expect(info.isWrappedBlock).toBe(true); + expect(info.bnBlock.node.type.name).toBe("blockContainer"); + expect(info.blockNoteType).toBe("paragraph"); + }); + + it("handles a container with zero children", () => { + const paragraphNode = blockToNode( + { id: "p-0", type: "paragraph", content: "Before" } as any, + pmSchema, + ); + const toggleNode = blockToNode( + { + id: "t-0", + type: "optionalToggle", + content: "Title", + children: [], + } as any, + pmSchema, + ); + const doc = wrapInDoc(paragraphNode, toggleNode); + + const togglePos = 1 + paragraphNode.nodeSize; + const info = getBlockInfoWithManualOffset(toggleNode, togglePos); + expect(info.childContainer!.node.childCount).toBe(0); + + // An empty child container has no last child to descend into, so the + // block itself is the bottom one. + expect(() => getBottomNestedBlockInfo(doc, info)).not.toThrow(); + expect(getBottomNestedBlockInfo(doc, info).bnBlock.node).toBe(toggleNode); + + expect(() => getPrevBlockInfo(doc, togglePos)).not.toThrow(); + expect(getPrevBlockInfo(doc, togglePos)!.blockNoteType).toBe("paragraph"); + }); +}); + +describe("content-bearing container: in a headless editor", () => { + it("inserts and reads back", () => { + const headless = BlockNoteEditor.create({ schema }) as any; + + try { + headless.replaceBlocks(headless.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); + headless.insertBlocks( + [ + { + id: "t-0", + type: "toggle", + content: "Title", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + }, + ], + "p-0", + "after", + ); + + const block = headless.getBlock("t-0")!; + expect(block.type).toBe("toggle"); + expect(block.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(block.children.map((child: any) => child.id)).toEqual(["c-0"]); + // The child is an ordinary block of the document, reachable by id. + expect(headless.getBlock("c-0")).toBeDefined(); + // The rendering half of this — that a container's attributes land on the + // author's own root element — is asserted against a real cascade in + // `tests/src/end-to-end/containerblocks/containerblocks.test.tsx` + // ("Stamps node type and id onto the author's own root element"). + } finally { + headless._tiptapEditor.destroy(); + } + }); +}); + +describe("content-bearing container: children content expression", () => { + it.each(Object.keys(CHILDREN_CONFIGS))( + "%s compiles the same as it does for a pure container", + (name) => { + const pure = pmSchema.nodes[`pure${name}`]; + const contentBearing = pmSchema.nodes[`content${name}__children`]; + + expect(contentBearing).toBeDefined(); + expect(contentBearing.spec.content).toBe(pure.spec.content); + }, + ); + + it("enforces the expression on the children node", () => { + // `Restricted` allows only `cell` children, and at least two of them. + expect(() => + blockToNode( + { + type: "contentRestricted", + content: "Title", + children: [{ type: "paragraph" }], + } as any, + pmSchema, + ).check(), + ).toThrow(); + + expect(() => + blockToNode( + { + type: "contentRestricted", + content: "Title", + children: [{ type: "cell" }, { type: "cell" }], + } as any, + pmSchema, + ).check(), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..8aa1aa04b0 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,12 +1,86 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + getChildrenConfig, + getMinChildCount, + isContentContainerNode, + isPlaceableAnywhere, +} from "../../schema/blocks/children.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; +import { getBlockSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; +/** + * The node holding a container's children, and the block type governing them: + * the node itself for a pure container, the generated `__children` node for a + * container that also has its own content. `undefined` for anything else — + * including a stray `__children` node, whose name isn't a block type and which + * is therefore always flattened. + */ +function getContainerChildren( + node: Node, +): { blockType: string; children: Node } | undefined { + if (isContentContainerNode(node)) { + return { blockType: node.type.name, children: node.lastChild! }; + } + if (isContainerNode(node.type)) { + return { blockType: node.type.name, children: node }; + } + return undefined; +} + +/** + * Whether a container node is "self-contained" — it has enough children to + * stand on its own and is allowed at the top level. Containers that aren't + * (e.g. a single selected column of a columnList) are flattened into their + * children. + */ +function isSelfContainedContainer(node: Node): boolean { + const container = getContainerChildren(node); + if (!container) { + return false; + } + const blockConfig = + getBlockSchema(node.type.schema)[container.blockType] ?? {}; + const children = getChildrenConfig(blockConfig); + if (!children) { + return false; + } + return ( + isPlaceableAnywhere(blockConfig) && + container.children.childCount >= getMinChildCount(children) + ); +} + +/** + * A container's own content as a standalone paragraph block, so flattening the + * container doesn't take the user's text with it. + */ +function containerContentAsBlock< + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema, +>(node: Node, root: Node): BlockNoDefaults | undefined { + if (!isContentContainerNode(node) || node.firstChild!.content.size === 0) { + return undefined; + } + const schema = node.type.schema; + const paragraph = schema.nodes["paragraph"].create( + null, + node.firstChild!.content, + ); + + return nodeToBlock( + schema.nodes["blockContainer"].createAndFill(null, paragraph)!, + root, + ); +} + /** * Converts all Blocks within a fragment to BlockNote blocks. */ @@ -15,46 +89,33 @@ export function fragmentToBlocks< I extends InlineContentSchema, S extends StyleSchema, >(fragment: Fragment) { - // first convert selection to blocknote-style blocks, and then - // pass these to the exporter const blocks: BlockNoDefaults[] = []; + + const pushFlattened = (node: Node, root: Node) => { + const container = getContainerChildren(node); + if (container && !isSelfContainedContainer(node)) { + const content = containerContentAsBlock(node, root); + if (content) { + blocks.push(content); + } + container.children.forEach((child) => pushFlattened(child, root)); + return; + } + blocks.push(nodeToBlock(node, root)); + }; + fragment.descendants((node) => { if (node.type.name === "blockContainer") { if (node.firstChild?.type.name === "blockGroup") { - // selection started within a block group - // in this case the fragment starts with: - // - // - // - // - // - // - // - // instead of: - // - // - // - // - // - // - // - // - // so we don't need to serialize this block, just descend into the children of the blockGroup + // Selection started within a block group — the fragment wraps the + // children in a blockContainer > blockGroup without a blockContent, + // so we descend into the blockGroup's children instead. return true; } } - if (node.type.name === "columnList" && node.childCount === 1) { - // column lists with a single column should be flattened (not the entire column list has been selected) - node.firstChild?.forEach((child) => { - blocks.push(nodeToBlock(child, node)); - }); - return false; - } - if (node.type.isInGroup("bnBlock")) { - blocks.push(nodeToBlock(node, node)); - // don't descend into children, as they're already included in the block returned by nodeToBlock + pushFlattened(node, node); return false; } return true; diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index fead006657..e445c4e9e5 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,5 +1,7 @@ import { Mark, Node, Slice } from "@tiptap/pm/model"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; +import { isContentContainerNode } from "../../schema/blocks/children.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { BlockSchema, @@ -430,7 +432,7 @@ export function nodeToBlock< const props: any = {}; for (const [attr, value] of Object.entries({ ...node.attrs, - ...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}), + ...(blockInfo.isWrappedBlock ? blockInfo.blockContent.node.attrs : {}), })) { const propSchema = blockSpec.propSchema; @@ -452,7 +454,7 @@ export function nodeToBlock< let content: Block["content"]; if (blockConfig.content === "inline") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } content = contentNodeToInlineContent( @@ -461,7 +463,7 @@ export function nodeToBlock< styleSchema, ); } else if (blockConfig.content === "table") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } content = contentNodeToTableContent( @@ -470,7 +472,7 @@ export function nodeToBlock< styleSchema, ); } else if (blockConfig.content === "plain") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } // Plain content is a single unstyled text item; an empty block is an @@ -533,6 +535,24 @@ export function docToBlocks< * * */ +/** + * The node holding a bnBlock's children when that node holds them directly: + * the container itself for a pure container, its generated `__children` node + * for a container that also has its own content. `undefined` for a + * `blockContainer`, whose children live in an optional `blockGroup`. + */ +function getChildrenHolder(node: Node): Node | undefined { + if (isContentContainerNode(node)) { + // The children live in the generated `__children` node, which is the last + // child. When a slice boundary cuts through the container's own `__content`, + // the `__children` node is absent from the slice — its last (and only) + // child is then the `__content` node, which holds no children of its own. + const lastChild = node.lastChild; + return lastChild && isContainerNode(lastChild.type) ? lastChild : undefined; + } + return isContainerNode(node.type) ? node : undefined; +} + export function prosemirrorSliceToSlicedBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -563,7 +583,9 @@ export function prosemirrorSliceToSlicedBlocks< blockCutAtStart: string | undefined; blockCutAtEnd: string | undefined; } { - if (node.type.name !== "blockGroup") { + // Both `blockGroup` and container nodes (columnList, column, callout, + // ...) hold bnBlock children directly, so both can be processed here. + if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) { throw new Error("unexpected"); } const blocks: Block[] = []; @@ -571,6 +593,68 @@ export function prosemirrorSliceToSlicedBlocks< let blockCutAtEnd: string | undefined; node.forEach((blockContainer, _offset, index) => { + const isFirstBlock = index === 0; + const isLastBlock = index === node.childCount - 1; + + const childrenHolder = getChildrenHolder(blockContainer); + if (childrenHolder) { + // A container child. When the slice boundary is open inside it, the + // selection covers part of its children — skip the container wrapper + // and splice in the included children (mirroring the + // nested-blockGroup descent below). When fully enclosed, convert it + // wholesale. + const openAtStart = isFirstBlock && openStart > 0; + const openAtEnd = isLastBlock && openEnd > 0; + + // A container that also has its own content keeps its children one + // node deeper, in its generated `__children` node. + const depthToChildren = childrenHolder === blockContainer ? 1 : 2; + + if (openAtStart || openAtEnd) { + const ret = processNode( + childrenHolder, + openAtStart ? Math.max(0, openStart - depthToChildren) : 0, + openAtEnd ? Math.max(0, openEnd - depthToChildren) : 0, + ); + if (openAtStart) { + blockCutAtStart = ret.blockCutAtStart; + } + if (openAtEnd) { + blockCutAtEnd = ret.blockCutAtEnd; + } + blocks.push(...ret.blocks); + return; + } + + blocks.push( + nodeToBlock(blockContainer, slice.content.firstChild!) as Block< + BSchema, + I, + S + >, + ); + return; + } + + if (isContentContainerNode(blockContainer)) { + // A content-bearing container whose `__children` node is absent from + // the slice: the boundary cut through its own `__content`, so it has no + // children to splice in. Convert it wholesale (with its cut content), + // recording the cut boundary so callers know the block was sliced. + const block = nodeToBlock( + blockContainer, + slice.content.firstChild!, + ) as Block; + if (isFirstBlock && openStart > 0) { + blockCutAtStart = block.id; + } + if (isLastBlock && openEnd > 0) { + blockCutAtEnd = block.id; + } + blocks.push(block); + return; + } + if (blockContainer.type.name !== "blockContainer") { throw new Error("unexpected"); } @@ -583,9 +667,6 @@ export function prosemirrorSliceToSlicedBlocks< ); } - const isFirstBlock = index === 0; - const isLastBlock = index === node.childCount - 1; - if (blockContainer.firstChild!.type.name === "blockGroup") { // this is the parent where a selection starts within one of its children, // e.g.: diff --git a/packages/core/src/api/pmUtil.ts b/packages/core/src/api/pmUtil.ts index 17ed2aa943..79461b75d1 100644 --- a/packages/core/src/api/pmUtil.ts +++ b/packages/core/src/api/pmUtil.ts @@ -2,6 +2,7 @@ import type { Node, NodeType, Schema } from "prosemirror-model"; import { Transform } from "prosemirror-transform"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js"; +import { blockTypeOfContainerContentNode } from "../schema/blocks/children.js"; import type { BlockSchema } from "../schema/blocks/types.js"; import type { InlineContentSchema } from "../schema/inlineContent/types.js"; import type { StyleSchema } from "../schema/styles/types.js"; @@ -67,7 +68,16 @@ export function isPlainContentNodeType( schema: Schema, nodeType: NodeType, ): boolean { - if (getBlockSchema(schema)[nodeType.name]?.content === "plain") { + const blockSchema = getBlockSchema(schema); + // A content-bearing container's content lives in a generated node, so it + // isn't a key in the block schema — resolve it back to the block it belongs + // to. + const blockType = + blockTypeOfContainerContentNode(nodeType.name) ?? nodeType.name; + + if ( + (blockSchema[nodeType.name] ?? blockSchema[blockType])?.content === "plain" + ) { return true; } diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts index 0b33335788..71f3ecaf35 100644 --- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts +++ b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts @@ -11,7 +11,7 @@ export const handleEnter = (editor: BlockNoteEditor) => { }; }); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index b268598218..5e52c8c76f 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -32,7 +32,7 @@ function calculateListItemIndex( // Fast path: previous sibling already in cache const blockInfo = getBlockInfo({ posBeforeNode: pos, node }); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } const prevBlock = tr.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore; @@ -80,7 +80,7 @@ function calculateListItemIndex( posBeforeNode: lastInChain.pos, node: lastInChain.node, }); - if (!lastInfo.isBlockContainer) { + if (!lastInfo.isWrappedBlock) { throw new Error("impossible"); } const predecessorNode = tr.doc.resolve(lastInfo.bnBlock.beforePos).nodeBefore; diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts index 12e558a453..578d3aae8b 100644 --- a/packages/core/src/blocks/utils/listItemEnterHandler.ts +++ b/packages/core/src/blocks/utils/listItemEnterHandler.ts @@ -14,7 +14,7 @@ export const handleEnter = ( }; }); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..e631edb881 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -7,6 +7,7 @@ import { } from "@tiptap/core"; import { type Command, type Transaction } from "@tiptap/pm/state"; import { Node, Schema } from "prosemirror-model"; +import type { BlockPlacement } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; import { @@ -37,6 +38,7 @@ import type { StyleSchema, StyleSpecs, } from "../schema/index.js"; +import { assertContainerSchemaInvariants } from "../schema/blocks/assertSchemaInvariants.js"; import "../style.css"; import { mergeCSSClasses } from "../util/browser.js"; import { EventEmitter } from "../util/EventEmitter.js"; @@ -558,6 +560,13 @@ export class BlockNoteEditor< tiptapOptions.parseOptions, ); + // `blockToNode` is lenient, and `createDocument` builds from JSON without + // validating — so without this the one path that never checks its result + // is the one that seeds the whole document. A container below its + // `children.min` would reach the editor and stay there, where the same + // blocks passed to `insertBlocks` would have been rejected. + doc.check(); + this._tiptapEditor = new TiptapEditor({ ...tiptapOptions, content: doc.toJSON(), @@ -572,6 +581,8 @@ export class BlockNoteEditor< this.pmSchema.cached.blockNoteEditor = this; + assertContainerSchemaInvariants(this.pmSchema); + this._tiptapEditor.on("mount", () => { this.headless = false; }); @@ -1051,13 +1062,14 @@ export class BlockNoteEditor< * error if the reference block could not be found. * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. - * @param placement Whether the blocks should be inserted just before, just after, or nested inside the - * `referenceBlock`. + * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next + * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children. Throws an error if + * the `referenceBlock` (or its parent, for `"before"`/`"after"`) doesn't accept the blocks there. */ public insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ) { return this._blockManager.insertBlocks( blocksToInsert, diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index f086444ecc..a33bfcab4b 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -1,4 +1,7 @@ -import { insertBlocks } from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; +import { + BlockPlacement, + insertBlocks, +} from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import { moveBlocksDown, moveBlocksUp, @@ -150,13 +153,13 @@ export class BlockManager< * error if the reference block could not be found. * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. - * @param placement Whether the blocks should be inserted just before, just after, or nested inside the - * `referenceBlock`. + * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next + * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children. */ public insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ) { return this.editor.transact((tr) => insertBlocks(tr, blocksToInsert, referenceBlock, placement), diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..83977b943d 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -39,6 +39,7 @@ import { UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; +import { isContainerType } from "../../../schema/blocks/children.js"; import type { BlockNoteEditor, BlockNoteEditorOptions, @@ -62,7 +63,16 @@ export function getDefaultTiptapExtensions( UniqueID.configure({ // everything from bnBlock group (nodes that represent a BlockNote block should have an id) - types: ["blockContainer", "columnList", "column"], + types: [ + "blockContainer", + // Container block specs whose PM node is itself in the `bnBlock` group + // (column, columnList, callout, etc.) — i.e. the bnBlock node IS the + // block, so the id lives on its attrs rather than on a wrapping + // blockContainer. + ...Object.entries(editor.schema.blockSpecs) + .filter(([, spec]) => isContainerType((spec as any).config)) + .map(([type]) => type), + ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, }), @@ -130,6 +140,16 @@ export function getDefaultTiptapExtensions( }), ] : []), + // Nodes the block's node depends on but which aren't blocks + // themselves (a content-bearing container's content & children nodes). + ...("extraNodes" in blockSpec.implementation + ? (blockSpec.implementation.extraNodes as Node[]).map((node) => + node.configure({ + editor: editor, + domAttributes: options.domAttributes, + }), + ) + : []), ]; }), createCopyToClipboardExtension(editor), diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 5cf6e74c1c..71167e8f5a 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -563,7 +563,7 @@ export class ExtensionManager { const blockInfo = getBlockInfoFromSelection(tr); if ( - !blockInfo.isBlockContainer || + !blockInfo.isWrappedBlock || this.editor.schema.blockSchema[blockInfo.blockNoteType] ?.content !== "inline" ) { diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index 4f0515df95..7b6b5b1d9e 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -213,9 +213,7 @@ function retypeLeadingParagraphForEmptyTarget( } const blockInfo = getBlockInfoFromSelection(view.state); - const target = blockInfo.isBlockContainer - ? blockInfo.blockContent.node - : null; + const target = blockInfo.isWrappedBlock ? blockInfo.blockContent.node : null; if ( !target || target.type.name === "paragraph" || @@ -275,7 +273,7 @@ function shouldApplyFix(fragment: Fragment, view: EditorView) { // for both paste and drop events. Drop events can potentially cause // issues as they don't always happen at the current selection. const blockInfo = getBlockInfoFromSelection(view.state); - if (blockInfo.isBlockContainer) { + if (blockInfo.isWrappedBlock) { const selectedBlockHasTableContent = blockInfo.blockContent.node.type.spec.content === "tableRow+"; diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index 9c7a2650fd..fabb450c84 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -11,6 +11,7 @@ import { StyledText, Styles, } from "../schema/index.js"; +import { isContainerType } from "../schema/blocks/children.js"; import type { BlockMapping, @@ -60,15 +61,35 @@ export abstract class Exporter< RS, TS, > { + // Stored with erased generics: a generically-typed property would change + // the class's variance in B/I/S and break mapping inference at subclass + // construction sites (the schema param was previously inference-only). + private readonly blockNoteSchema: BlockNoteSchema; + public constructor( - _schema: BlockNoteSchema, // only used for type inference + schema: BlockNoteSchema, protected readonly mappings: { blockMapping: BlockMapping; inlineContentMapping: InlineContentMapping; styleMapping: StyleMapping; }, public readonly options: ExporterOptions, - ) {} + ) { + this.blockNoteSchema = schema; + } + + /** + * Whether a block type is a container block (declares `children`, e.g. + * `columnList`, `column`, or a custom callout). Container mappings own the + * placement of their children — exporters must not append the children + * after the container's own output. + */ + public isContainerBlock(blockType: string): boolean { + const spec = (this.blockNoteSchema.blockSpecs as Record)[ + blockType + ]; + return !!spec && isContainerType(spec.config); + } /** * The strings this exporter renders into the produced document - the @@ -129,7 +150,9 @@ export abstract class Exporter< const mapping = this.mappings.blockMapping[block.type]; if (!mapping) { throw new Error( - `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`, + this.isContainerBlock(block.type) + ? `No mapping found for container block type "${block.type}" — container blocks require an explicit block mapping that places their children.` + : `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`, ); } return mapping(block, this, nestingLevel, numberedListIndex, children); diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index fddd2712e9..63dff6d346 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -20,8 +20,16 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + ContainerUIInfo, + getContainerUIInfo, +} from "../../api/blockManipulation/containers/containerUI.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; import { dragStart, unsetDragImage } from "./dragging.js"; +import { + getContainerChildAtCursor, + hasHorizontalContainerAncestor, +} from "./sideMenuContainerGeometry.js"; export type SideMenuState< BSchema extends BlockSchema, @@ -37,7 +45,8 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250; function getBlockFromCoords( view: EditorView, coords: { left: number; top: number }, - adjustForColumns = true, + containerUIInfo: ContainerUIInfo, + adjustForHorizontalContainers = true, ) { const elements = view.root.elementsFromPoint(coords.left, coords.top); @@ -46,21 +55,28 @@ function getBlockFromCoords( // probably a ui overlay like formatting toolbar etc continue; } - if (adjustForColumns) { - const column = element.closest("[data-node-type=columnList]"); - if (column) { - return getBlockFromCoords( - view, - { - // TODO can we do better than this? - left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself - top: coords.top, - }, - false, - ); - } + if ( + adjustForHorizontalContainers && + containerUIInfo.containerSelector && + // Inside a container with side-by-side children (e.g. a columnList), + // the x position must be offset — the hovered coordinates land in the + // side menu's own gutter, which belongs to a different child. The + // horizontal container can be any ancestor (the element may sit inside + // a vertical child of it, like a block inside a column). + hasHorizontalContainerAncestor(element, containerUIInfo) + ) { + return getBlockFromCoords( + view, + { + // TODO can we do better than this? + left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself + top: coords.top, + }, + containerUIInfo, + false, + ); } - return getDraggableBlockFromElement(element, view); + return getDraggableBlockFromElement(element, view, containerUIInfo); } return undefined; } @@ -71,6 +87,7 @@ function getBlockFromMousePos( y: number; }, view: EditorView, + containerUIInfo: ContainerUIInfo, ): { node: HTMLElement; id: string } | undefined { // Editor itself may have padding or other styling which affects // size/position, so we get the boundingRect of the first child (i.e. the @@ -94,7 +111,7 @@ function getBlockFromMousePos( top: mousePos.y, }; - const referenceBlock = getBlockFromCoords(view, coords); + const referenceBlock = getBlockFromCoords(view, coords, containerUIInfo); if (!referenceBlock) { // could not find the reference block @@ -109,15 +126,26 @@ function getBlockFromMousePos( * ``` * Hovering at position x (left edge of BlockB) would return BlockA. * Instead, we check at position y (right edge of BlockA) to correctly identify BlockB. + * `elementsFromPoint` returns the deepest element at a point, so this single + * probe descends through any depth of regular nesting. + * + * When the reference block is a (draggable) container block, the probe is + * aimed at the direct child under the cursor instead of the container + * itself — the container's own padding can exceed the probe inset, which + * would keep resolving the container even though the cursor is aligned with + * one of its children (making the child's menu jump away as the cursor + * moves towards it). */ - const referenceBlocksBoundingBox = - referenceBlock.node.getBoundingClientRect(); + const probeTarget = + getContainerChildAtCursor(referenceBlock.node, mousePos, containerUIInfo) ?? + referenceBlock.node; return getBlockFromCoords( view, { - left: referenceBlocksBoundingBox.right - 10, + left: probeTarget.getBoundingClientRect().right - 10, top: mousePos.y, }, + containerUIInfo, false, ); } @@ -214,7 +242,11 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const block = getBlockFromMousePos( + this.mousePos, + this.pmView, + getContainerUIInfo(this.editor), + ); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -240,7 +272,15 @@ export class SideMenuView< // Shows or updates elements. if (this.editor.isEditable) { const blockContentBoundingBox = block.node.getBoundingClientRect(); - const column = block.node.closest("[data-node-type=column]"); + // The closest container ancestor (a column, callout, ...) — excluding + // the hovered block itself, which may be a draggable container. Blocks + // inside a container anchor the side menu to the container's block + // area rather than the editor's left edge, which would put the menu + // over unrelated content (or off-screen inside columns). + const containerUIInfo = getContainerUIInfo(this.editor); + const container = containerUIInfo.containerSelector + ? block.node.parentElement?.closest(containerUIInfo.containerSelector) + : undefined; const sideMenuBlock = this.editor.getBlock( this.hoveredBlock!.getAttribute("data-id")!, ); @@ -255,12 +295,16 @@ export class SideMenuView< this.state = { show: true, referencePos: new DOMRect( - column - ? // We take the first child as column elements have some default - // padding. This is a little weird since this child element will - // be the first block, but since it's always non-nested and we - // only take the x coordinate, it's ok. - column.firstElementChild!.getBoundingClientRect().x + container + ? // We anchor to the container's first block element (rather + // than the container itself, which may have padding or its own + // chrome around the block area). This is a little weird since + // this element is the first block, but since it's always + // non-nested and we only take the x coordinate, it's ok. + ( + container.querySelector('[data-node-type="blockOuter"]') ?? + container.firstElementChild! + ).getBoundingClientRect().x : ( this.pmView.dom.firstChild as HTMLElement ).getBoundingClientRect().x, diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts new file mode 100644 index 0000000000..5bb52a71c9 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts @@ -0,0 +1,267 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; +import { + getContainerChildAtCursor, + getDirectChildBlocks, + hasHorizontalContainerAncestor, + isHorizontalContainer, +} from "./sideMenuContainerGeometry.js"; + +// The DOM half of the side-menu container geometry: the `querySelectorAll` / +// `closest` walks that find a container's direct child blocks, and the +// live-layout claim the module exists for — that a container whose children +// *happen* to sit side-by-side is recognised as horizontal without declaring +// anything. +// +// Everything here is attached to the real document and laid out by the real +// engine; nothing stubs `getBoundingClientRect`. The counterpart node suite +// (`sideMenuContainerGeometry.test.ts`) covers the arithmetic these adapters +// feed. A column list inside a real editor is covered end-to-end by +// `tests/src/end-to-end/multicolumn/multicolumn.test.tsx`. + +let mounted: HTMLElement[] = []; + +afterEach(() => { + mounted.forEach((el) => el.remove()); + mounted = []; +}); + +/** Attaches a tree to the document so the browser actually lays it out. */ +function mount(el: T): T { + document.body.appendChild(el); + mounted.push(el); + return el; +} + +function el(nodeType: string): HTMLElement { + const node = document.createElement("div"); + node.setAttribute("data-node-type", nodeType); + return node; +} + +/** The `blockOuter > blockContainer` chrome BlockNote renders around every + * regular block, with real text in it so it has a real height. */ +function regularChild(text = "block"): { + outer: HTMLElement; + blockContainer: HTMLElement; +} { + const outer = el("blockOuter"); + const blockContainer = el("blockContainer"); + blockContainer.textContent = text; + outer.append(blockContainer); + return { outer, blockContainer }; +} + +function uiInfo(containerTypes: string[]): ContainerUIInfo { + const set = new Set(containerTypes); + return { + containerTypes: set, + draggableContainerTypes: set, + nonDraggableBlockTypes: new Set(), + containerSelector: containerTypes.length + ? containerTypes.map((t) => `[data-node-type="${t}"]`).join(",") + : null, + }; +} + +/** + * A column list laid out the way the real one is: a flex row of two columns, + * each holding one block. Nothing declares "horizontal" — the browser puts the + * columns side by side and the module has to notice. + */ +function buildColumnList() { + const info = uiInfo(["columnList", "column"]); + + const columnList = el("columnList"); + columnList.style.display = "flex"; + columnList.style.width = "400px"; + + const columnA = el("column"); + const columnB = el("column"); + for (const column of [columnA, columnB]) { + column.style.flex = "1"; + } + + const childA = regularChild("A"); + const childB = regularChild("B"); + columnA.append(childA.outer); + columnB.append(childB.outer); + columnList.append(columnA, columnB); + mount(columnList); + + return { info, columnList, columnA, columnB, childA, childB }; +} + +/** A callout: an ordinary block-flow container, so its children stack. */ +function buildVerticalContainer() { + const info = uiInfo(["callout"]); + + const callout = el("callout"); + callout.style.width = "400px"; + const first = regularChild("first"); + const second = regularChild("second"); + callout.append(first.outer, second.outer); + mount(callout); + + return { info, callout, first, second }; +} + +describe("getDirectChildBlocks", () => { + it("returns direct child blocks, skipping nested grandchildren", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + + // The blocks inside each column must not come back as the list's own + // children — the `closest` check is what stops the walk one level down. + expect(getDirectChildBlocks(columnList, info)).toEqual([columnA, columnB]); + }); + + it("sees through blockOuter wrappers to the blockContainer child", () => { + const { info, columnA, childA } = buildColumnList(); + + // The column's own direct child is the wrapped blockContainer, not the + // blockOuter chrome (which isn't a block in the selector's sense). + expect(getDirectChildBlocks(columnA, info)).toEqual([ + childA.blockContainer, + ]); + }); + + it("returns nothing for a container with no block children", () => { + const info = uiInfo(["callout"]); + const empty = mount(el("callout")); + + expect(getDirectChildBlocks(empty, info)).toEqual([]); + }); +}); + +describe("isHorizontalContainer", () => { + it("recognises a real flex row as horizontal", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + + // The claim the module exists for, asserted against real layout: nothing + // declares the column list horizontal, and no rect is stubbed. + expect(isHorizontalContainer(columnList, info)).toBe(true); + + // Stated as geometry too, so a failure says whether the layout or the + // detection is what broke. + const a = columnA.getBoundingClientRect(); + const b = columnB.getBoundingClientRect(); + expect(a.width).toBeGreaterThan(0); + expect(b.left).toBeGreaterThanOrEqual(a.right - 1); + expect(a.top).toBe(b.top); + }); + + it("is false for a container whose children stack", () => { + const { info, callout, first, second } = buildVerticalContainer(); + + expect(isHorizontalContainer(callout, info)).toBe(false); + + const a = first.blockContainer.getBoundingClientRect(); + const b = second.blockContainer.getBoundingClientRect(); + expect(a.height).toBeGreaterThan(0); + expect(b.top).toBeGreaterThanOrEqual(a.bottom); + }); + + it("is false for a column holding a single block", () => { + const { info, columnA } = buildColumnList(); + + expect(isHorizontalContainer(columnA, info)).toBe(false); + }); +}); + +describe("hasHorizontalContainerAncestor", () => { + it("is true for a block nested inside a column of a column list", () => { + const { info, childA } = buildColumnList(); + + // The block sits inside a (vertical) column, whose parent column list is + // the horizontal one — the walk must climb past the column. + expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe( + true, + ); + }); + + it("is false for a block inside a purely vertical container", () => { + const { info, first } = buildVerticalContainer(); + + expect(hasHorizontalContainerAncestor(first.blockContainer, info)).toBe( + false, + ); + }); + + it("is false when there is no container ancestor", () => { + const info = uiInfo(["columnList", "column"]); + const loose = regularChild(); + mount(loose.outer); + + expect(hasHorizontalContainerAncestor(loose.blockContainer, info)).toBe( + false, + ); + }); + + it("is false when the schema declares no containers", () => { + const { childA } = buildColumnList(); + + expect( + hasHorizontalContainerAncestor(childA.blockContainer, uiInfo([])), + ).toBe(false); + }); +}); + +describe("getContainerChildAtCursor", () => { + it("returns undefined for a non-container element", () => { + const { info, childA } = buildColumnList(); + + expect( + getContainerChildAtCursor(childA.blockContainer, { x: 10, y: 10 }, info), + ).toBeUndefined(); + }); + + it("resolves the hovered column of a real row", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + const b = columnB.getBoundingClientRect(); + + expect( + getContainerChildAtCursor( + columnList, + { x: b.left + b.width / 2, y: b.top + b.height / 2 }, + info, + ), + ).toBe(columnB); + + const a = columnA.getBoundingClientRect(); + expect( + getContainerChildAtCursor( + columnList, + { x: a.left + a.width / 2, y: a.top + a.height / 2 }, + info, + ), + ).toBe(columnA); + }); + + it("falls back to the block on that row when x is in the gutter", () => { + const { info, callout, first } = buildVerticalContainer(); + const rect = first.blockContainer.getBoundingClientRect(); + + // The cursor's y is in the first block's band but its x is left of the + // container entirely — where the side menu renders. + expect( + getContainerChildAtCursor( + callout, + { x: rect.left - 20, y: rect.top + rect.height / 2 }, + info, + ), + ).toBe(first.blockContainer); + }); + + it("returns undefined when the cursor is below all children", () => { + const { info, callout } = buildVerticalContainer(); + + expect( + getContainerChildAtCursor( + callout, + { x: 10, y: callout.getBoundingClientRect().bottom + 500 }, + info, + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts new file mode 100644 index 0000000000..9cbdfcdbd8 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment node +import { describe, expect, it } from "vite-plus/test"; + +import { + rectIndexAtCursor, + rectsAreSideBySide, + type BlockRect, +} from "./sideMenuContainerGeometry.js"; + +// The arithmetic half of the side-menu container geometry: pure functions over +// rects, so there is nothing to stub and no DOM to build. These used to be +// tested through the DOM adapters with `getBoundingClientRect` monkey-patched +// onto detached elements — which faked the one input the module exists to read. +// The adapters (and the claim that a real column list really does lay its +// children out side-by-side) are covered against real layout in +// `sideMenuContainerGeometry.browser.test.ts`. + +const rect = ( + top: number, + bottom: number, + left: number, + right: number, +): BlockRect => ({ top, bottom, left, right }); + +// Two columns of a column list: same vertical band, adjacent horizontally. +const SIDE_BY_SIDE = [rect(0, 100, 0, 100), rect(0, 100, 100, 200)]; +// Two blocks of a callout: same horizontal band, stacked vertically with a gap +// between them (the abutting, gap-free case is its own test below). +const STACKED = [rect(0, 40, 0, 200), rect(50, 90, 0, 200)]; + +describe("rectsAreSideBySide", () => { + it("is true when two rects overlap vertically", () => { + expect(rectsAreSideBySide(SIDE_BY_SIDE)).toBe(true); + }); + + it("is false when rects are stacked", () => { + expect(rectsAreSideBySide(STACKED)).toBe(false); + }); + + it("is false for a single rect", () => { + expect(rectsAreSideBySide([rect(0, 100, 0, 100)])).toBe(false); + }); + + it("is false for no rects at all", () => { + expect(rectsAreSideBySide([])).toBe(false); + }); + + it("treats abutting (non-overlapping) rects as stacked", () => { + // The second rect's top exactly meets the first's bottom — a stack with no + // gap must not be misread as a row. + expect( + rectsAreSideBySide([rect(0, 40, 0, 200), rect(40, 80, 0, 200)]), + ).toBe(false); + }); + + it("finds an overlapping pair that isn't the first two", () => { + // The loop is over every pair, not just neighbours: a column list whose + // first two children happen to be stacked is still a row. + expect( + rectsAreSideBySide([ + rect(0, 40, 0, 100), + rect(40, 80, 0, 100), + rect(40, 80, 100, 200), + ]), + ).toBe(true); + }); + + it("counts even a one-pixel vertical overlap", () => { + expect( + rectsAreSideBySide([rect(0, 41, 0, 100), rect(40, 80, 0, 100)]), + ).toBe(true); + }); +}); + +describe("rectIndexAtCursor", () => { + it("returns the rect whose x range contains the cursor (side-by-side)", () => { + // Both rects share the y range, so only x distinguishes them. x=150 lands + // in the second: the vertical-only fallback recorded for the first must not + // win over an x match found later in the list. This is what makes hovering + // the second column of a row resolve to it rather than to its neighbour. + expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 150, y: 50 })).toBe(1); + }); + + it("prefers the x match over the first vertical match", () => { + // The mirror of the above: x=10 is within the first rect. + expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 10, y: 50 })).toBe(0); + }); + + it("falls back to the first vertical match when x is in the gutter", () => { + // The cursor's y is in the first block's band but its x is left of it (the + // side-menu gutter). The first vertical match wins. + expect(rectIndexAtCursor(STACKED, { x: -20, y: 20 })).toBe(0); + }); + + it("returns undefined when the cursor is below every rect", () => { + expect(rectIndexAtCursor(STACKED, { x: 10, y: 999 })).toBeUndefined(); + }); + + it("returns undefined when the cursor is above every rect", () => { + expect(rectIndexAtCursor(STACKED, { x: 10, y: -999 })).toBeUndefined(); + }); + + it("returns undefined for no rects at all", () => { + expect(rectIndexAtCursor([], { x: 10, y: 10 })).toBeUndefined(); + }); + + it("includes the rect edges", () => { + const single = [rect(0, 40, 0, 200)]; + expect(rectIndexAtCursor(single, { x: 0, y: 0 })).toBe(0); + expect(rectIndexAtCursor(single, { x: 200, y: 40 })).toBe(0); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts new file mode 100644 index 0000000000..f54e540ba6 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts @@ -0,0 +1,164 @@ +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; + +/** + * DOM hit-testing helpers the side menu uses to resolve which block a cursor + * is over inside container blocks. They read live layout geometry + * (`getBoundingClientRect`) rather than any declared layout flag, so container + * blocks don't have to describe their own layout — a container whose children + * happen to sit side-by-side (like a column list) is detected as such. + */ + +/** + * The selector matching any block that can be a direct child of a container: + * a regular block (`blockContainer`) or a nested container element. + */ +function containerChildSelector(containerUIInfo: ContainerUIInfo): string { + return containerUIInfo.containerSelector + ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` + : `[data-node-type="blockContainer"]`; +} + +/** + * The direct child block elements of a container element (in the block + * sense): the closest block element above each match must be the container + * itself. Matches `blockContainer`s (regular children) and nested container + * elements. + */ +export function getDirectChildBlocks( + container: Element, + containerUIInfo: ContainerUIInfo, +): Element[] { + const childSelector = containerChildSelector(containerUIInfo); + + const children: Element[] = []; + for (const child of container.querySelectorAll(childSelector)) { + if (child.parentElement?.closest(childSelector) === container) { + children.push(child); + } + } + return children; +} + +/** + * The part of a `DOMRect` these helpers actually read. Splitting the arithmetic + * out over this shape keeps it testable without a layout engine — see + * `sideMenuContainerGeometry.test.ts` — while the live-geometry claim itself is + * covered against real layout in `sideMenuContainerGeometry.browser.test.ts`. + */ +export type BlockRect = { + top: number; + bottom: number; + left: number; + right: number; +}; + +/** + * Whether a set of sibling rects is laid out side-by-side rather than stacked: + * two of them overlapping along the vertical axis can only be sitting next to + * each other horizontally. + */ +export function rectsAreSideBySide(rects: BlockRect[]): boolean { + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + if (rects[i].top < rects[j].bottom && rects[j].top < rects[i].bottom) { + return true; + } + } + } + return false; +} + +/** + * The index of the rect the cursor sits in, or `undefined` for none. A rect + * containing the cursor's x position wins over the first one merely containing + * its y position (which is how a side-by-side row is disambiguated); the + * y-only match is the fallback, so hovering a container's gutter still + * resolves to the child on that row. + */ +export function rectIndexAtCursor( + rects: BlockRect[], + mousePos: { x: number; y: number }, +): number | undefined { + let verticalMatch: number | undefined = undefined; + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if (mousePos.y < rect.top || mousePos.y > rect.bottom) { + continue; + } + if (mousePos.x >= rect.left && mousePos.x <= rect.right) { + return i; + } + verticalMatch = verticalMatch ?? i; + } + return verticalMatch; +} + +/** + * Whether a container element lays its direct child blocks out side-by-side + * (like a column list), detected from geometry rather than a declared flag. + * The DOM adapter over {@link rectsAreSideBySide}. + */ +export function isHorizontalContainer( + container: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + return rectsAreSideBySide( + getDirectChildBlocks(container, containerUIInfo).map((child) => + child.getBoundingClientRect(), + ), + ); +} + +/** + * Whether `element` sits inside a horizontal container at any depth (e.g. a + * block inside a column of a columnList — the columnList, not the column, is + * the horizontal one). Walks up the container ancestor chain. + */ +export function hasHorizontalContainerAncestor( + element: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + if (!containerUIInfo.containerSelector) { + return false; + } + let container = element.closest(containerUIInfo.containerSelector); + while (container) { + if (isHorizontalContainer(container, containerUIInfo)) { + return true; + } + container = + container.parentElement?.closest(containerUIInfo.containerSelector) ?? + null; + } + return false; +} + +/** + * If `element` is a container block's element, finds its direct child block + * whose vertical range contains the cursor. Hovering a container's own + * chrome (padding, a title bar, the side-menu gutter) next to a child + * should attach the side menu to that child — mirroring how hovering a + * parent block's gutter next to a nested block attaches to the nested + * block. The container's own menu stays reachable on rows occupied only by + * its chrome. When children sit side-by-side, a child containing the + * cursor's x position wins over the first vertical match. + * + * The DOM adapter over {@link rectIndexAtCursor}. + */ +export function getContainerChildAtCursor( + element: Element, + mousePos: { x: number; y: number }, + containerUIInfo: ContainerUIInfo, +): Element | undefined { + const nodeType = element.getAttribute("data-node-type"); + if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) { + return undefined; + } + + const children = getDirectChildBlocks(element, containerUIInfo); + const index = rectIndexAtCursor( + children.map((child) => child.getBoundingClientRect()), + mousePos, + ); + return index === undefined ? undefined : children[index]; +} diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts b/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts new file mode 100644 index 0000000000..97c775267d --- /dev/null +++ b/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getDraggableBlockFromElement } from "./getDraggableBlockFromElement.js"; + +// These are pure DOM walks (`closest`/`querySelector` over the block chrome), +// so we build detached trees rather than booting an editor. Only `view.dom` is +// read, as the stop condition for the upward walk. No layout is involved, but +// the unit under test *is* the DOM API surface, so it runs against a real +// browser engine rather than jsdom's re-implementation of it. + +/** Builds the `blockOuter > blockContainer > blockContent` chrome BlockNote + * renders around every regular block. */ +function regularBlock( + id: string, + contentType: string, +): { outer: HTMLElement; blockContainer: HTMLElement; content: HTMLElement } { + const outer = document.createElement("div"); + outer.setAttribute("data-node-type", "blockOuter"); + + const blockContainer = document.createElement("div"); + blockContainer.setAttribute("data-node-type", "blockContainer"); + blockContainer.setAttribute("data-id", id); + + const content = document.createElement("div"); + content.setAttribute("data-content-type", contentType); + + blockContainer.append(content); + outer.append(blockContainer); + return { outer, blockContainer, content }; +} + +/** Nests `child` under `parent` in a `blockGroup`, as list nesting does. */ +function nest(parent: HTMLElement, child: HTMLElement) { + const group = document.createElement("div"); + group.setAttribute("data-node-type", "blockGroup"); + group.append(child); + parent.append(group); +} + +function viewWith(root: HTMLElement) { + const dom = document.createElement("div"); + dom.append(root); + return { dom }; +} + +describe("getDraggableBlockFromElement", () => { + it("returns the block container for a regular block", () => { + const { outer, blockContainer, content } = regularBlock("a", "paragraph"); + + expect(getDraggableBlockFromElement(content, viewWith(outer))).toEqual({ + node: blockContainer, + id: "a", + }); + }); + + it("skips a block whose type opts out of dragging", () => { + const { outer, content } = regularBlock("a", "lockedBlock"); + + expect( + getDraggableBlockFromElement(content, viewWith(outer), { + nonDraggableBlockTypes: new Set(["lockedBlock"]), + }), + ).toBeUndefined(); + }); + + it("falls through to the nearest draggable ancestor", () => { + const parent = regularBlock("parent", "paragraph"); + const child = regularBlock("child", "lockedBlock"); + nest(parent.blockContainer, child.outer); + + // Dragging from inside the locked child should hand back the parent's + // handle rather than no handle at all. + expect( + getDraggableBlockFromElement(child.content, viewWith(parent.outer), { + nonDraggableBlockTypes: new Set(["lockedBlock"]), + }), + ).toEqual({ node: parent.blockContainer, id: "parent" }); + }); + + it("reads the block's own content type, not a nested block's", () => { + const parent = regularBlock("parent", "lockedBlock"); + const child = regularBlock("child", "paragraph"); + nest(parent.blockContainer, child.outer); + + // `parent`'s own content element precedes the nested `blockGroup`, so the + // first `[data-content-type]` match inside it must be "lockedBlock". + expect( + getDraggableBlockFromElement(parent.content, viewWith(parent.outer), { + nonDraggableBlockTypes: new Set(["lockedBlock"]), + }), + ).toBeUndefined(); + }); + + it("returns a container block only when its type is draggable", () => { + const column = document.createElement("div"); + column.setAttribute("data-node-type", "column"); + column.setAttribute("data-id", "col"); + + expect( + getDraggableBlockFromElement(column, viewWith(column), { + draggableContainerTypes: new Set(["columnList"]), + }), + ).toBeUndefined(); + + expect( + getDraggableBlockFromElement(column, viewWith(column), { + draggableContainerTypes: new Set(["column"]), + }), + ).toEqual({ node: column, id: "col" }); + }); +}); diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts index abc6bd2906..188f3d6503 100644 --- a/packages/core/src/extensions/getDraggableBlockFromElement.ts +++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts @@ -1,18 +1,59 @@ import { EditorView } from "prosemirror-view"; +const EMPTY_SET: ReadonlySet = new Set(); + +/** + * Walks up from `element` to the closest element that can host a side-menu + * drag handle. Both sets are derived from each spec's `meta.draggable` (see + * `getContainerUIInfo`); a block that opts out is skipped, so the handle falls + * through to the nearest draggable ancestor rather than disappearing. + */ export function getDraggableBlockFromElement( element: Element, - view: EditorView, + // Only `dom` is read — the stop condition for the upward walk. + view: Pick, + types: { + draggableContainerTypes?: ReadonlySet; + nonDraggableBlockTypes?: ReadonlySet; + } = {}, ) { + const draggableContainerTypes = types.draggableContainerTypes ?? EMPTY_SET; + const nonDraggableBlockTypes = types.nonDraggableBlockTypes ?? EMPTY_SET; + + const isDraggable = (el: Element) => { + const nodeType = el.getAttribute?.("data-node-type"); + + if (nodeType === "blockContainer") { + if (nonDraggableBlockTypes.size === 0) { + return true; + } + // Every regular block shares the `blockContainer` node, so its actual + // block type only shows up on its content element. That element comes + // before any nested `blockGroup`, so the first match in document order + // is this block's own content rather than a descendant's. + const contentType = el + .querySelector("[data-content-type]") + ?.getAttribute("data-content-type"); + + return !contentType || !nonDraggableBlockTypes.has(contentType); + } + + return ( + nodeType !== null && + nodeType !== undefined && + draggableContainerTypes.has(nodeType) + ); + }; + while ( element && element.parentElement && element.parentElement !== view.dom && - element.getAttribute?.("data-node-type") !== "blockContainer" + !isDraggable(element) ) { element = element.parentElement; } - if (element.getAttribute?.("data-node-type") !== "blockContainer") { + if (!isDraggable(element)) { return undefined; } return { node: element as HTMLElement, id: element.getAttribute("data-id")! }; diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 4d1758094a..b50d57aef9 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -8,13 +8,28 @@ import { getParentBlockInfo, getPrevBlockInfo, mergeBlocksCommand, + mergeIntoContainerContent, } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; import { liftItem, nestBlock, unnestBlock, } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; -import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +import { + fixContainersById, + isContainerNode, +} from "../../../api/blockManipulation/containers/fixContainer.js"; +import { + ascendToInsertablePos, + descendToLastInsertionPos, + getAncestorContainers, + getFirstLeafBlock, +} from "../../../api/blockManipulation/containers/containerNav.js"; +import { + getChildrenConfig, + isContentContainerNode, + resolveChildren, +} from "../../../schema/blocks/children.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { @@ -45,7 +60,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -69,7 +84,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { blockContent } = blockInfo; @@ -92,7 +107,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -106,7 +121,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ // return early here. if ( !prevBlockInfo || - !prevBlockInfo.isBlockContainer || + !prevBlockInfo.isWrappedBlock || prevBlockInfo.blockContent.node.type.spec.content !== "inline*" ) { return false; @@ -127,12 +142,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the previous block is a columnList, moves the current block to - // the end of the last column in it. + // If the previous block is a container (e.g. a columnList or a + // callout), moves the current block to its deepest trailing insertion + // slot — descending through nested containers, e.g. to the end of the + // last column. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -146,21 +163,27 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!prevBlockInfo || prevBlockInfo.isBlockContainer) { + if (!prevBlockInfo || prevBlockInfo.isWrappedBlock) { return false; } - if (dispatch) { - const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1; - const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1); + const insertionPos = descendToLastInsertionPos( + prevBlockInfo.bnBlock.node, + prevBlockInfo.bnBlock.beforePos, + state.schema.nodes["blockContainer"], + ); + if (insertionPos === null) { + return false; + } + if (dispatch) { tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node); + tr.insert(insertionPos, blockInfo.bnBlock.node); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)), + TextSelection.near(tr.doc.resolve(insertionPos + 1)), ); return true; @@ -168,13 +191,55 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is the first in a column, moves it to the end of the - // previous column. If there is no previous column, moves it above the - // columnList. + // If the block is the first child of a container that has its own + // content, merges it into that content — the mirror of the Delete + // case. A *pure* container has nothing to merge into, so it falls + // through to the "move it out" branch below, as before. + () => + commands.command(({ state, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isWrappedBlock) { + return false; + } + + const selectionAtBlockStart = + state.selection.from === blockInfo.blockContent.beforePos + 1; + if (!selectionAtBlockStart || !state.selection.empty) { + return false; + } + + // Only the container's first child. + if (state.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore) { + return false; + } + + const parentInfo = getParentBlockInfo( + state.doc, + blockInfo.bnBlock.beforePos, + ); + if ( + !parentInfo || + !isContentContainerNode(parentInfo.bnBlock.node) + ) { + return false; + } + + return mergeIntoContainerContent( + state, + dispatch, + parentInfo, + blockInfo, + ); + }), + // If the block is the first in a container (e.g. a column or a + // callout), moves it out: to the end of the previous sibling + // container if there is one (e.g. the previous column), otherwise to + // just before the closest enclosing boundary that accepts it (e.g. + // above the columnList / callout). () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -192,32 +257,55 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { return false; } - const $blockPos = tr.doc.resolve(blockInfo.bnBlock.beforePos); - const $columnPos = tr.doc.resolve($blockPos.before()); - const columnListPos = $columnPos.before(); + const blockContainerType = state.schema.nodes["blockContainer"]; + const containerBeforePos = $pos.before(); + const $containerPos = tr.doc.resolve(containerBeforePos); + + // A previous sibling inside an enclosing container (e.g. the + // previous column) is a target to descend into. A sibling at a + // regular block position is not — there the block moves out to + // before the container instead. + const prevSibling = + isContainerNode($containerPos.node().type) && + $containerPos.nodeBefore && + isContainerNode($containerPos.nodeBefore.type) + ? $containerPos.nodeBefore + : null; + + const insertionPos = prevSibling + ? descendToLastInsertionPos( + prevSibling, + containerBeforePos - prevSibling.nodeSize, + blockContainerType, + ) + : ascendToInsertablePos( + tr.doc, + containerBeforePos, + blockContainerType, + ); + if (insertionPos === null) { + return false; + } if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - fixColumnList(tr, columnListPos); - - if ($columnPos.pos === columnListPos + 1) { - tr.insert(columnListPos, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve(columnListPos)), - ); - } else { - tr.insert($columnPos.pos - 1, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve($columnPos.pos)), - ); - } + tr.insert(insertionPos, blockInfo.bnBlock.node); + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); } return true; @@ -227,7 +315,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -247,12 +335,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, prevBlockInfo, ); - if (!bottomNestedPrevBlockInfo.isBlockContainer) { + if (!bottomNestedPrevBlockInfo.isWrappedBlock) { return false; } if ( !bottomNestedPrevBlockInfo || - !bottomNestedPrevBlockInfo.isBlockContainer + !bottomNestedPrevBlockInfo.isWrappedBlock ) { return false; } @@ -313,7 +401,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -332,7 +420,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ prevBlockInfo, ); - if (!bottomBlock.isBlockContainer) { + if (!bottomBlock.isWrappedBlock) { return false; } @@ -375,11 +463,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer || !blockInfo.childContainer) { + if (!blockInfo.isWrappedBlock || !blockInfo.childContainer) { return false; } const { blockContent, childContainer } = blockInfo; + // A container allowed to hold no children still has a child + // container node, but no first child to pull anything out of. + if (childContainer.node.childCount === 0) { + return false; + } + const selectionAtBlockEnd = state.selection.from === blockContent.afterPos - 1; const selectionEmpty = state.selection.empty; @@ -387,7 +481,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ const firstChildBlockInfo = getBlockInfoFromResolvedPos( state.doc.resolve(childContainer.beforePos + 1), ); - if (!firstChildBlockInfo.isBlockContainer) { + if (!firstChildBlockInfo.isWrappedBlock) { return false; } @@ -408,8 +502,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ Fragment.empty, ) .deleteRange( - // Deletes whole child container if there's only one child. - childContainer.node.childCount === 1 + // Deletes whole child container if there's only one child + // — but a container with its own content always has one + // (its children node is part of its content expression), + // so there only the child itself goes. + childContainer.node.childCount === 1 && + !isContentContainerNode(blockInfo.bnBlock.node) ? { from: childContainer.beforePos, to: childContainer.afterPos, @@ -440,7 +538,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -449,7 +547,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -468,12 +566,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the next block is a columnList, moves the first block from its - // first column to after the current block. + // If the next block is a container (e.g. a columnList or a callout), + // moves its first leaf block out, to after the current block. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -487,22 +585,32 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || nextBlockInfo.isWrappedBlock) { + return false; + } + + const firstLeaf = getFirstLeafBlock( + nextBlockInfo.bnBlock.node, + nextBlockInfo.bnBlock.beforePos, + ); + if (!firstLeaf) { return false; } if (dispatch) { - const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1; - const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); + const containersToFix = getAncestorContainers( + tr.doc, + firstLeaf.beforePos, + ); tr.delete( - $blockBeforePos.pos, - $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, + firstLeaf.beforePos, + firstLeaf.beforePos + firstLeaf.node.nodeSize, ); - fixColumnList(tr, nextBlockInfo.bnBlock.beforePos); - tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!); + tr.insert(blockInfo.bnBlock.afterPos, firstLeaf.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), + TextSelection.near(tr.doc.resolve(firstLeaf.beforePos)), ); return true; @@ -510,13 +618,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is the last in a column, moves it to the start of the - // next column. If there is no next column, moves it below the - // columnList. + // If the block is the last in a container (e.g. a column or a + // callout), moves the next block — the first leaf of the next sibling + // container, or the block following the enclosing containers — to + // after it. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -534,36 +643,49 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { return false; } - const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos); - const $columnEndPos = tr.doc.resolve($blockEndPos.after()); - const columnListEndPos = $columnEndPos.after(); + // Climbs out of the containers the block is the last child of, + // to the first position with a following node. + let $boundary = $pos; + while ( + $boundary.nodeAfter === null && + $boundary.depth > 0 && + isContainerNode($boundary.node().type) + ) { + $boundary = tr.doc.resolve($boundary.after()); + } + + const nextNode = $boundary.nodeAfter; + if (!nextNode) { + return false; + } + + // The block to pull in: the next node itself or — when it's a + // container — its first leaf block. + const target = isContainerNode(nextNode.type) + ? getFirstLeafBlock(nextNode, $boundary.pos) + : { node: nextNode, beforePos: $boundary.pos }; + if (!target) { + return false; + } if (dispatch) { - // Position before first block in next column, or first block - // after columnList if there is no next column. - const nextBlockBeforePos = - $columnEndPos.pos === columnListEndPos - 1 - ? columnListEndPos - : $columnEndPos.pos + 1; - const nextBlockInfo = getBlockInfoFromResolvedPos( - tr.doc.resolve(nextBlockBeforePos), + const containersToFix = getAncestorContainers( + tr.doc, + target.beforePos, ); tr.delete( - nextBlockInfo.bnBlock.beforePos, - nextBlockInfo.bnBlock.afterPos, - ); - fixColumnList( - tr, - columnListEndPos - $columnEndPos.node().nodeSize, + target.beforePos, + target.beforePos + target.node.nodeSize, ); - tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node); + tr.insert(blockInfo.bnBlock.afterPos, target.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), + TextSelection.near(tr.doc.resolve(target.beforePos)), ); } @@ -577,7 +699,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { blockContent } = blockInfo; @@ -611,7 +733,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -653,7 +775,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -666,7 +788,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -715,7 +837,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -730,7 +852,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!nextBlockInfo) { return false; } - if (!nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo.isWrappedBlock) { return false; } @@ -770,7 +892,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -859,12 +981,128 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // Enter inside the content of a container that has children of its own + // (a toggle's title): everything after the cursor becomes a new first + // child, and the cursor moves into it. At the end of the title that's + // a new empty first child. Without this, the generic split below would + // try to split the container itself. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.isWrappedBlock || + !blockInfo.childContainer || + !isContentContainerNode(blockInfo.bnBlock.node) + ) { + return false; + } + const { blockContent, childContainer } = blockInfo; + + const titleEndPos = blockContent.afterPos - 1; + if ( + state.selection.from < blockContent.beforePos + 1 || + state.selection.to > titleEndPos + ) { + return false; + } + + if (dispatch) { + // The tail of the title — empty when the cursor is at its end. + const tail = blockContent.node.content.cut( + state.selection.to - blockContent.beforePos - 1, + ); + const newChild = state.schema.nodes[ + "blockContainer" + ].createAndFill( + undefined, + state.schema.nodes["paragraph"].create(undefined, tail), + )!; + + // Removes the tail (and anything selected) from the title, then + // prepends it to the container's children. + tr.delete(state.selection.from, titleEndPos); + const insertionPos = tr.mapping.map(childContainer.beforePos + 1); + tr.insert(insertionPos, newChild); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); + tr.scrollIntoView(); + } + + return true; + }), + // If the block is empty and the last child of a container with + // `exitOnEnter` behavior (the default for containers), moves the + // block out to after the container — one level per press, list-style. + // Without this, Enter only ever creates new blocks *within* the + // container, so a trailing container could trap the cursor. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isWrappedBlock) { + return false; + } + + const selectionEmpty = + state.selection.anchor === state.selection.head; + const blockEmpty = blockInfo.blockContent.node.childCount === 0; + if (!selectionEmpty || !blockEmpty) { + return false; + } + + const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const parentBlock = $pos.node(); + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // Only fires on the container's last child. + if (tr.doc.resolve(blockInfo.bnBlock.afterPos).nodeAfter !== null) { + return false; + } + + const parentChildren = getChildrenConfig( + this.options.editor.schema.blockSpecs[parentBlock.type.name] + ?.config ?? {}, + ); + if ( + parentChildren && + !resolveChildren(parentChildren).exitOnEnter + ) { + return false; + } + + const containerAfterPos = $pos.after(); + + if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + + tr.delete( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + ); + // The container's after-position, mapped through the deletion + // (and any schema-driven refill it triggered). + const insertionPos = tr.mapping.map(containerAfterPos); + tr.insert(insertionPos, blockInfo.bnBlock.node); + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); + tr.scrollIntoView(); + } + + return true; + }), // Creates a new block and moves the selection to it if the current one is empty, while the selection is also // empty & at the start of the block. () => commands.command(({ state, dispatch, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -920,7 +1158,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, chain }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { blockContent } = blockInfo; diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 7ab30b78aa..c6c57a72c9 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -67,9 +67,12 @@ const UniqueID = Extension.create({ setIdAttribute: false, isWithinEditor: undefined as ((element: Element) => boolean) | undefined, generateID: () => { - // Use mock ID if tests are running. - if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) { - const testOptions = (window as any).__TEST_OPTIONS; + // Use mock ID if tests are running. Resolved off `globalThis` rather + // than a bare `window` so that tests running in the plain `node` + // environment (no `window`) still get deterministic IDs. + const testHost: any = (globalThis as any).window ?? globalThis; + if (testHost.__TEST_OPTIONS) { + const testOptions = testHost.__TEST_OPTIONS; if (testOptions.mockID === undefined) { testOptions.mockID = 0; } else { diff --git a/packages/core/src/fonts/inter.css b/packages/core/src/fonts/inter.css index 57337cdd50..6e152551bf 100644 --- a/packages/core/src/fonts/inter.css +++ b/packages/core/src/fonts/inter.css @@ -9,7 +9,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-100.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-200 - latin */ @font-face { @@ -20,7 +20,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-200.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-300 - latin */ @font-face { @@ -31,7 +31,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-300.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-regular - latin */ @font-face { @@ -42,7 +42,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-regular.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-500 - latin */ @font-face { @@ -53,7 +53,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-500.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-600 - latin */ @font-face { @@ -64,7 +64,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-600.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-700 - latin */ @font-face { @@ -75,7 +75,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-700.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-800 - latin */ @font-face { @@ -86,7 +86,7 @@ local(""), url("./inter-v12-latin/inter-v12-latin-800.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } /* inter-900 - latin */ @font-face { @@ -97,5 +97,5 @@ local(""), url("./inter-v12-latin/inter-v12-latin-900.woff2") format("woff2"), /* Chrome 26+, Opera 23+, Firefox 39+ */ - url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ + url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */ } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4f220e1e2..fe5a7053d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,10 @@ export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js"; export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; -export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +// The rest of the container machinery — repair, navigation, UI info, the node +// groups and the generated node names — is on `@blocknote/core/internal`. +// `isContainerNode` stays here: it answers a question about the *document*, +// which integrations legitimately ask. +export { isContainerNode } from "./api/blockManipulation/containers/fixContainer.js"; export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts new file mode 100644 index 0000000000..5f16bd1fe3 --- /dev/null +++ b/packages/core/src/internal.ts @@ -0,0 +1,75 @@ +/** + * `@blocknote/core/internal` + * + * BlockNote's own machinery, exposed so the packages built on top of core + * (`@blocknote/react`, `@blocknote/xl-multi-column`, …) and BlockNote's tests + * can reach it — not part of the public API. Anything here may change in any + * release, without a major version bump or a deprecation. + * + * The public counterparts stay on the root entrypoint: `isContainerType`, + * `isContainerNode`, and the `children` config types (`ChildrenConfig`, + * `ChildrenAllow`, `ChildSlot`, `ChildCount`). + */ + +// How a `children` config compiles to a ProseMirror content expression, and the +// node groups and generated node names that fall out of it. +export { + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + CONTAINER_CONTENT_GROUP, + CONTAINER_NODE_PRIORITY, + blockTypeOfContainerChildrenNode, + blockTypeOfContainerContentNode, + childrenContentExpression, + containerChildrenNodeName, + containerContentNodeName, + containerNodePriority, + getChildrenConfig, + getContentContainerNodeTypes, + getMinChildCount, + isContentContainerNode, + isPlaceableAnywhere, + resolveChildren, + type ContainerTypeContext, +} from "./schema/blocks/children.js"; + +// Validation of `children` configs, run when a schema is built. +export { + slotAccepts, + validateChildrenConfigs, + validateContainerRunsBefore, +} from "./schema/blocks/validateChildren.js"; + +export { assertContainerSchemaInvariants } from "./schema/blocks/assertSchemaInvariants.js"; + +// The attributes a container block's root element carries, and the three ways +// they get there (node view, HTML serialization, framework render). +export { + applyContainerAttributes, + fillContainerAttributes, + getContainerAttributes, +} from "./schema/blocks/containerAttributes.js"; + +// Repairing a container after its children changed. +export { + fixContainer, + fixContainersById, + flattenNonInsertableBlocks, + isEmptyContainerChild, + removeEmptyChildren, +} from "./api/blockManipulation/containers/fixContainer.js"; + +// Position-based navigation through arbitrarily nested containers. +export { + ascendToInsertablePos, + descendToFirstInsertionPos, + descendToLastInsertionPos, + getAncestorContainers, + getFirstLeafBlock, +} from "./api/blockManipulation/containers/containerNav.js"; + +// What the side menu and drag handle need to know about a schema's containers. +export { + getContainerUIInfo, + type ContainerUIInfo, +} from "./api/blockManipulation/containers/containerUI.js"; diff --git a/packages/core/src/pm-nodes/README.md b/packages/core/src/pm-nodes/README.md index be57ead212..6e9e1571c1 100644 --- a/packages/core/src/pm-nodes/README.md +++ b/packages/core/src/pm-nodes/README.md @@ -16,7 +16,7 @@ In the BlockNote API, recall that blocks look like this: } ``` -`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a `column` or `columnList`. +`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a container block (a block declaring `children`, such as a `column`, `columnList`, or a custom callout). `content` is the block's Inline Content. Inline content doesn't have any `id`, it's "loose" content within the node. @@ -61,41 +61,92 @@ group: "blockContent", Blocks that are part of the `blockContent` group define the appearance / behaviour of the main element of the block (i.e.: headings, paragraphs, list items, etc.). These are only used for "regular" blocks that are represented as `blockContainer` nodes. -## Multi-column +## Container blocks -The `multi-column` package makes it possible to order blocks side by side in -columns. It adds the `columnList` and `column` nodes to the schema. +A block config can declare `children`, marking the block as a _container +block_: a block that holds other blocks as its body. A container with +`content: "none"` holds them directly, and emits a single ProseMirror node +(built by `createSpec`'s `buildContainerNode`) with this shape: + +```typescript +name: blockConfig.type, +group: "bnBlock childContainer blockGroupChild", // blockGroupChild is dropped for `placement: "containerOnly"` +// From `children.allow`/`min`/`max`/`sequence`; defaults to `blockGroupChild+`. +// See `childrenContentExpression`: allowed regular blocks collapse to one +// `blockContainer` term (ordered first, so ProseMirror auto-fill picks it); +// allowed container types appear verbatim, one term per sequence slot. +content: "blockGroupChild{min,max}", +priority: 40, // below blockContainer (50), so `blockGroupChild` auto-fill never recurses into containers +``` + +Unlike regular blocks, a container block's PM node **is** the `bnBlock` — there +is no `blockContainer` wrapper and no `blockGroup` around the children; child +blocks sit directly inside the container node. The children are exposed as +`block.children` in the BlockNote API. + +### Container blocks with their own content + +A container can also declare `content: "inline"` / `"plain"`. It then needs two +holes rather than one, and ProseMirror nodes are either inline-content or +block-content, never both — so `buildContentContainerNode` emits two generated +companion nodes alongside the block's own: + +```typescript +name: "toggle", +group: "bnBlock blockGroupChild", +content: "toggle__content toggle__children", + +name: "toggle__content", // group: "containerContent", content: "inline*" | "text*" +name: "toggle__children", // group: "childContainer", content: +``` + +`toggle__children` is in the `childContainer` group, so `nodeToBlock` finds the +children exactly where it does for a regular block, and the resulting `Block` +JSON is byte-identical to a nested regular block's. `containerContent` is +deliberately _not_ `blockContent`: a `blockContent` node is a legal child of +`blockContainer`, so a paste could otherwise produce +`blockContainer > toggle__content`, whose block type would resolve to the +generated node name. + +Note that the block's own node is a `childContainer` in the first shape and not +in the second — `isContainerNode` is `false` for `toggle` and `true` for +`toggle__children`. `isContentContainerNode` answers the other question. + +The `xl-multi-column` package's blocks are the canonical (pure) containers: ### ColumnList ```typescript +// children: { allow: { blocks: false, containers: ["column"] }, min: 2, unwrapWhenEmptied: true } name: "columnList", -group: "childContainer bnBlock blockGroupChild", -// A block always contains content, and optionally a blockGroup which contains nested blocks -content: "column column+", // min two columns +group: "bnBlock childContainer blockGroupChild", +content: "column{2,}", // min two columns ``` -The column list contains 2 or more columns. +The column list contains 2 or more columns. Its `unwrapWhenEmptied` config +makes `fixContainer` drop emptied columns and unwrap the list when fewer than +two non-empty columns remain. ### Column ```typescript +// children: { exitOnEnter: false }, placement: "containerOnly" name: "column", -group: "bnBlock childContainer", -// A block always contains content, and optionally a blockGroup which contains nested blocks -content: "blockContainer+", +group: "bnBlock childContainer", // not blockGroupChild: only valid inside a columnList +content: "blockGroupChild+", ``` -The column contains 1 or more block containers. +The column contains 1 or more blocks. # Groups We use Prosemirror "groups" to help organize this schema. Here is a list of the different groups: - `blockContent`: described above (contain the content for blocks that are represented as `BlockContainer` nodes) -- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and `columnList` -- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`, but for columns, both `columnList` and `column` are considered to be `childContainer` nodes. -- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. Both `blockContainer`, `column` and `columnList` are part of this group. +- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and top-level container blocks (e.g. `columnList`) +- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`; a pure container block node (`columnList`, `column`, a custom callout) is also a `childContainer`, and for a container with its own content it is the generated `__children` node. +- `containerContent`: the generated `__content` node of a container block that has its own content. Deliberately not `blockContent` — see above. +- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. `blockContainer` and every container block node are part of this group. _Note that the last two groups, `bnBlock` and `childContainer`, are not used anywhere in the schema. They are however helpful while programming. For example, we can check whether a node is a `bnBlock`, and then we know it corresponds to a BlockNote Block. Or, we can check whether a node is a `childContainer`, and then we know it's a container of a BlockNote Block's `children`. See `getBlockInfoFromPos` for an example of how this is used._ @@ -133,6 +184,14 @@ _Note that the last two groups, `bnBlock` and `childContainer`, are not used any + + A container block's own content + + + Its body + + + ``` diff --git a/packages/core/src/schema/blocks/assertSchemaInvariants.ts b/packages/core/src/schema/blocks/assertSchemaInvariants.ts new file mode 100644 index 0000000000..d5053b3617 --- /dev/null +++ b/packages/core/src/schema/blocks/assertSchemaInvariants.ts @@ -0,0 +1,62 @@ +import { Fragment, type Schema } from "prosemirror-model"; + +import { isContainerNode } from "../../api/blockManipulation/containers/fixContainer.js"; + +/** + * Checks the two structural properties the rest of the container machinery + * assumes, once, when the ProseMirror schema is built. + * + * Both used to be guaranteed only by a chain of implicit reasoning spread + * across several files. Asserting them here turns a class of "works until it + * silently doesn't" bugs into a startup error naming the cause. + */ +export function assertContainerSchemaInvariants(pmSchema: Schema) { + assertBlockGroupFillsWithBlockContainer(pmSchema); + assertContainersAreFillable(pmSchema); +} + +/** + * `blockGroup` must auto-fill with `blockContainer` rather than with some + * container block type. + * + * Today this holds because container nodes register below `blockContainer`'s + * priority, which drives TipTap's registration order, which drives the order + * ProseMirror resolves a group into types, which drives what `fillBefore` + * picks. That's four implicit links, and Yjs document initialization depends + * on the result (see `FixUpSchema`, which reads the first auto-filled child + * expecting it to be the id-carrying `blockContainer`). + */ +function assertBlockGroupFillsWithBlockContainer(pmSchema: Schema) { + const defaultType = pmSchema.nodes["blockGroup"]?.contentMatch.defaultType; + + if (defaultType?.name !== "blockContainer") { + throw new Error( + `BlockNote schema invariant broken: \`blockGroup\` auto-fills with "${defaultType?.name}" instead of "blockContainer". ` + + "Container block nodes must register at a lower priority than `blockContainer` (see CONTAINER_NODE_PRIORITY). " + + "Yjs document initialization depends on this (see FixUpSchema).", + ); + } +} + +/** + * Every container must be creatable empty, or inserting one throws a raw + * ProseMirror error at the call site instead of here. + * + * This is the empirical version of "is this `children` config buildable" — it + * asks ProseMirror rather than trying to re-derive the answer from the config, + * so it catches combinations no hand-written check would think to cover. + */ +function assertContainersAreFillable(pmSchema: Schema) { + for (const type of Object.values(pmSchema.nodes)) { + if (!isContainerNode(type)) { + continue; + } + + if (!type.contentMatch.fillBefore(Fragment.empty, true)) { + throw new Error( + `Container block "${type.name}" can never be created empty: its \`children\` config compiles to \`${type.spec.content}\`, ` + + "which ProseMirror cannot auto-fill. Lower the minimum child count, or allow regular blocks in the first slot.", + ); + } + } +} diff --git a/packages/core/src/schema/blocks/children.test.ts b/packages/core/src/schema/blocks/children.test.ts new file mode 100644 index 0000000000..6436731f28 --- /dev/null +++ b/packages/core/src/schema/blocks/children.test.ts @@ -0,0 +1,134 @@ +// @vitest-environment node +import { describe, expect, it } from "vite-plus/test"; + +import { childrenContentExpression, resolveChildren } from "./children.js"; +import type { ChildrenConfig } from "./types.js"; + +// Pretend schema: `column` and `gridCell` are containers, everything else is a +// regular block. +const CONTAINER_TYPES = ["column", "gridCell", "cardHeader", "cardBody"]; +const ctx = { + isContainerBlockType: (type: string) => CONTAINER_TYPES.includes(type), + containerBlockTypes: () => CONTAINER_TYPES, +}; + +// The content expression is the whole enforcement story — if this table is +// right, `allow`/`min`/`max`/`sequence` are enforced by ProseMirror itself. +const CASES: [string, ChildrenConfig, string][] = [ + ["any block, at least one (the default)", {}, "blockGroupChild+"], + ["any block, possibly none", { min: 0 }, "blockGroupChild*"], + ["any block, exactly one", { min: 1, max: 1 }, "blockGroupChild"], + ["any block, two or more", { min: 2 }, "blockGroupChild{2,}"], + ["any block, two to four", { min: 2, max: 4 }, "blockGroupChild{2,4}"], + ["any block, at most one", { min: 0, max: 1 }, "blockGroupChild?"], + ["any block, exactly three", { min: 3, max: 3 }, "blockGroupChild{3}"], + ["regular blocks only", { allow: { containers: false } }, "blockContainer+"], + [ + "one container type only", + { allow: { blocks: false, containers: ["column"] }, min: 2 }, + "column{2,}", + ], + [ + "regular blocks or one container type", + { allow: { containers: ["column"] } }, + "(blockContainer | column)+", + ], + [ + "any container but no regular blocks", + { allow: { blocks: false, containers: true } }, + "(column | gridCell | cardHeader | cardBody)+", + ], + [ + "ordered: header then body", + { + sequence: [ + { allow: { blocks: false, containers: ["cardHeader"] } }, + { allow: { blocks: false, containers: ["cardBody"] } }, + ], + }, + "cardHeader cardBody", + ], + [ + "ordered: optional header, then one or more regular blocks", + { + sequence: [ + { + allow: { blocks: false, containers: ["cardHeader"] }, + count: { max: 1 }, + }, + { allow: { containers: false }, count: { min: 1 } }, + ], + }, + "cardHeader? blockContainer+", + ], + [ + "ordered: body then a trailing citation", + { + sequence: [ + { allow: { containers: false }, count: { min: 1 } }, + { allow: { blocks: false, containers: ["cardBody"] } }, + ], + }, + "blockContainer+ cardBody", + ], +]; + +describe("childrenContentExpression", () => { + it.each(CASES)("%s", (_name, config, expected) => { + expect(childrenContentExpression(config, ctx)).toBe(expected); + }); + + // `fillBefore` picks the first matching type in a union, and filling with + // `blockContainer` (rather than another container) is what keeps auto-fill + // from recursing through nested containers. + it("orders blockContainer first in a union", () => { + expect( + childrenContentExpression({ allow: { containers: ["column"] } }, ctx), + ).toMatch(/^\(blockContainer \|/); + }); +}); + +describe("resolveChildren", () => { + it("treats the uniform form as a one-slot sequence", () => { + const uniform = resolveChildren({ allow: { containers: false }, min: 2 }); + const sequence = resolveChildren({ + sequence: [{ allow: { containers: false }, count: { min: 2 } }], + }); + expect(uniform.slots).toEqual(sequence.slots); + }); + + it("defaults a slot to exactly one child", () => { + expect(resolveChildren({ sequence: [{}] }).slots[0]).toMatchObject({ + min: 1, + max: 1, + }); + }); + + it("sums slot bounds into overall counts", () => { + const resolved = resolveChildren({ + sequence: [{}, { count: { min: 1, max: 3 } }], + }); + expect(resolved.minCount).toBe(2); + expect(resolved.maxCount).toBe(4); + }); + + it("reports an unbounded maximum when any slot is unbounded", () => { + expect( + resolveChildren({ sequence: [{}, { count: { min: 1 } }] }).maxCount, + ).toBeUndefined(); + }); + + it("collapses `containers: false` to an empty list", () => { + expect( + resolveChildren({ allow: { containers: false } }).slots[0].containers, + ).toEqual([]); + }); + + it("returns the same object for the same config", () => { + // Downstream code resolves the same config object on every node build and + // repair pass, and must never mutate the user's object. + const config: ChildrenConfig = { min: 1 }; + expect(resolveChildren(config)).toBe(resolveChildren(config)); + expect(config).toEqual({ min: 1 }); + }); +}); diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts new file mode 100644 index 0000000000..5d965c8e29 --- /dev/null +++ b/packages/core/src/schema/blocks/children.ts @@ -0,0 +1,323 @@ +import type { Node, NodeType, Schema } from "prosemirror-model"; + +import type { + BlockConfig, + ChildrenConfig, + ChildCount, + ChildSlot, + ResolvedChildren, + ResolvedSlot, +} from "./types.js"; + +export const CHILD_CONTAINER_GROUP = "childContainer"; + +export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; + +/** + * The group holding the generated content node of a *content-bearing* + * container — a container that has its own inline content as well as children. + * + * Deliberately not `blockContent`: a `blockContent` node is a legal child of + * `blockContainer`, so a paste could produce `blockContainer > toggle__content` + * whose block type would resolve to the generated node name ("Block is of an + * unrecognized type"). A group of its own makes that unrepresentable. + */ +export const CONTAINER_CONTENT_GROUP = "containerContent"; + +// The two generated companion nodes of a content-bearing container. The +// separator is `__` rather than something more obviously generated like `$` +// because these names appear in the container's own content expression, and +// ProseMirror's expression parser only accepts word characters in a node name. +const CONTENT_NODE_SUFFIX = "__content"; +const CHILDREN_NODE_SUFFIX = "__children"; + +export function containerContentNodeName(blockType: string): string { + return `${blockType}${CONTENT_NODE_SUFFIX}`; +} + +export function containerChildrenNodeName(blockType: string): string { + return `${blockType}${CHILDREN_NODE_SUFFIX}`; +} + +/** Inverse of {@link containerContentNodeName}. */ +export function blockTypeOfContainerContentNode( + nodeName: string, +): string | undefined { + return nodeName.endsWith(CONTENT_NODE_SUFFIX) + ? nodeName.slice(0, -CONTENT_NODE_SUFFIX.length) + : undefined; +} + +/** Inverse of {@link containerChildrenNodeName}. */ +export function blockTypeOfContainerChildrenNode( + nodeName: string, +): string | undefined { + return nodeName.endsWith(CHILDREN_NODE_SUFFIX) + ? nodeName.slice(0, -CHILDREN_NODE_SUFFIX.length) + : undefined; +} + +/** + * Whether `node` is the node of a container block that has its own content — + * one whose children live in a generated `__children` node rather than + * directly inside it. + * + * Derived from the node's *shape*, the same way `getBlockInfoWithManualOffset` + * discriminates: a `containerContent` node only ever appears as the first + * child of the block that generated it. Note this is not the same question as + * `isContainerNode`, which is `false` for this node and `true` for its + * generated `__children` node. + */ +export function isContentContainerNode(node: Node): boolean { + return !!node.firstChild?.type.isInGroup(CONTAINER_CONTENT_GROUP); +} + +/** + * The generated content & children node types of a container block that has + * its own content, or `undefined` if the block type is anything else (a + * regular block, or a pure container whose node holds its children directly). + */ +export function getContentContainerNodeTypes( + schema: Schema, + blockType: string, +): { contentType: NodeType; childrenType: NodeType } | undefined { + const contentType = schema.nodes[containerContentNodeName(blockType)]; + const childrenType = schema.nodes[containerChildrenNodeName(blockType)]; + + return contentType && childrenType + ? { contentType, childrenType } + : undefined; +} + +// Below `blockContainer`'s priority (50): PM's `fillBefore` picks the first +// matching type in a group, and `blockContainer` must win so auto-fill doesn't +// recurse through nested containers. +export const CONTAINER_NODE_PRIORITY = 40; + +/** + * The band container nodes register in, strictly below `blockContainer`'s 50. + * `runsBefore` can move a container within the band — never out of it. + */ +const CONTAINER_PRIORITY_BAND = { min: 30, max: 49 }; + +/** + * The priority a spec with no `runsBefore` (and which nothing else declares a + * `runsBefore` on) gets from `sortByDependencies`. Priorities are spaced 10 + * apart around it. + */ +const DEFAULT_SPEC_PRIORITY = 101; + +/** + * Maps the priority `sortByDependencies` computed from a container's + * `runsBefore` into the container band. + * + * Containers can't simply use the computed value: every container node has to + * register below `blockContainer` (see {@link CONTAINER_NODE_PRIORITY}), while + * the computed priorities sit around 101. So the relative order is preserved + * and the magnitude is not — a container with no `runsBefore` lands exactly on + * `CONTAINER_NODE_PRIORITY`, and each level of `runsBefore` moves it one step. + * + * The consequence, which `validateContainerRunsBefore` turns into an error + * rather than a surprise: a container can be ordered before another *container* + * but never before a regular block, because the whole band is below every + * regular block's priority. + */ +export function containerNodePriority(priority: number | undefined): number { + if (priority === undefined) { + return CONTAINER_NODE_PRIORITY; + } + + const steps = Math.round((priority - DEFAULT_SPEC_PRIORITY) / 10); + + return Math.min( + CONTAINER_PRIORITY_BAND.max, + Math.max(CONTAINER_PRIORITY_BAND.min, CONTAINER_NODE_PRIORITY + steps), + ); +} + +export function getChildrenConfig(config: { + children?: ChildrenConfig; +}): ChildrenConfig | undefined { + return config.children; +} + +export function isContainerType(config: { + children?: ChildrenConfig; +}): boolean { + return config.children !== undefined; +} + +/** + * Whether a block can be inserted anywhere a regular block goes — the document + * root, or nested under any other block. `placement: "containerOnly"` blocks + * (e.g. a `column`, which only ever lives inside a `columnList`) are left out + * of the `blockGroupChild` group, so the schema rejects them elsewhere. + */ +export function isPlaceableAnywhere(config: { + placement?: BlockConfig["placement"]; +}): boolean { + return config.placement !== "containerOnly"; +} + +// Resolving is pure, and downstream code asks the same questions of the same +// config object over and over (per node build, per repair pass, per keystroke +// in some paths). Cache by config identity — which also keeps us from ever +// mutating the user's object, so `blockSchema[type]` identity checks stay +// valid across schema instances. +const resolvedCache = new WeakMap(); + +/** + * Normalizes a `children` config into the single shape every consumer reads: + * an ordered list of slots. The uniform form (`allow`/`min`/`max`) is exactly + * sugar for a one-slot sequence, so nothing downstream has to branch on which + * form the author wrote. + */ +export function resolveChildren(children: ChildrenConfig): ResolvedChildren { + const cached = resolvedCache.get(children); + if (cached) { + return cached; + } + + const slots: ResolvedSlot[] = children.sequence + ? children.sequence.map(resolveSlot) + : [ + { + ...resolveAllow(children.allow), + min: children.min ?? 1, + max: children.max, + }, + ]; + + let maxCount: number | undefined = 0; + for (const slot of slots) { + if (slot.max === undefined) { + maxCount = undefined; + break; + } + maxCount += slot.max; + } + + const resolved: ResolvedChildren = { + slots, + minCount: slots.reduce((total, slot) => total + slot.min, 0), + maxCount, + default: children.default, + unwrapWhenEmptied: children.unwrapWhenEmptied ?? false, + exitOnEnter: children.exitOnEnter ?? true, + }; + + resolvedCache.set(children, resolved); + return resolved; +} + +// A slot names a *position*, so it holds exactly one child unless it says +// otherwise — hence `count` rather than `min`/`max`. +function resolveSlot(slot: ChildSlot): ResolvedSlot { + return { ...resolveAllow(slot.allow), ...resolveCount(slot.count) }; +} + +function resolveCount(count: ChildCount | undefined): { + min: number; + max: number | undefined; +} { + if (count === undefined) { + return { min: 1, max: 1 }; + } + if (typeof count === "number") { + return { min: count, max: count }; + } + return { min: count.min ?? 0, max: count.max }; +} + +function resolveAllow( + allow: ChildrenConfig["allow"], +): Pick { + const containers = allow?.containers ?? true; + return { + blocks: allow?.blocks ?? true, + // `false` and `[]` mean the same thing downstream, so collapse them here + // rather than making every consumer handle both. + containers: containers === false ? [] : containers, + }; +} + +export function getMinChildCount(children: ChildrenConfig): number { + return resolveChildren(children).minCount; +} + +/** + * Context a single block spec can't know on its own: which of the schema's + * block types are containers, and what they're called. + */ +export type ContainerTypeContext = { + isContainerBlockType: (blockType: string) => boolean; + containerBlockTypes: () => readonly string[]; +}; + +/** + * Builds the ProseMirror content expression for a container block. + * + * Container children are PM *nodes*: a container-type block is its own node + * type, while every regular block lives inside a shared `blockContainer` node. + * That asymmetry is why `allow` has two fields instead of one list — the + * schema can name container types exactly, but cannot tell one regular block + * type from another. + */ +export function childrenContentExpression( + children: ChildrenConfig, + ctx: ContainerTypeContext, +): string { + return resolveChildren(children) + .slots.map((slot) => slotTerm(slot, ctx) + quantifier(slot.min, slot.max)) + .join(" "); +} + +function slotTerm(slot: ResolvedSlot, ctx: ContainerTypeContext): string { + // "Anything" is already a group, so use it rather than spelling out a union + // that would need rebuilding whenever the schema gains a container type. + if (slot.blocks && slot.containers === true) { + return BLOCK_GROUP_CHILD_GROUP; + } + + const terms: string[] = []; + // `blockContainer` FIRST: PM's `fillBefore` picks the first matching type in + // a union, and filling with `blockContainer` (rather than another container) + // keeps auto-fill from recursing through nested containers. + if (slot.blocks) { + terms.push("blockContainer"); + } + // Deliberately expanded rather than emitting the `childContainer` group — + // that group also contains `blockGroup`, which is not a block. + terms.push( + ...(slot.containers === true ? ctx.containerBlockTypes() : slot.containers), + ); + + if (terms.length === 0) { + // Validation rejects this first; this is a bug-guard, not a user-facing + // error path. + throw new Error( + "Container slot allows nothing. This is a bug in BlockNote.", + ); + } + + return terms.length === 1 ? terms[0] : `(${terms.join(" | ")})`; +} + +function quantifier(min: number, max: number | undefined): string { + if (max === undefined) { + if (min === 0) { + return "*"; + } + if (min === 1) { + return "+"; + } + return `{${min},}`; + } + if (min === max) { + return max === 1 ? "" : `{${min}}`; + } + if (min === 0 && max === 1) { + return "?"; + } + return `{${min},${max}}`; +} diff --git a/packages/core/src/schema/blocks/containerAttributes.ts b/packages/core/src/schema/blocks/containerAttributes.ts new file mode 100644 index 0000000000..47e5fe6842 --- /dev/null +++ b/packages/core/src/schema/blocks/containerAttributes.ts @@ -0,0 +1,99 @@ +import { camelToDataKebab } from "../../util/string.js"; +import { PropSchema, Props } from "../propTypes.js"; + +/** + * The attributes BlockNote relies on being present on a container block's root + * element: the `data-node-type` marker the generated parse rules read, each + * non-default prop as a kebab-cased `data-*` attribute, and (when known) the + * block's `data-id`. + * + * The single source of truth for every path that puts these on an element — + * the node view (`applyContainerAttributes`) and HTML serialization + * (`fillContainerAttributes`) — so the live and serialized DOM carry the same + * attributes on the same element. + */ +export function getContainerAttributes( + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id: string | undefined, +): Record { + const attributes: Record = { "data-node-type": blockType }; + + for (const [prop, value] of Object.entries(blockProps)) { + if (value === undefined || value === propSchema[prop]?.default) { + continue; + } + attributes[camelToDataKebab(prop)] = `${value}`; + } + + if (id) { + attributes["data-id"] = id; + } + + return attributes; +} + +/** + * Applies the attributes BlockNote relies on to a container block's root + * element. Called from the container node view (both initial render and + * `update`) so block renders don't have to stamp these themselves. The node + * view owns the element, so this overwrites existing attributes and clears the + * ones for props that are back at their default. + */ +export function applyContainerAttributes( + dom: HTMLElement | DocumentFragment | undefined | null, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id: string | undefined, +) { + const element = dom as HTMLElement | undefined; + if (!element || typeof element.setAttribute !== "function") { + return; + } + + const attributes = getContainerAttributes( + blockType, + blockProps, + propSchema, + id, + ); + + for (const prop of Object.keys(blockProps)) { + const attr = camelToDataKebab(prop); + if (!(attr in attributes)) { + element.removeAttribute(attr); + } + } + for (const [attr, value] of Object.entries(attributes)) { + element.setAttribute(attr, value); + } +} + +/** + * Fills in the `data-node-type` marker and non-default prop `data-*` + * attributes a container block's serialized root needs to parse back, without + * clobbering any the block's own render already set (author-set attributes + * win). Used by internal/external HTML serialization, where there is no node + * view to own the element. + */ +export function fillContainerAttributes( + dom: HTMLElement, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, +) { + const attributes = getContainerAttributes( + blockType, + blockProps, + propSchema, + undefined, + ); + + for (const [attr, value] of Object.entries(attributes)) { + if (!dom.hasAttribute(attr)) { + dom.setAttribute(attr, value); + } + } +} diff --git a/packages/core/src/schema/blocks/containerParse.browser.test.ts b/packages/core/src/schema/blocks/containerParse.browser.test.ts new file mode 100644 index 0000000000..6a727a7e8b --- /dev/null +++ b/packages/core/src/schema/blocks/containerParse.browser.test.ts @@ -0,0 +1,339 @@ +import { Fragment } from "prosemirror-model"; +import { AllSelection } from "prosemirror-state"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "./createSpec.js"; + +// Every test here goes through `tryParseHTMLToBlocks`, which parses real HTML +// into a real DOM (`document.implementation.createHTMLDocument` in +// `api/parsers/html/util/nestedLists.ts`) before ProseMirror's parser ever runs +// — and the clipboard test additionally needs a mounted view for +// `view.serializeForClipboard`. Parsing HTML *is* the capability under test, so +// this whole suite runs against a real browser engine rather than jsdom's. + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// A pure container that recognizes its own external HTML. Before containers +// went through `getParseRules`, `parse` was silently dropped for them and this +// produced nothing at all. +const Card = createBlockSpec( + { + type: "card" as const, + propSchema: { tone: { default: "neutral" } }, + content: "none", + children: {}, + }, + { + render: renderDiv, + parse: (el) => + el.classList.contains("card") + ? { tone: el.getAttribute("data-tone") ?? undefined } + : undefined, + }, +)(); + +// The same, but taking over the parsing of its own body. +const Quote = createBlockSpec( + { + type: "quote" as const, + propSchema: {}, + content: "none", + children: {}, + }, + { + render: renderDiv, + parse: (el) => (el.tagName === "BLOCKQUOTE" ? {} : undefined), + // Returns inline nodes — the natural thing to build from an element — and + // relies on `toContainerChildren` to place them. + parseContent: ({ el, schema }) => + Fragment.from(schema.text(el.textContent?.trim() || "empty")), + }, +)(); + +// A container with its own content, to exercise the two generated nodes +// through the clipboard. +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: { open: { default: true } }, + content: "inline", + children: {}, + }, + { render: renderDiv }, +)(); + +// A content-bearing container whose `parseContent` returns a leading run of +// inline nodes followed by a block — the shape that has to split across the +// two generated nodes. +const Section = createBlockSpec( + { + type: "section" as const, + propSchema: {}, + content: "inline", + children: {}, + }, + { + render: renderDiv, + parse: (el) => (el.tagName === "SECTION" ? {} : undefined), + parseContent: ({ el, schema }) => + Fragment.fromArray([ + schema.text(el.getAttribute("data-title") || "untitled"), + schema.nodes["paragraph"].create( + null, + schema.text(el.textContent?.trim() || "empty"), + ), + ]), + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + card: Card, + quote: Quote, + toggle: Toggle, + section: Section, + } as const, +}); + +let editor: BlockNoteEditor; +const div = document.createElement("div"); + +beforeAll(() => { + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }) as any; + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +describe("container `parse`", () => { + it("parses an external element into a container, children intact", () => { + const blocks = editor.tryParseHTMLToBlocks( + '

First

Second

', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("card"); + expect(blocks[0].props.tone).toBe("warning"); + // No `getContent` is supplied, so ProseMirror parses the children with the + // normal block rules and `findWrapping` adds the `blockContainer`s. + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + "heading", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "First", styles: {} }, + ]); + }); + + it("places inline nodes returned by `parseContent` into a child block", () => { + const blocks = editor.tryParseHTMLToBlocks( + "
Quoted text
", + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("quote"); + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "Quoted text", styles: {} }, + ]); + }); + + it("splits `parseContent` across a content-bearing container's two regions", () => { + const blocks = editor.tryParseHTMLToBlocks( + '
Body
', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("section"); + // The leading inline run is the block's own content; the block that + // follows it is a child. + expect(blocks[0].content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "Body", styles: {} }, + ]); + }); +}); + +describe("container HTML round-trip", () => { + const toggleBlocks = [ + { + id: "t-0", + type: "toggle" as const, + props: { open: false }, + content: "Title", + children: [ + { id: "t-p-0", type: "paragraph" as const, content: "Body" }, + { id: "t-p-1", type: "heading" as const, content: "Sub" }, + ], + }, + ]; + + const expectRoundTripped = (parsed: any[]) => { + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("toggle"); + expect(parsed[0].props.open).toBe(false); + expect(parsed[0].content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + expect( + parsed[0].children.map((child: any) => [ + child.type, + child.content?.[0]?.text, + ]), + ).toEqual([ + ["paragraph", "Body"], + ["heading", "Sub"], + ]); + }; + + it("round-trips a content-bearing container through full HTML", () => { + editor.replaceBlocks(editor.document, toggleBlocks); + + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain('data-node-type="toggle"'); + + expectRoundTripped(editor.tryParseHTMLToBlocks(html)); + }); + + it("round-trips a content-bearing container through the clipboard", () => { + editor.replaceBlocks(editor.document, toggleBlocks); + + // What a copy actually puts on the clipboard: ProseMirror's own + // serialization, which renders the generated content & children nodes. + const view = editor._tiptapEditor.view; + view.dispatch(view.state.tr.setSelection(new AllSelection(view.state.doc))); + const clipboardHTML = view.serializeForClipboard( + view.state.selection.content(), + ).dom.innerHTML; + + expect(clipboardHTML).toContain('data-content-type="toggle"'); + expect(clipboardHTML).toContain('data-children-of="toggle"'); + + expectRoundTripped(editor.tryParseHTMLToBlocks(clipboardHTML)); + }); + + it("round-trips a content-bearing container through external HTML", () => { + editor.replaceBlocks(editor.document, toggleBlocks); + + const html = editor.blocksToHTMLLossy(editor.document); + expect(html).toContain('data-node-type="toggle"'); + + expectRoundTripped(editor.tryParseHTMLToBlocks(html)); + }); + + // Two children, because the one-child case passes either way. External HTML + // has no marker element for the container's own content, so an empty title + // leaves the parser reading a block element first — with nothing to satisfy + // the content node, it can't open the children node and every child used to + // land *after* the container. + it("round-trips an empty-titled container's children through external HTML", () => { + editor.replaceBlocks(editor.document, [ + { ...toggleBlocks[0], content: undefined }, + ]); + + const html = editor.blocksToHTMLLossy(editor.document); + const parsed = editor.tryParseHTMLToBlocks(html); + + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("toggle"); + expect( + (parsed[0] as any).children.map((child: any) => [ + child.type, + child.content?.[0]?.text, + ]), + ).toEqual([ + ["paragraph", "Body"], + ["heading", "Sub"], + ]); + }); +}); + +describe("container `runsBefore`", () => { + const ambiguous = (type: string) => + createBlockSpec( + { type, propSchema: {}, content: "none", children: {} } as any, + { + render: renderDiv, + parse: (el: HTMLElement) => + el.classList.contains("shared") ? {} : undefined, + }, + ); + + const makeEditor = (betaRunsBefore?: string[]) => { + const alpha = ambiguous("alpha")(); + const beta = ambiguous("beta")(); + if (betaRunsBefore) { + (beta.implementation as any).runsBefore = betaRunsBefore; + } + + return BlockNoteEditor.create({ + schema: BlockNoteSchema.create().extend({ + blockSpecs: { ...defaultBlockSpecs, alpha, beta } as any, + }), + }) as BlockNoteEditor; + }; + + it("leaves the declaration order alone by default", () => { + const other = makeEditor(); + try { + expect( + other.tryParseHTMLToBlocks('

x

')[0] + .type, + ).toBe("alpha"); + } finally { + other._tiptapEditor.destroy(); + } + }); + + it("orders a container's parse rules before another container's", () => { + const other = makeEditor(["alpha"]); + try { + expect( + other.tryParseHTMLToBlocks('

x

')[0] + .type, + ).toBe("beta"); + } finally { + other._tiptapEditor.destroy(); + } + }); + + it("rejects a `runsBefore` naming a regular block", () => { + // Container nodes all register below `blockContainer`, so this ordering is + // not something the schema could ever produce. + expect(() => makeEditor(["paragraph"])).toThrow( + /can never be ordered before a regular block/, + ); + }); +}); diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index b1e54d640a..02e697b6f8 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -1,11 +1,13 @@ -import { Editor, Node } from "@tiptap/core"; +import { Editor, Node, NodeViewRendererProps } from "@tiptap/core"; import { DOMParser, Fragment, Node as PMNode, + Schema as PMSchema, TagParseRule, } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { Extension, @@ -13,8 +15,22 @@ import { } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + CHILD_CONTAINER_GROUP, + CONTAINER_CONTENT_GROUP, + type ContainerTypeContext, + childrenContentExpression, + containerChildrenNodeName, + containerContentNodeName, + containerNodePriority, + getChildrenConfig, + isPlaceableAnywhere, +} from "./children.js"; +import { applyContainerAttributes } from "./containerAttributes.js"; +import { + applyDOMAttributes, getBlockFromNodeView, propsToAttributes, wrapInBlockStructure, @@ -28,6 +44,27 @@ import { LooseBlockSpec, } from "./types.js"; +/** + * Full-schema context that node construction needs but a single block spec + * can't know on its own. + */ +export type BlockSchemaContext = ContainerTypeContext; + +const NO_SCHEMA_CONTEXT: BlockSchemaContext = { + isContainerBlockType: (blockType) => { + throw new Error( + `Cannot resolve whether "${blockType}" is a container block without full schema context. ` + + "Blocks that use `children.allow.containers` must be registered through `BlockNoteSchema.create`.", + ); + }, + containerBlockTypes: () => { + throw new Error( + "Cannot resolve the schema's container block types without full schema context. " + + "Blocks that use `children.allow.containers` must be registered through `BlockNoteSchema.create`.", + ); + }, +}; + // Function that causes events within non-selectable blocks to be handled by the // browser instead of the editor. export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) { @@ -45,9 +82,75 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) { }; } -// Function that uses the 'parse' function of a blockConfig to create a -// TipTap node's `parseHTML` property. This is only used for parsing content -// from the clipboard. +/** + * Places the fragment a container block's `parseContent` returned into the + * shape the container's content expression can hold. + * + * A container's content is blocks, not inline nodes, so a `parseContent` author + * who returns inline nodes (the natural thing to return from a DOM element) + * would otherwise have them dropped. Runs of inline nodes become paragraphs, + * block-level nodes pass through untouched, and ProseMirror's `findWrapping` + * then inserts the `blockContainer` (and, for a content-bearing container, the + * generated `__children`) wrappers around them. + * + * The one exception is a *leading* run of inline nodes in a content-bearing + * container: that is the block's own content, and it's left inline so it lands + * in the generated `__content` node. + */ +function toContainerChildren( + fragment: Fragment, + schema: PMSchema, + hasOwnContent: boolean, +): Fragment { + const out: PMNode[] = []; + let inlineRun: PMNode[] = []; + let seenBlock = false; + + const flush = () => { + if (inlineRun.length === 0) { + return; + } + // A leading inline run in a content-bearing container is its own content, + // so it stays inline; anything else has to become a block. + out.push( + ...(hasOwnContent && !seenBlock + ? inlineRun + : [schema.nodes["paragraph"].create(null, inlineRun)]), + ); + inlineRun = []; + }; + + fragment.forEach((child) => { + if (child.isInline) { + inlineRun.push(child); + return; + } + flush(); + seenBlock = true; + out.push(child); + }); + flush(); + + return Fragment.fromArray(out); +} + +/** + * Function that uses the 'parse' function of a blockConfig to create a + * TipTap node's `parseHTML` property. This is only used for parsing content + * from the clipboard. + * + * `kind` picks the default rule and how `parseContent` is honored. It is a + * parameter rather than a second function because everything else — the + * `implementation.parse` rule, its `getAttrs`, whitespace handling — is + * identical for both, and forking it is how `implementation.parse` came to be + * silently ignored for containers in the first place. + * + * **What `children.allow` does not do:** it does not filter what a user can + * paste into a container. HTML paste parses with `blockGroup` as the top node + * (see `parseHTML.ts`), so ProseMirror's fitting algorithm places content the + * container's expression rejects *after* the container rather than dropping + * it. `allow` constrains the document model, not the parser. + */ export function getParseRules< TName extends string, TProps extends PropSchema, @@ -55,12 +158,21 @@ export function getParseRules< >( config: BlockConfig, implementation: BlockImplementation, + kind: "regular" | "container" = "regular", ) { + const isContainer = kind === "container"; + + // A container owns its outer element rather than living inside a + // `blockContent` div, so it's matched by the marker its own render (and the + // serializer) puts on that element — and it has no `contentElement`: its + // children are blocks, parsed by the normal block rules. const rules: TagParseRule[] = [ - { - tag: "[data-content-type=" + config.type + "]", - contentElement: ".bn-inline-content", - }, + isContainer + ? { tag: `[data-node-type=${config.type}]` } + : { + tag: "[data-content-type=" + config.type + "]", + contentElement: ".bn-inline-content", + }, ]; if (implementation.parse) { @@ -81,10 +193,33 @@ export function getParseRules< }, // Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed preserveWhitespace: true, - getContent: - config.content === "inline" || - config.content === "none" || - config.content === "plain" + // `getContent` is omitted entirely for a container without + // `parseContent`: that leaves ProseMirror to parse the element's + // children with the normal block rules, which is exactly what a + // container wants. Supplying it (even to return the same thing) would + // mean re-implementing block parsing. + getContent: isContainer + ? implementation.parseContent + ? (node, schema) => + toContainerChildren( + implementation.parseContent!({ + el: node as HTMLElement, + schema, + }) ?? + // `parseContent` returns undefined to fall through to the + // default parsing, which for a container is "parse my + // children as blocks". + DOMParser.fromSchema(schema).parse(node as HTMLElement, { + topNode: schema.nodes["blockGroup"].create(), + preserveWhitespace: true, + }).content, + schema, + config.content !== "none", + ) + : undefined + : config.content === "inline" || + config.content === "none" || + config.content === "plain" ? (node, schema) => { if (implementation.parseContent) { const result = implementation.parseContent({ @@ -167,130 +302,465 @@ export function getParseRules< return rules; } -// A function to create custom block for API consumers -// we want to hide the tiptap node from API consumers and provide a simpler API surface instead -export function addNodeAndExtensionsToSpec< +function buildContainerNode( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + schemaContext: BlockSchemaContext, + priority?: number, +) { + const children = getChildrenConfig(blockConfig)!; + + const groups = ["bnBlock", "childContainer"]; + if (isPlaceableAnywhere(blockConfig)) { + groups.push("blockGroupChild"); + } + + return Node.create({ + name: blockConfig.type, + content: childrenContentExpression(children, schemaContext), + group: groups.join(" "), + marks() { + return suggestionMarks(this.editor); + }, + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + defining: true, + priority: containerNodePriority(priority), + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return getParseRules(blockConfig, blockImplementation, "container"); + }, + + renderHTML({ HTMLAttributes }) { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", blockConfig.type); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + dom.setAttribute(attribute, value as string); + } + return { dom, contentDOM: dom }; + }, + + addNodeView() { + return (props) => + containerNodeView(blockConfig, blockImplementation, props, { + editor: this.options.editor, + tiptapEditor: this.editor, + blockContentDOMAttributes: + this.options.domAttributes?.blockContent || {}, + }); + }, + }); +} + +/** + * The block author's own root element: `dom` for a vanilla render, and + * `rootDOM` when a framework put wrappers of its own above it. `null` means + * the framework hasn't rendered it yet — there is nothing to stamp, and + * falling back to `dom` would stamp a wrapper instead. + */ +function containerRootDOM(output: { + dom: HTMLElement | DocumentFragment; + rootDOM?: HTMLElement | null; +}): HTMLElement | DocumentFragment | null | undefined { + return output.rootDOM === undefined ? output.dom : output.rootDOM; +} + +/** + * The node view shared by every container block, with or without its own + * content: the block's `render` output, with the attributes BlockNote relies + * on stamped onto it. + */ +function containerNodeView< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + props: NodeViewRendererProps, + context: { + editor: unknown; + tiptapEditor: Editor; + blockContentDOMAttributes: Record; + }, +): NodeView { + // A container's node *is* the block — convert directly rather than + // walking up to a parent (which would find the enclosing block). + const block = nodeToBlock(props.node, props.view.state.doc); + + const nodeView = blockImplementation.render.call( + { + blockContentDOMAttributes: context.blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + context.editor as any, + ); + + // Read lazily: a framework render may not have produced its root element + // yet, and its identity can change across re-renders. + const rootDOM = () => containerRootDOM(nodeView); + + applyContainerAttributes( + rootDOM(), + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + + const typedNodeView = nodeView as unknown as NodeView; + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, context.tiptapEditor); + } + + ignoreNonContentMutations(typedNodeView); + + // Unlike regular blocks, containers honor an `update` hook: + // recreating the node view would remount every child block, so a + // container patches its own DOM in place instead. + const update = typedNodeView.update?.bind(typedNodeView); + if (update) { + typedNodeView.update = (node, decorations, innerDecorations) => { + if (node.type.name !== blockConfig.type) { + return false; + } + if (update(node, decorations, innerDecorations) === false) { + return false; + } + applyContainerAttributes( + rootDOM(), + blockConfig.type, + nodeToBlock(node, props.view.state.doc).props as any, + blockConfig.propSchema, + node.attrs.id, + ); + return true; + }; + } + + return typedNodeView; +} + +/** + * Builds the nodes for a container block that also has its own content: + * + * ``` + * toggle group: "bnBlock blockGroupChild" + * content: "toggle__content toggle__children" + * ├── toggle__content group: "containerContent" content: "inline*" + * └── toggle__children group: "childContainer" content: + * ``` + * + * A ProseMirror node holds either inline content or block content, never both, + * so the two regions are necessarily two sibling nodes. Keeping the children in + * a `childContainer` node — with the same content expression a pure container + * would compile to — is what makes the resulting `Block` JSON identical to a + * nested regular block's, and `allow`/`min`/`max`/`sequence` enforce the same + * way for both kinds of container. + * + * All props stay on the outer node; the content node declares none. + */ +function buildContentContainerNode< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + schemaContext: BlockSchemaContext, + priority?: number, +): { node: Node; extraNodes: Node[] } { + const children = getChildrenConfig(blockConfig)!; + + const contentName = containerContentNodeName(blockConfig.type); + const childrenName = containerChildrenNodeName(blockConfig.type); + const nodePriority = containerNodePriority(priority); + + const groups = ["bnBlock"]; + if (isPlaceableAnywhere(blockConfig)) { + groups.push("blockGroupChild"); + } + + const node = Node.create({ + name: blockConfig.type, + content: `${contentName} ${childrenName}`, + group: groups.join(" "), + marks() { + return suggestionMarks(this.editor); + }, + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + defining: true, + priority: nodePriority, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + return getParseRules(blockConfig, blockImplementation, "container"); + }, + + renderHTML({ HTMLAttributes }) { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", blockConfig.type); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + dom.setAttribute(attribute, value as string); + } + return { dom, contentDOM: dom }; + }, + + addNodeView() { + return (props) => + containerNodeView(blockConfig, blockImplementation, props, { + editor: this.options.editor, + tiptapEditor: this.editor, + blockContentDOMAttributes: + this.options.domAttributes?.blockContent || {}, + }); + }, + }); + + const contentNode = Node.create({ + name: contentName, + group: CONTAINER_CONTENT_GROUP, + content: blockConfig.content === "plain" ? "text*" : "inline*", + // Same reasoning as for a regular block's content node: "plain" content + // holds unstyled text, so it disallows formatting marks but keeps the + // non-formatting ones (comments and suggestions/diffs). + marks() { + return blockConfig.content === "plain" + ? nonFormattingMarks(this.editor) + : undefined; + }, + code: blockImplementation.meta?.code ?? false, + defining: true, + priority: nodePriority, + + parseHTML() { + return [{ tag: `[data-content-type=${blockConfig.type}]` }]; + }, + + renderHTML() { + const dom = document.createElement("div"); + dom.className = "bn-inline-content"; + dom.setAttribute("data-content-type", blockConfig.type); + return { dom, contentDOM: dom }; + }, + }); + + const childrenNode = Node.create({ + name: childrenName, + group: CHILD_CONTAINER_GROUP, + content: childrenContentExpression(children, schemaContext), + marks() { + return suggestionMarks(this.editor); + }, + priority: nodePriority, + + parseHTML() { + return [{ tag: `[data-children-of=${blockConfig.type}]` }]; + }, + + renderHTML() { + const dom = document.createElement("div"); + dom.setAttribute("data-children-of", blockConfig.type); + return { dom, contentDOM: dom }; + }, + }); + + return { node, extraNodes: [contentNode, childrenNode] }; +} + +function buildRegularNode< TName extends string, TProps extends PropSchema, TContent extends "inline" | "none" | "table" | "plain", >( blockConfig: BlockConfig, blockImplementation: BlockImplementation, - extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, -): LooseBlockSpec { - const node = - ((blockImplementation as any).node as Node) || - Node.create({ - name: blockConfig.type, - content: (blockConfig.content === "inline" - ? "inline*" - : blockConfig.content === "plain" - ? "text*" - : blockConfig.content === "none" - ? "" - : blockConfig.content) as TContent extends "inline" - ? "inline*" - : TContent extends "plain" - ? "text*" - : "", - // "plain" blocks hold unstyled text, so they disallow formatting marks. - // They still allow the non-formatting marks (comments and - // suggestions/diffs) — those annotate content without changing it and are - // ignored by the block model. `nonFormattingMarks` resolves the group only - // when at least one such mark is registered, so a plain block in an editor - // without any of them doesn't reference an empty (unknown) mark group. - marks() { - return blockConfig.content === "plain" - ? nonFormattingMarks(this.editor) - : undefined; - }, - group: "blockContent", - selectable: blockImplementation.meta?.selectable ?? true, - isolating: blockImplementation.meta?.isolating ?? true, - code: blockImplementation.meta?.code ?? false, - defining: blockImplementation.meta?.defining ?? true, - priority, - addAttributes() { - return propsToAttributes(blockConfig.propSchema); - }, +) { + return Node.create({ + name: blockConfig.type, + content: (blockConfig.content === "inline" + ? "inline*" + : blockConfig.content === "plain" + ? "text*" + : blockConfig.content === "none" + ? "" + : blockConfig.content) as TContent extends "inline" + ? "inline*" + : TContent extends "plain" + ? "text*" + : "", + // "plain" blocks hold unstyled text, so they disallow formatting marks. + // They still allow the non-formatting marks (comments and + // suggestions/diffs) — those annotate content without changing it and are + // ignored by the block model. `nonFormattingMarks` resolves the group only + // when at least one such mark is registered, so a plain block in an editor + // without any of them doesn't reference an empty (unknown) mark group. + marks() { + return blockConfig.content === "plain" + ? nonFormattingMarks(this.editor) + : undefined; + }, + group: "blockContent", + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + code: blockImplementation.meta?.code ?? false, + defining: blockImplementation.meta?.defining ?? true, + priority, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, - parseHTML() { - return getParseRules(blockConfig, blockImplementation); - }, + parseHTML() { + return getParseRules(blockConfig, blockImplementation); + }, + + renderHTML({ HTMLAttributes }) { + // renderHTML is used for copy/pasting content from the editor back into + // the editor, so we need to make sure the `blockContent` element is + // structured correctly as this is what's used for parsing blocks. We + // just render a placeholder div inside as the `blockContent` element + // already has all the information needed for proper parsing. + const div = document.createElement("div"); + return wrapInBlockStructure( + { + dom: div, + contentDOM: + blockConfig.content === "inline" || blockConfig.content === "plain" + ? div + : undefined, + }, + blockConfig.type, + {}, + blockConfig.propSchema, + blockImplementation.meta?.fileBlockAccept !== undefined, + HTMLAttributes, + ); + }, + + addNodeView() { + return (props) => { + // Gets the BlockNote editor instance + const editor = this.options.editor; + // Gets the block. Resolving this can't rely on `getPos()` alone — + // node views are constructed part-way through ProseMirror's + // reconciliation, where positions don't always line up with + // `view.state.doc` yet (see `getBlockFromNodeView`). + const block = getBlockFromNodeView( + props.getPos, + props.node, + props.view.state.doc, + ); + // Gets the custom HTML attributes for `blockContent` nodes + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; - renderHTML({ HTMLAttributes }) { - // renderHTML is used for copy/pasting content from the editor back into - // the editor, so we need to make sure the `blockContent` element is - // structured correctly as this is what's used for parsing blocks. We - // just render a placeholder div inside as the `blockContent` element - // already has all the information needed for proper parsing. - const div = document.createElement("div"); - return wrapInBlockStructure( + const nodeView = blockImplementation.render.call( { - dom: div, - contentDOM: - blockConfig.content === "inline" || - blockConfig.content === "plain" - ? div - : undefined, + blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, }, - blockConfig.type, - {}, - blockConfig.propSchema, - blockImplementation.meta?.fileBlockAccept !== undefined, - HTMLAttributes, + block as any, + editor as any, ); - }, - addNodeView() { - return (props) => { - // Gets the BlockNote editor instance - const editor = this.options.editor; - // Gets the block. Resolving this can't rely on `getPos()` alone — - // node views are constructed part-way through ProseMirror's - // reconciliation, where positions don't always line up with - // `view.state.doc` yet (see `getBlockFromNodeView`). - const block = getBlockFromNodeView( - props.getPos, - props.node, - props.view.state.doc, - ); - // Gets the custom HTML attributes for `blockContent` nodes - const blockContentDOMAttributes = - this.options.domAttributes?.blockContent || {}; + // Cast needed because render returns `dom: HTMLElement | DocumentFragment` + // but tiptap's NodeView expects `dom: HTMLElement` + const typedNodeView = nodeView as unknown as NodeView; - const nodeView = blockImplementation.render.call( - { - blockContentDOMAttributes, - props, - renderType: "nodeView", - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ); + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, this.editor); + } - // Cast needed because render returns `dom: HTMLElement | DocumentFragment` - // but tiptap's NodeView expects `dom: HTMLElement` - const typedNodeView = nodeView as unknown as NodeView; + // Ignores DOM mutations that don't affect the block's content, so + // that browser extensions which rewrite the DOM (e.g. Dark Reader) + // can't trigger an infinite re-render loop that freezes the tab. + ignoreNonContentMutations(typedNodeView); + + // See explanation for why `update` is not implemented for NodeViews + // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 + // TODO: in a future version, we might want to implement updates so that + // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220) + return typedNodeView; + }; + }, + }); +} - if (blockImplementation.meta?.selectable === false) { - applyNonSelectableBlockFix(typedNodeView, this.editor); - } +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function addNodeAndExtensionsToSpec< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + extensions?: (ExtensionFactoryInstance | Extension)[], + priority?: number, + schemaContext: BlockSchemaContext = NO_SCHEMA_CONTEXT, +): LooseBlockSpec { + const childrenConfig = getChildrenConfig(blockConfig); - // Ignores DOM mutations that don't affect the block's content, so - // that browser extensions which rewrite the DOM (e.g. Dark Reader) - // can't trigger an infinite re-render loop that freezes the tab. - ignoreNonContentMutations(typedNodeView); + if (childrenConfig && blockConfig.content === "table") { + throw new Error( + `Block "${blockConfig.type}" sets \`children\` but its \`content\` is "table". A table block cannot also hold child blocks.`, + ); + } - // See explanation for why `update` is not implemented for NodeViews - // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464 - // https://github.com/TypeCellOS/BlockNote/issues/220 - return typedNodeView; - }; - }, - }); + const isContainer = childrenConfig !== undefined; + + // A container with its own content is built from three nodes (see + // `buildContentContainerNode`); every other kind of block is a single node. + const built: { node: Node; extraNodes?: Node[] } = ( + blockImplementation as any + ).node + ? { node: (blockImplementation as any).node as Node } + : childrenConfig && blockConfig.content !== "none" + ? buildContentContainerNode( + blockConfig as unknown as BlockConfig< + TName, + TProps, + "inline" | "plain" + >, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "inline" | "plain" + >, + schemaContext, + priority, + ) + : childrenConfig + ? { + node: buildContainerNode( + blockConfig as unknown as BlockConfig, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "none" + >, + schemaContext, + priority, + ), + } + : { + node: buildRegularNode(blockConfig, blockImplementation, priority), + }; + + const { node, extraNodes } = built; if (node.name !== blockConfig.type) { throw new Error( @@ -303,11 +773,12 @@ export function addNodeAndExtensionsToSpec< implementation: { ...blockImplementation, node, + ...(extraNodes ? { extraNodes } : {}), render(block, editor) { const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - return blockImplementation.render.call( + const output = blockImplementation.render.call( { blockContentDOMAttributes, props: undefined, @@ -317,6 +788,18 @@ export function addNodeAndExtensionsToSpec< block as any, editor as any, ); + + if (isContainer) { + applyContainerAttributes( + containerRootDOM(output), + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + } + + return output; }, // TODO: this should not have wrapInBlockStructure and generally be a lot simpler // post-processing in externalHTMLExporter should not be necessary @@ -324,7 +807,7 @@ export function addNodeAndExtensionsToSpec< const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - return ( + const output = blockImplementation.toExternalHTML?.call( { blockContentDOMAttributes, propSchema: blockConfig.propSchema }, block as any, @@ -340,8 +823,19 @@ export function addNodeAndExtensionsToSpec< }, block as any, editor as any, - ) - ); + ); + + if (output && isContainer) { + applyContainerAttributes( + containerRootDOM(output), + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + } + + return output; }, }, extensions, @@ -452,6 +946,8 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + const isContainer = getChildrenConfig(blockConfig) !== undefined; + return { config: blockConfig, implementation: { @@ -470,6 +966,11 @@ export function createBlockSpec< return undefined; } + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -489,6 +990,11 @@ export function createBlockSpec< editor as any, ); + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + const nodeView = wrapInBlockStructure( output, block.type, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index cfd17b9d11..551963f417 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,7 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; -import { LooseBlockSpec } from "./types.js"; +import { BlockConfig, ChildrenConfig, LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -157,6 +157,26 @@ export function getBlockFromNodeView( } } +/** + * Applies custom `blockContent` DOM attributes to an element, merging (rather + * than overwriting) its class list. + */ +export function applyDOMAttributes( + dom: HTMLElement | DocumentFragment, + domAttributes: Record | undefined, +) { + if (!domAttributes || !(dom instanceof HTMLElement)) { + return; + } + for (const [attr, value] of Object.entries(domAttributes)) { + if (attr === "class") { + dom.className = mergeCSSClasses(dom.className, value); + } else { + dom.setAttribute(attr, value); + } + } +} + // Function that wraps the `dom` element returned from 'blockConfig.render' in a // `blockContent` div, which contains the block type and props as HTML // attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds @@ -232,6 +252,12 @@ export function createBlockSpecFromTiptapNode< node: Node; type: string; content: "inline" | "table" | "none" | "plain"; + // Declares the block's container semantics (child counts/repair etc.) even + // though the node itself is hand-written — the node's own content + // expression stays authoritative for the PM schema, while BlockNote-level + // behavior (repair, seeding, validation) reads this config. + children?: ChildrenConfig; + placement?: BlockConfig["placement"]; }, P extends PropSchema, >( @@ -244,6 +270,10 @@ export function createBlockSpecFromTiptapNode< type: config.type as T["type"], content: config.content, propSchema, + ...(config.children !== undefined ? { children: config.children } : {}), + ...(config.placement !== undefined + ? { placement: config.placement } + : {}), }, implementation: { node: config.node, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 8d7e203e61..82e9d60ebb 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,11 +1,7 @@ /** Define the main block types **/ // import { Extension, Node } from "@tiptap/core"; import type { Node, NodeViewRendererProps } from "@tiptap/core"; -import type { - Fragment, - Node as ProsemirrorNode, - Schema, -} from "prosemirror-model"; +import type { Fragment, Node as PMNode, Schema } from "prosemirror-model"; import type { ViewMutationRecord } from "prosemirror-view"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import type { @@ -67,6 +63,16 @@ export interface BlockConfigMeta< */ isolating?: boolean; + /** + * Whether this block type gets a side menu drag handle (and can be dragged + * by it). Applies to any block type, not just container blocks — e.g. a + * "locked" block can opt out of dragging entirely. A block that opts out is + * skipped when looking for a drag handle, so the handle falls through to the + * nearest draggable ancestor. + * @default true + */ + draggable?: boolean; + /** * Enables syntax highlighting of the contents of the block with the result of this callback */ @@ -80,6 +86,118 @@ export interface BlockConfigMeta< hasPreview?: boolean; } +/** + * What may appear in one child position of a container block. + * + * The two fields mirror the only two distinctions the document schema can + * make. Every regular block is the *same* ProseMirror node (`blockContainer`), + * so "only headings" is not expressible — and therefore is not offered. + * Container-type blocks are their own node type, so those are exact. + */ +export type ChildrenAllow = { + /** + * Whether regular (non-container) blocks are allowed. This cannot be + * narrowed to specific block types: paragraphs, headings and code blocks are + * indistinguishable at the node level. + * @default true + */ + blocks?: boolean; + /** + * Which container-block types are allowed. `true` for any container type, + * `false` for none, or an explicit list — enforced exactly by the schema. + * @default true + */ + containers?: boolean | readonly string[]; +}; + +/** How many children fill one slot. A bare number means exactly that many. */ +export type ChildCount = number | { min?: number; max?: number }; + +/** One position in an ordered {@link ChildrenConfig.sequence}. */ +export type ChildSlot = { + allow?: ChildrenAllow; + /** @default 1 — a slot names a position, so it holds one child unless told otherwise. */ + count?: ChildCount; +}; + +type ChildrenBehavior = { + /** + * Children to create the container with when it is inserted without an + * explicit `children` array. When omitted, BlockNote fills the container + * with whatever its content expression requires (usually one empty + * paragraph), so a container can never be created in an invalid state. + */ + default?: readonly PartialBlockNoDefaults[]; + /** + * As children are emptied out (Backspace merges the last child away, + * `replaceBlocks` deletes children, ...), drop the emptied children and — + * when fewer than the required number of non-empty children remain — + * replace the container with its survivors, or remove it entirely when none + * remain. Column lists use this so emptied columns disappear and a + * one-column list unwraps. + * + * Coupled to the child count, so it lives here rather than in `meta`: + * without it repair is a no-op, because ProseMirror's schema fitting always + * pads a container back up to its minimum with empty children, so + * "effectively below the minimum" can only be detected by discounting those. + * @default false + */ + unwrapWhenEmptied?: boolean; + /** + * Whether pressing Enter on an empty block that is the last child of this + * container moves that block out of (after) the container, list-style. + * Without it, a container as the last block in the document can trap the + * cursor, as Enter only ever creates new blocks *within* the container. + * @default true + */ + exitOnEnter?: boolean; +}; + +/** + * Marks a block as a *container*: a block whose body is other blocks, exposed + * as `block.children` at runtime. + * + * Two forms — a uniform body (`allow` plus `min`/`max`), or an ordered body + * (`sequence`). The uniform form is exactly sugar for a one-slot sequence. + */ +export type ChildrenConfig = ChildrenBehavior & + ( + | { + allow?: ChildrenAllow; + /** @default 1 */ + min?: number; + /** @default unbounded */ + max?: number; + sequence?: never; + } + | { + sequence: readonly ChildSlot[]; + allow?: never; + min?: never; + max?: never; + } + ); + +/** One slot of a {@link ChildrenConfig}, with every default filled in. */ +export type ResolvedSlot = { + blocks: boolean; + /** `true` for any container type; a (possibly empty) list otherwise. */ + containers: true | readonly string[]; + min: number; + max: number | undefined; +}; + +/** A {@link ChildrenConfig} with every default filled in. */ +export type ResolvedChildren = { + slots: ResolvedSlot[]; + /** Sum of the slots' minimums — what `unwrapWhenEmptied` compares against. */ + minCount: number; + maxCount: number | undefined; + default: readonly PartialBlockNoDefaults[] | undefined; + unwrapWhenEmptied: boolean; + exitOnEnter: boolean; +}; + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -106,8 +224,31 @@ export interface BlockConfig< * The content that the block supports */ content: C; - // TODO: how do you represent things that have nested content? - // e.g. tables, alerts (with title & content) + /** + * Makes this a *container* block: a block whose body is other blocks, + * exposed on `block.children`. The block's `render` places them via + * `contentRef` (React) / `contentDOM` (vanilla), the same way it would place + * inline content. + * + * Can be combined with `content: "inline"` / `"plain"`, in which case the + * block has its own content *and* children, and both are placed in that one + * editable region. Only `content: "table"` is incompatible. + * + * `children: {}` is the minimal container. + */ + children?: ChildrenConfig; + /** + * Where this block may be placed. + * + * - `"anywhere"` (default): anywhere a regular block goes — the document + * root, or nested under any other block. + * - `"containerOnly"`: only inside a container that names this type in its + * `children.allow.containers` (e.g. a `column` inside a `columnList`). + * + * Only meaningful for container blocks; regular blocks are always placeable + * anywhere. + */ + placement?: "anywhere" | "containerOnly"; } /** @@ -227,9 +368,11 @@ export type LooseBlockSpec< ) => { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; + /** See {@link BlockImplementation.render}'s `rootDOM`. */ + rootDOM?: HTMLElement | null; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -246,6 +389,12 @@ export type LooseBlockSpec< | undefined; node: Node; + /** + * Nodes the block's own node needs in the schema but which aren't blocks + * themselves — the generated content & children nodes of a container block + * that has its own content. Registered alongside `node`. + */ + extraNodes?: Node[]; }; extensions?: (Extension | ExtensionFactoryInstance)[]; }; @@ -286,9 +435,11 @@ export type BlockSpecs = { ) => { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; + /** See {@link BlockImplementation.render}'s `rootDOM`. */ + rootDOM?: HTMLElement | null; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -590,19 +741,31 @@ export type BlockImplementation< ) => { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; + /** + * The block author's own root element, when it isn't `dom` itself. React + * renders a node view through wrapper elements of its own, so the element + * ProseMirror is handed is not the one the author wrote — this points at + * the latter, and is what container attributes (`data-node-type`, + * `data-id`, prop `data-*`) are stamped onto. + * @default dom + */ + rootDOM?: HTMLElement | null; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; + destroy?: () => void; /** - * Called by ProseMirror when this block's node is updated (e.g. its content - * or props change). Return `true` to handle the update in place - keeping - * the existing DOM - or `false` to have the node view recreated via - * `render`. When omitted, ProseMirror keeps the node view and reconciles its - * `contentDOM` in place as long as the node type stays the same. + * Optional NodeView update hook. Called when the underlying ProseMirror + * node's attributes change (or its decorations change). Return `false` to + * tell ProseMirror to destroy and recreate the NodeView (i.e. re-run + * `render` from scratch). Return `true` (or `undefined`) when you have + * patched `dom` in-place and PM should keep the existing view. * - * Useful for blocks whose `render` builds custom DOM that needs to stay in - * sync with the node (e.g. a code block rendering a preview of its content). + * Only honored for container blocks (blocks with `children`), where + * recreating the node view would remount every child block — e.g. column + * resizing patches widths in place through this hook. Non-container + * blocks always recreate on attr changes (see + * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464). */ - update?: (node: ProsemirrorNode) => boolean; - destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; /** diff --git a/packages/core/src/schema/blocks/validateChildren.test.ts b/packages/core/src/schema/blocks/validateChildren.test.ts new file mode 100644 index 0000000000..f60bdf16eb --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildren.test.ts @@ -0,0 +1,252 @@ +// @vitest-environment node +import { describe, expect, it } from "vite-plus/test"; + +import type { ChildrenConfig } from "./types.js"; +import { validateChildrenConfigs } from "./validateChildren.js"; + +type ContainerFixture = { + children: ChildrenConfig; + placement?: "anywhere" | "containerOnly"; +}; + +function configsWith(containers: Record) { + return { + paragraph: { type: "paragraph", content: "inline" as const }, + heading: { type: "heading", content: "inline" as const }, + ...Object.fromEntries( + Object.entries(containers).map(([type, { children, placement }]) => [ + type, + { type, content: "none" as const, children, placement }, + ]), + ), + }; +} + +const validate = (containers: Record) => () => + validateChildrenConfigs(configsWith(containers)); + +describe("validateChildrenConfigs", () => { + it("accepts a plain container config", () => { + expect(validate({ callout: { children: { min: 1 } } })).not.toThrow(); + }); + + it("accepts the columnList shape (restricted children, min 2)", () => { + expect( + validate({ + grid: { + children: { + allow: { blocks: false, containers: ["gridCell"] }, + min: 2, + }, + }, + gridCell: { children: {}, placement: "containerOnly" }, + }), + ).not.toThrow(); + }); + + it("rejects unknown allow.containers entries", () => { + expect( + validate({ grid: { children: { allow: { containers: ["nope"] } } } }), + ).toThrow(/nope/); + }); + + // The bug this redesign exists to fix: `allowedBlocks: ["heading"]` used to + // compile to "any regular block" and validate as if it had restricted + // something. Naming a regular type is now a hard error rather than a lie. + it("rejects a regular block type in allow.containers", () => { + expect( + validate({ grid: { children: { allow: { containers: ["heading"] } } } }), + ).toThrow(/regular block, not a container block/); + }); + + it("rejects an allow that permits nothing", () => { + expect( + validate({ + grid: { children: { allow: { blocks: false, containers: false } } }, + }), + ).toThrow(/permits nothing/); + }); + + it("rejects container-only allow when the schema has no other containers", () => { + expect( + validate({ + grid: { children: { allow: { blocks: false, containers: true } } }, + }), + ).toThrow(/no other container block types/); + }); + + it("rejects negative or non-integer minimums", () => { + expect(validate({ callout: { children: { min: -1 } } })).toThrow( + /non-negative integer/, + ); + }); + + it("rejects a maximum smaller than the minimum", () => { + expect(validate({ callout: { children: { min: 3, max: 2 } } })).toThrow( + /greater than or equal/, + ); + }); + + it("rejects an empty sequence", () => { + expect(validate({ card: { children: { sequence: [] } } })).toThrow( + /must not be empty/, + ); + }); + + it("rejects `default` violating the child count", () => { + expect( + validate({ + callout: { children: { min: 2, default: [{ type: "paragraph" }] } }, + }), + ).toThrow(/fewer than the 2 required/); + }); + + it("rejects `default` containing a block the slot doesn't permit", () => { + expect( + validate({ + grid: { + children: { + allow: { blocks: false, containers: ["gridCell"] }, + min: 2, + default: [{ type: "paragraph" }, { type: "paragraph" }], + }, + }, + gridCell: { children: {}, placement: "containerOnly" }, + }), + ).toThrow(/not permitted/); + }); + + it("reports which sequence slot rejected a `default` entry", () => { + expect( + validate({ + card: { + children: { + sequence: [ + { allow: { blocks: false, containers: ["cardHeader"] } }, + { allow: { containers: false }, count: { min: 1 } }, + ], + default: [{ type: "paragraph" }, { type: "paragraph" }], + }, + }, + cardHeader: { children: {}, placement: "containerOnly" }, + }), + ).toThrow(/`sequence` slot 0/); + }); + + // A `default` may legitimately skip a leading optional slot whose `min` is + // already met — the walker must move past it rather than forcing the child + // into a slot that doesn't accept it. + it("accepts a `default` that skips a leading optional slot", () => { + expect( + validate({ + card: { + children: { + sequence: [ + { + allow: { blocks: false, containers: ["cardHeader"] }, + count: { min: 0, max: 1 }, + }, + { allow: { blocks: true }, count: { min: 1 } }, + ], + default: [{ type: "paragraph" }], + }, + }, + cardHeader: { children: {}, placement: "containerOnly" }, + }), + ).not.toThrow(); + }); + + it("rejects placement on a block that isn't a container", () => { + expect(() => + validateChildrenConfigs({ + paragraph: { + type: "paragraph", + content: "inline", + placement: "containerOnly", + }, + }), + ).toThrow(/only applies to container blocks/); + }); + + it("rejects a containerOnly block no container accepts", () => { + expect( + validate({ + grid: { + children: { + allow: { blocks: false, containers: ["gridCell"] }, + min: 2, + }, + }, + gridCell: { + children: { allow: { containers: false } }, + placement: "containerOnly", + }, + orphan: { + children: { allow: { containers: false } }, + placement: "containerOnly", + }, + }), + ).toThrow(/could never be inserted/); + }); + + // A container may have its own content: it becomes a node holding a content + // node and a children node. + it("accepts `children` on a block with inline content", () => { + expect(() => + validateChildrenConfigs({ + toggle: { type: "toggle", content: "inline", children: { min: 1 } }, + }), + ).not.toThrow(); + }); + + it("rejects `children` on a table block", () => { + expect(() => + validateChildrenConfigs({ + bad: { type: "bad", content: "table", children: { min: 1 } }, + }), + ).toThrow(/cannot be combined with `content: "table"`/); + }); + + // The content & children nodes are generated from the block type, so a block + // type that happens to have one of those names would clash with them. + it("rejects a block type that collides with a generated node name", () => { + expect(() => + validateChildrenConfigs({ + toggle: { type: "toggle", content: "inline", children: {} }, + toggle__content: { type: "toggle__content", content: "inline" }, + }), + ).toThrow(/collides with the block type of the same name/); + }); + + // `fillBefore` recurses across node types, so a cycle blows the stack rather + // than returning null — it has to be caught before the schema is built. + it("rejects a container cycle", () => { + expect( + validate({ + card: { + children: { allow: { blocks: false, containers: ["cardBody"] } }, + }, + cardBody: { + children: { allow: { blocks: false, containers: ["card"] } }, + placement: "containerOnly", + }, + }), + ).toThrow(/requires it back/); + }); + + it("accepts a mutual reference when one side allows regular blocks", () => { + // A slot that accepts regular blocks can always be filled with a + // paragraph, so it breaks the cycle. + expect( + validate({ + card: { + children: { allow: { blocks: false, containers: ["cardBody"] } }, + }, + cardBody: { + children: { allow: { blocks: true, containers: ["card"] } }, + placement: "containerOnly", + }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts new file mode 100644 index 0000000000..ea7f8df8eb --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -0,0 +1,392 @@ +import { + containerChildrenNodeName, + containerContentNodeName, + getChildrenConfig, + isContainerType, + isPlaceableAnywhere, + resolveChildren, +} from "./children.js"; +import type { + BlockConfig, + ChildrenConfig, + ResolvedChildren, + ResolvedSlot, +} from "./types.js"; + +type ValidatableConfig = Pick & { + children?: ChildrenConfig; + placement?: BlockConfig["placement"]; +}; + +/** + * Validates the `children` config of every block in a schema, so that + * misconfigurations surface as a clear error at schema-creation time instead + * of as an opaque ProseMirror one (or a stack overflow) much later. + * + * @param blockConfigs The configs of every block in the schema, keyed by type. + */ +export function validateChildrenConfigs( + blockConfigs: Record, +) { + const isContainerBlockType = (blockType: string) => + !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]); + + for (const [type, config] of Object.entries(blockConfigs)) { + const children = getChildrenConfig(config); + + if (!children) { + if (config.placement !== undefined) { + fail( + type, + "`placement` only applies to container blocks, but this block does not declare `children`. Regular blocks can always be placed anywhere.", + ); + } + continue; + } + + validateOne(type, config, children, blockConfigs, isContainerBlockType); + } + + validateContainerOnlyIsReachable(blockConfigs); + validateNoCycles(blockConfigs, isContainerBlockType); +} + +function fail(type: string, message: string): never { + throw new Error( + `Invalid \`children\` config for block "${type}": ${message}`, + ); +} + +function validateOne( + type: string, + config: ValidatableConfig, + children: ChildrenConfig, + blockConfigs: Record, + isContainerBlockType: (blockType: string) => boolean, +) { + // A container may have its own content: it then becomes a node holding a + // content node and a children node. A table can't — its content is already + // a node tree of its own, with nowhere to put the children node. + if (config.content === "table") { + fail( + type, + '`children` cannot be combined with `content: "table"`. A table block\'s content is already a structure of its own.', + ); + } + + if (config.content !== "none") { + // The content & children nodes are generated from the block type, so a + // block type that happens to have the generated name would silently + // overwrite one of them. + for (const generated of [ + containerContentNodeName(type), + containerChildrenNodeName(type), + ]) { + if (generated in blockConfigs) { + fail( + type, + `it has its own content as well as \`children\`, so it generates a node named "${generated}" — which collides with the block type of the same name. Rename one of the two.`, + ); + } + } + } + + if (children.sequence && children.sequence.length === 0) { + fail(type, "`sequence` must not be empty. Omit it for a uniform body."); + } + + const resolved = resolveChildren(children); + + resolved.slots.forEach((slot, index) => { + const where = + resolved.slots.length === 1 ? "" : ` (in \`sequence\` slot ${index})`; + + if (!Number.isInteger(slot.min) || slot.min < 0) { + fail( + type, + `minimum child count must be a non-negative integer, but is ${slot.min}${where}.`, + ); + } + if (slot.max !== undefined) { + if (!Number.isInteger(slot.max) || slot.max < 1) { + fail( + type, + `maximum child count must be a positive integer, but is ${slot.max}${where}.`, + ); + } + if (slot.max < slot.min) { + fail( + type, + `maximum child count (${slot.max}) must be greater than or equal to the minimum (${slot.min})${where}.`, + ); + } + } + + validateAllow(type, slot, where, blockConfigs, isContainerBlockType); + }); + + validateDefault(type, resolved, blockConfigs, isContainerBlockType); +} + +function validateAllow( + type: string, + slot: ResolvedSlot, + where: string, + blockConfigs: Record, + isContainerBlockType: (blockType: string) => boolean, +) { + if (slot.containers !== true) { + for (const allowed of slot.containers) { + if (!(allowed in blockConfigs)) { + fail( + type, + `\`allow.containers\` contains "${allowed}", which is not a block type in this schema${where}.`, + ); + } + // The whole point of splitting `allow` into `blocks` and `containers` is + // that each field maps onto something the schema can actually enforce. + // Silently accepting a regular block type here would put us right back + // to promising a restriction we cannot keep. + if (!isContainerBlockType(allowed)) { + fail( + type, + `\`allow.containers\` contains "${allowed}", which is a regular block, not a container block${where}. ` + + "BlockNote cannot restrict which regular block types a container accepts — every regular block is the same ProseMirror node. " + + "Use `allow: { blocks: true }` to accept regular blocks, or `allow: { blocks: false }` to reject them all.", + ); + } + } + } + + if ( + !slot.blocks && + slot.containers !== true && + slot.containers.length === 0 + ) { + fail( + type, + `\`allow\` permits nothing${where}. A container must accept at least one block or container type; drop \`children\` entirely for a block that holds none.`, + ); + } + + if (!slot.blocks && slot.containers === true) { + const hasContainer = Object.entries(blockConfigs).some( + ([blockType]) => isContainerBlockType(blockType) && blockType !== type, + ); + if (!hasContainer) { + fail( + type, + `\`allow\` permits only container blocks${where}, but this schema has no other container block types.`, + ); + } + } +} + +function validateDefault( + type: string, + resolved: ResolvedChildren, + blockConfigs: Record, + isContainerBlockType: (blockType: string) => boolean, +) { + const { default: defaultChildren, minCount, maxCount } = resolved; + if (!defaultChildren) { + return; + } + + if (defaultChildren.length < minCount) { + fail( + type, + `\`default\` has ${defaultChildren.length} block(s), fewer than the ${minCount} required.`, + ); + } + if (maxCount !== undefined && defaultChildren.length > maxCount) { + fail( + type, + `\`default\` has ${defaultChildren.length} block(s), more than the ${maxCount} allowed.`, + ); + } + + // Walk the slots greedily so a `sequence` reports which position rejected + // the child rather than just "not permitted". + let slotIndex = 0; + let filled = 0; + for (const child of defaultChildren) { + const childType = child.type ?? "paragraph"; + if (!(childType in blockConfigs)) { + fail( + type, + `\`default\` contains a block of type "${childType}", which is not a block type in this schema.`, + ); + } + + // Advance past any slot this child can't land in: one already at its `max`, + // or one that doesn't accept this type but has already met its `min` (so + // skipping it still leaves a valid fill). A slot still below its `min` + // can't be skipped — doing so would leave the default short on that slot. + while (slotIndex < resolved.slots.length) { + const candidate = resolved.slots[slotIndex]; + const isFull = filled >= (candidate.max ?? Infinity); + const skippableMismatch = + filled >= candidate.min && + !slotAccepts(candidate, childType, isContainerBlockType); + if (!isFull && !skippableMismatch) { + break; + } + slotIndex++; + filled = 0; + } + const slot = resolved.slots[slotIndex]; + if (!slot) { + fail(type, `\`default\` has more blocks than \`sequence\` has room for.`); + } + + if (!slotAccepts(slot, childType, isContainerBlockType)) { + const where = + resolved.slots.length === 1 ? "" : ` in \`sequence\` slot ${slotIndex}`; + fail( + type, + `\`default\` contains a block of type "${childType}", which is not permitted${where}.`, + ); + } + filled++; + } +} + +/** + * Whether a slot accepts a block type. Honest by construction: the schema's + * only lever for regular blocks is "is `blockContainer` in the union or not", + * so that is exactly what this asks. + */ +export function slotAccepts( + slot: ResolvedSlot, + blockType: string, + isContainerBlockType: (blockType: string) => boolean, +): boolean { + if (isContainerBlockType(blockType)) { + return slot.containers === true || slot.containers.includes(blockType); + } + return slot.blocks; +} + +/** + * Container nodes register in a priority band strictly below `blockContainer` + * (see `containerNodePriority`), which is below every regular block. So a + * container's `runsBefore` can only order it against other containers — naming + * a regular block there promises an ordering the schema cannot produce. + * + * @param blockConfigs The configs of every block in the schema, keyed by type. + * @param runsBefore The `runsBefore` each block's implementation declares. + */ +export function validateContainerRunsBefore( + blockConfigs: Record, + runsBefore: Record, +) { + for (const [type, config] of Object.entries(blockConfigs)) { + if (!isContainerType(config)) { + continue; + } + + for (const other of runsBefore[type] ?? []) { + // "default" is `sortByDependencies`' reference point rather than a block + // type, and a type that isn't in the schema is somebody else's error. + if (other === "default" || !(other in blockConfigs)) { + continue; + } + if (!isContainerType(blockConfigs[other])) { + throw new Error( + `Invalid \`runsBefore\` for container block "${type}": it names "${other}", which is a regular block, not a container block. ` + + "Container block nodes always register below regular ones, so a container can never be ordered before a regular block. " + + "`runsBefore` on a container can only name other container blocks.", + ); + } + } + } +} + +/** + * A `placement: "containerOnly"` block that no container accepts could never + * be inserted anywhere, which is always a mistake rather than a choice. + * + * Deliberately conservative: one container allowing *any* container type is + * taken as accepting all of them. Proving the stricter thing (that the block + * is reachable from a block placeable at the root) is full graph reachability, + * and this check exists to catch typos, not to police schema topology. + */ +function validateContainerOnlyIsReachable( + blockConfigs: Record, +) { + const accepted = new Set(); + for (const config of Object.values(blockConfigs)) { + const children = getChildrenConfig(config); + if (!children) { + continue; + } + for (const slot of resolveChildren(children).slots) { + if (slot.containers === true) { + return; // some container accepts every container type + } + for (const allowed of slot.containers) { + accepted.add(allowed); + } + } + } + + for (const [type, config] of Object.entries(blockConfigs)) { + if (!isPlaceableAnywhere(config) && !accepted.has(type)) { + fail( + type, + `it declares \`placement: "containerOnly"\`, but no container's \`children.allow.containers\` includes it, so it could never be inserted.`, + ); + } + } +} + +/** + * A container that requires a child which in turn requires it back can never + * be created: ProseMirror's `fillBefore` recurses across node types and blows + * the stack rather than returning `null`. So this has to be caught statically, + * before the schema is built. + */ +function validateNoCycles( + blockConfigs: Record, + isContainerBlockType: (blockType: string) => boolean, +) { + // A slot that allows regular blocks can always be filled with a plain + // paragraph, so it never forces recursion — only container-only slots do. + const requiredContainers = (type: string): string[] => { + const children = getChildrenConfig(blockConfigs[type]); + if (!children) { + return []; + } + return resolveChildren(children).slots.flatMap((slot) => + slot.min >= 1 && !slot.blocks && slot.containers !== true + ? slot.containers.filter(isContainerBlockType) + : [], + ); + }; + + const state = new Map(); + + const visit = (type: string, path: string[]) => { + const seen = state.get(type); + if (seen === "done") { + return; + } + if (seen === "visiting") { + fail( + type, + `it requires a child that requires it back (${[...path, type].join(" -> ")}), so it could never be created. Allow regular blocks in one of the slots to break the cycle.`, + ); + } + + state.set(type, "visiting"); + for (const next of requiredContainers(type)) { + visit(next, [...path, type]); + } + state.set(type, "done"); + }; + + for (const type of Object.keys(blockConfigs)) { + visit(type, []); + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..674a0bdb96 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,3 +1,10 @@ +// `children.js` and `validateChildren.js` are deliberately *not* re-exported +// wholesale: almost everything in them is machinery for compiling a `children` +// config into a ProseMirror content expression, which lives on +// `@blocknote/core/internal` (see `src/internal.ts`). Only the question a block +// author asks — "is this a container?" — belongs here; the config types come +// from `./blocks/types.js` below. +export { isContainerType } from "./blocks/children.js"; export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index a7a04e93dc..eba0017f75 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -16,6 +16,12 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "./index.js"; +import { isContainerType } from "./blocks/children.js"; +import type { BlockSchemaContext } from "./blocks/createSpec.js"; +import { + validateChildrenConfigs, + validateContainerRunsBefore, +} from "./blocks/validateChildren.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -91,6 +97,38 @@ export class CustomBlockNoteSchema< })), ); + // Container-ness is needed to build *other* blocks' nodes (a container's + // `allow.containers` maps block types to node terms, and only container + // types are their own node type), so it's resolved across the whole schema + // up front. Validation runs first so misconfigurations surface as clear + // errors rather than as opaque ProseMirror ones. + const blockConfigs = Object.fromEntries( + Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [ + key, + blockSpec.config, + ]), + ); + + validateChildrenConfigs(blockConfigs); + validateContainerRunsBefore( + blockConfigs, + Object.fromEntries( + Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [ + key, + blockSpec.implementation?.runsBefore, + ]), + ), + ); + + const isContainerBlockType = (blockType: string) => + !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]); + + const schemaContext: BlockSchemaContext = { + isContainerBlockType, + containerBlockTypes: () => + Object.keys(blockConfigs).filter(isContainerBlockType), + }; + const blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { return [ @@ -100,6 +138,7 @@ export class CustomBlockNoteSchema< blockSpec.implementation, blockSpec.extensions, getPriority(key), + schemaContext, ), ]; }), diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts index df0267f093..f752b48182 100644 --- a/packages/core/src/y/extensions/AttributionExtension.test.ts +++ b/packages/core/src/y/extensions/AttributionExtension.test.ts @@ -17,15 +17,14 @@ const editors: BlockNoteEditor[] = []; // No Yjs/collaboration needed — the extension's load plugin only cares that a // transaction adds a `y-attributed-*` mark, which we do directly below. function createEditor() { - const resolveUsers = vi.fn( - async (ids: string[]): Promise => - ids.map((id) => ({ - id, - username: `name-${id}`, - avatarUrl: "", - color: "#123456", - colorLight: "#abcdef", - })), + const resolveUsers = vi.fn(async (ids: string[]): Promise => + ids.map((id) => ({ + id, + username: `name-${id}`, + avatarUrl: "", + color: "#123456", + colorLight: "#abcdef", + })), ); const editor = BlockNoteEditor.create({ diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts index 37fb1fd4e9..7dc3f4253d 100644 --- a/packages/core/src/yjs/extensions/FixUpSchema.ts +++ b/packages/core/src/yjs/extensions/FixUpSchema.ts @@ -25,7 +25,15 @@ export const FixUpSchemaExtension = createExtension(({ editor }) => { // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) const jsonNode = JSON.parse(JSON.stringify(ret.toJSON())); - jsonNode.content[0].content[0].attrs.id = "initialBlockId"; + // The first fill of the doc's blockGroup is guaranteed to be a + // `blockContainer` (container block nodes register at lower priority + // precisely so auto-fill picks `blockContainer` first), but guard on + // the node actually carrying an id attr in case a custom schema + // changes that. + const firstBlock = jsonNode.content?.[0]?.content?.[0]; + if (firstBlock?.attrs && "id" in firstBlock.attrs) { + firstBlock.attrs.id = "initialBlockId"; + } cache = Node.fromJSON(schema, jsonNode); return cache; diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 2763b9723c..4f743d69fe 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ blocks: path.resolve(__dirname, "src/blocks/index.ts"), locales: path.resolve(__dirname, "src/i18n/index.ts"), extensions: path.resolve(__dirname, "src/extensions/index.ts"), + internal: path.resolve(__dirname, "src/internal.ts"), yjs: path.resolve(__dirname, "src/yjs/index.ts"), y: path.resolve(__dirname, "src/y/index.ts"), }, diff --git a/packages/core/vitestSetup.ts b/packages/core/vitestSetup.ts index bf9678c8f8..23642824a6 100644 --- a/packages/core/vitestSetup.ts +++ b/packages/core/vitestSetup.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach } from "vite-plus/test"; +// This setup file also runs for test files that opt into the plain `node` +// environment (`@vitest-environment node`), where there is no `window` at all. +// `__TEST_OPTIONS` (which drives deterministic block IDs) therefore hangs off +// `window` when there is one and off `globalThis` otherwise — the same +// resolution `UniqueID`'s `generateID` uses. +const testHost: any = (globalThis as any).window ?? globalThis; + beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; + testHost.__TEST_OPTIONS = {}; }); afterEach(() => { - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + delete testHost.__TEST_OPTIONS; }); // Mock ClipboardEvent diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 2bf0e4fa57..57f340afb2 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -1,4 +1,4 @@ -import { getNodeById } from "@blocknote/core"; +import { getNodeById, isContainerNode } from "@blocknote/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -29,6 +29,28 @@ export const BlockPopover = ( return undefined; } + // For container blocks the PM node IS the block, so a position + // inside it resolves to its contentDOM — the child-blocks area — + // which would anchor the popover to the first child's rows instead + // of the block's own element. + if (isContainerNode(nodePosInfo.node.type)) { + const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode); + // Frameworks like React wrap the node view in a `display: contents` + // element that has no box of its own (a zero-size bounding rect), so + // anchoring to it would place the popover at (0, 0). The block's + // actual box is the author's root element inside it, which core + // stamps with `data-node-type`; vanilla containers render that boxed + // element directly as the node view's DOM. + if (dom instanceof Element) { + const boxed = dom.matches("[data-node-type]") + ? dom + : dom.querySelector("[data-node-type]"); + if (boxed) { + return { element: boxed }; + } + } + } + const { node } = editor.prosemirrorView.domAtPos( nodePosInfo.posBeforeNode + 1, ); diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 507f2cd46f..c3a39005a5 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -111,6 +111,13 @@ width: 100%; } +/* Container blocks own their outer DOM: the block's root element is the one + its `render` returned, so the wrapper React needs around it must not be a + box of its own. */ +.bn-react-node-view-renderer.bn-container-node-view { + display: contents; +} + /* Indent line styling */ .bn-block-group .bn-block:not(:has(.bn-toggle-wrapper)) diff --git a/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx new file mode 100644 index 0000000000..3d8e736e48 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx @@ -0,0 +1,168 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +/** + * The DOM output of React container blocks, in a real browser. + * + * Both halves need a DOM that actually exists: the external-HTML path renders + * the block through a temporary `createRoot` (see `@util/ReactRenderUtil`), and + * a React node view only runs at all once `contentComponent` is set, which + * happens when `BlockNoteViewRaw` mounts the editor. Runs in the tests + * package's browser suite; the document-level assertions live next door in + * `ReactBlockSpec.container.test.tsx` (node). + * + * Layout facts (`display: contents` hosts, box geometry) and the content + + * children region ordering are covered by + * `tests/src/end-to-end/containerblocks/containerblocks.test.tsx`. + */ + +// A pure container: its `contentRef` element holds its child blocks. +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { flavor: { default: "tip" } }, + content: "none", + children: { min: 1, default: [{ type: "paragraph" }] }, + }, + { + render: (props) => ( +
+
+
+ ), + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { callout: createCallout() }, +}); + +describe("React container block external HTML", () => { + it("does not wrap containers in a blockContent div", () => { + // Headless: the React `toExternalHTML` path renders through a temporary + // React root, which is why this still needs a real document. + const editor = BlockNoteEditor.create({ schema }); + + const html = editor.blocksToHTMLLossy([ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }], + }, + ] as any); + + // Container blocks own their outer DOM entirely — regression test for the + // React `toExternalHTML` path wrapping them in a spurious + // `bn-block-content` div (core's `createBlockSpec` passes them through). + expect(html).not.toContain('data-content-type="callout"'); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain("Hello"); + }); + + it("puts container attributes on the block's own root element", () => { + // The serialized root is the element the block's `render` returned — no + // wrapper of React's in between — so `.callout[data-flavor]` CSS matches + // the same element here as it does in the live editor (below). + const editor = BlockNoteEditor.create({ schema }); + + const html = editor.blocksToHTMLLossy([ + { type: "callout", id: "c-0", children: [{ type: "paragraph" }] }, + ] as any); + + expect(html).toContain('class="callout"'); + expect(html).toContain('data-node-type="callout"'); + expect(html).not.toContain("data-node-view-wrapper"); + }); +}); + +let root: Root | undefined; +let div: HTMLDivElement | undefined; +let editor: BlockNoteEditor | undefined; + +afterEach(() => { + root?.unmount(); + root = undefined; + if (div) { + document.body.removeChild(div); + div = undefined; + } + editor?._tiptapEditor.destroy(); + editor = undefined; +}); + +/** Lets TipTap's deferred node-view render and React's commit run. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function mountEditor(initialContent: any[]) { + div = document.createElement("div"); + document.body.appendChild(div); + + editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent, + }) as BlockNoteEditor; + + root = createRoot(div); + flushSync(() => { + root!.render(); + }); + // TipTap only renders a node view synchronously when this is set; BlockNote + // mounts the editor itself and never does, so the first batch of node views + // takes the deferred path (see `tests/src/unit/react/staleNodeViewPos.test.tsx`). + (editor as any)._tiptapEditor.isEditorContentInitialized = true; + await tick(); + + return { editor: editor!, div: div! }; +} + +describe("React container block node view", () => { + it("stamps only non-default props onto the block's own root, and keeps them in sync", async () => { + const mounted = await mountEditor([ + { id: "c-0", type: "callout", children: [{ type: "paragraph" }] }, + ]); + + const calloutRoot = mounted.div.querySelector(".callout")!; + // The author's element, not `div.react-renderer` or the node view wrapper — + // exactly the class the author wrote, and nothing else. + expect(calloutRoot.className).toBe("callout"); + expect(calloutRoot.getAttribute("data-id")).toBe("c-0"); + // `flavor` is at its default, so no attribute is written for it. + expect(calloutRoot.hasAttribute("data-flavor")).toBe(false); + + mounted.editor.updateBlock("c-0", { props: { flavor: "warning" } } as any); + await tick(); + + // Re-queried: a prop change must land on whatever element is now the + // block's root, so `.callout[data-flavor="warning"]` selects in the live + // editor exactly as it does in the serialized HTML above. + expect( + mounted.div + .querySelector(".callout")! + .getAttribute("data-flavor"), + ).toBe("warning"); + }); + + it("mounts a pure container's children inside its `contentRef` element", async () => { + const mounted = await mountEditor([ + { + id: "c-0", + type: "callout", + children: [{ id: "c-child", type: "paragraph", content: "Child" }], + }, + ]); + + const body = mounted.div.querySelector(".callout-body")!; + // A container with no content of its own puts its children where the + // author placed `contentRef` — not somewhere else in the node view. The + // child's own block element is a descendant, so this is structure, not + // just text that happened to bubble up. + expect(body.querySelector('[data-id="c-child"]')).not.toBeNull(); + expect(body.textContent).toBe("Child"); + }); +}); diff --git a/packages/react/src/schema/ReactBlockSpec.container.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.test.tsx new file mode 100644 index 0000000000..9a1af7d644 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx @@ -0,0 +1,179 @@ +/** + * @vitest-environment node + * + * Document-level tests for React container blocks. Every assertion here reads + * `editor.document`, so there is nothing to render and no DOM to need — the + * node environment keeps that honest. The DOM output (serialized HTML and the + * live node view) is covered by `ReactBlockSpec.container.browser.test.tsx`. + */ +import { + BlockNoteEditor, + BlockNoteSchema, + defaultBlockSpecs, +} from "@blocknote/core"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +// Same shape as the example callout block (`examples/06-custom-schema/09-container-block`). +const Callout = createReactBlockSpec( + { + type: "callout" as const, + propSchema: {}, + content: "none" as const, + children: { min: 1, default: [{ type: "paragraph" }] }, + }, + { + render: ({ contentRef }) => ( +
+
+
+ ), + }, +)(); + +// The additivity claim: adding `children` to an existing block is one config +// line and *zero* render changes. Both blocks below share this render, which is +// the shape every inline-content React block already has — `contentRef` on a +// plain div. `Alert` is `examples/06-custom-schema/01-alert-block` reduced to +// its structure; `AlertWithBody` is the same block with `children` added. +const renderAlert = ({ contentRef }: { contentRef: (el: any) => void }) => ( +
+
+
+
+); + +const Alert = createReactBlockSpec( + { + type: "alert" as const, + propSchema: { flavor: { default: "warning" } }, + content: "inline" as const, + }, + { render: renderAlert }, +)(); + +const AlertWithBody = createReactBlockSpec( + { + type: "alertWithBody" as const, + propSchema: { flavor: { default: "warning" } }, + content: "inline" as const, + children: { min: 1 }, + }, + { render: renderAlert }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + alert: Alert, + alertWithBody: AlertWithBody, + } as const, +}); + +const defaultParagraphProps = { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", +}; + +/** + * The document without block ids. Converting a block generates fresh ids, and + * core's deterministic test-id hook (`UniqueID`'s `generateID`) only kicks in + * when a `window` exists — under node it falls back to real UUIDs. Ids that + * matter are asserted individually. + */ +const withoutIds = (blocks: any[]): any[] => + blocks.map(({ id: _id, children, ...rest }) => ({ + ...rest, + children: withoutIds(children), + })); + +describe("React updateBlock → container with `default` (document-level)", () => { + const editor = BlockNoteEditor.create({ schema }); + + beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "" }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + }); + + it("converts an empty paragraph to a callout via editor.updateBlock", () => { + editor.updateBlock("p-0", { type: "callout" }); + + expect(withoutIds(editor.document)).toEqual([ + { + type: "callout", + props: {}, + // `content: "none"`, so the block has no inline content of its own — + // and the `children.default` seeded it exactly one empty paragraph. + content: undefined, + children: [ + { + type: "paragraph", + props: defaultParagraphProps, + content: [], + children: [], + }, + ], + }, + { + type: "paragraph", + props: defaultParagraphProps, + content: [], + children: [], + }, + ]); + // The block that wasn't converted keeps its identity. + expect(editor.document[1].id).toBe("trailing"); + }, 5000); +}); + +// The plan's headline claim: `children` is additive. Adding it to a block gives +// that block a body without touching its `render` — the block's `contentRef` +// element goes from holding just its inline content to holding its inline +// content followed by its child blocks. +describe("adding `children` to an existing block", () => { + const editor = BlockNoteEditor.create({ schema }); + + it("keeps the block without `children` unchanged", () => { + editor.replaceBlocks(editor.document, [ + { id: "a-0", type: "alert", content: "Heads up" }, + ] as any); + + const block = editor.getBlock("a-0")!; + expect(block.content).toEqual([ + { type: "text", text: "Heads up", styles: {} }, + ]); + expect(block.children).toEqual([]); + }, 5000); + + it("gains a body that accepts child blocks, with the same render", () => { + editor.replaceBlocks(editor.document, [ + { + id: "b-0", + type: "alertWithBody", + content: "Heads up", + children: [{ id: "b-child", type: "paragraph", content: "Details" }], + }, + ] as any); + + const block = editor.getBlock("b-0")!; + expect(block.content).toEqual([ + { type: "text", text: "Heads up", styles: {} }, + ]); + expect(block.children.map((child) => child.id)).toEqual(["b-child"]); + // The child is an ordinary block of the document, reachable by id. + expect(editor.getBlock("b-child")).toBeDefined(); + }, 5000); + + it("seeds a body when inserted without children", () => { + editor.replaceBlocks(editor.document, [ + { id: "c-0", type: "alertWithBody", content: "Heads up" }, + ] as any); + + expect(editor.getBlock("c-0")!.children).toHaveLength(1); + }, 5000); +}); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 5311d4e37d..9dff8ac9dc 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -1,3 +1,4 @@ +import { applyContainerAttributes } from "@blocknote/core/internal"; import { BlockConfig, BlockConfigOrCreator, @@ -6,11 +7,14 @@ import { BlockNoteEditor, BlockSpec, camelToDataKebab, + ChildrenConfig, CustomBlockImplementation, Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, + isContainerType, mergeCSSClasses, + nodeToBlock, Props, PropSchema, } from "@blocknote/core"; @@ -20,12 +24,29 @@ import { ReactNodeViewRenderer, useReactNodeView, } from "@tiptap/react"; -import { FC, ReactNode } from "react"; +import { CSSProperties, FC, ReactNode, useLayoutEffect } from "react"; import { renderToDOMSpec } from "./@util/ReactRenderUtil.js"; import { useNodeViewBlock } from "./useNodeViewBlock.js"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks +// A container block's root element is the block's own element, so every +// wrapper React puts above it has to contribute no box of its own. Module +// scope so the style object is referentially stable across renders. +const DISPLAY_CONTENTS: CSSProperties = { display: "contents" }; + +/** + * Whether the block has an editable region for its `render` to place: its + * inline content, its child blocks, or — for a container that also has its own + * content — both. Only a `content: "none"` block without `children` has + * nothing to place, and so is the only kind that doesn't get a `contentRef`. + */ +type HasEditableRegion = Config extends { children: ChildrenConfig } + ? true + : Config extends { content: "none" } + ? false + : true; + export type ReactCustomBlockRenderProps< B extends BlockConfigOrCreator, Config extends ExtractBlockConfigFromConfigOrCreator = @@ -33,11 +54,16 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; -} & (Config["content"] extends "inline" | "plain" - ? { - contentRef: (node: HTMLElement | null) => void; - } - : object); +} & (Config["content"] extends "table" + ? object + : HasEditableRegion extends true + ? { + // Points to where the block's editable region mounts: its inline + // content, its child blocks, or — for a container that has its own + // content — its content followed by its children. + contentRef: (node: HTMLElement | null) => void; + } + : object); // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< @@ -131,20 +157,20 @@ export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, const TContent extends "inline" | "none" | "plain", + // Inferred from the config object itself rather than widened to + // `BlockConfig<...>`, so `children` survives into the render props and + // `contentRef` is offered exactly when the block has an editable region. + const BlockConf extends BlockConfig, const TOptions extends Record | undefined = undefined, >( - blockConfigOrCreator: BlockConfig, + blockConfigOrCreator: BlockConf, blockImplementationOrCreator: - | ReactCustomBlockImplementation> + | ReactCustomBlockImplementation | (TOptions extends undefined - ? () => ReactCustomBlockImplementation< - BlockConfig - > + ? () => ReactCustomBlockImplementation : ( options: Partial, - ) => ReactCustomBlockImplementation< - BlockConfig - >), + ) => ReactCustomBlockImplementation), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] | (TOptions extends undefined @@ -152,7 +178,13 @@ export function createReactBlockSpec< : ( options: Partial, ) => (ExtensionFactoryInstance | Extension)[]), -): (options?: Partial) => BlockSpec; +): ( + options?: Partial, +) => BlockSpec< + BlockConf["type"], + BlockConf["propSchema"], + BlockConf["content"] +>; export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, @@ -230,10 +262,33 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { - const BlockContent = - blockImplementation.toExternalHTML || blockImplementation.render; + const isContainer = isContainerType(blockConfig); + const BlockContent = (blockImplementation.toExternalHTML || + blockImplementation.render) as FC; const output = renderToDOMSpec((refCB) => { - return ( + const content = ( + { + refCB(element); + if (element && !isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + context={context} + /> + ); + // A container block's render output *is* the block's root element. + // No wrapper of any kind, so the attributes core stamps + // afterwards land on the author's own element — the same element + // they land on in the live editor. + return isContainer ? ( + content + ) : ( - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - context={context} - /> + {content} ); }, editor); @@ -268,78 +310,200 @@ export function createReactBlockSpec< // constructed (itself guarded, via `getBlockFromNodeView`). Seeds // the fallback below so there is always something to render. const initialBlock = block; + // Container-ness is fixed per spec, so the node-view component + // can be chosen once — each variant is straight-line code using + // only the hooks and wrappers it needs. + const isContainer = isContainerType(blockConfig); + const BlockContent = blockImplementation.render as FC; + const blockContentDOMAttributes = this.blockContentDOMAttributes; + + // Set by the container node view's `NodeViewWrapper` below. The + // author's own root element is that wrapper's first element child; + // it's read lazily because React may not have committed yet when + // this node view is handed to core, and because the author's + // component is free to swap its root element on a re-render. + const wrapper: { current: HTMLElement | null } = { current: null }; + const authorRootDOM = () => + (wrapper.current?.firstElementChild as HTMLElement | null) ?? + null; + + // Vanilla JS node views are recreated on each update. However, + // using `ReactNodeViewRenderer` makes it so the node view is only + // created once, so the block we get in the node view will be + // outdated. Therefore, both variants have to (re-)resolve the + // block inside the `ReactNodeViewRenderer` component. - return ReactNodeViewRenderer( - (props: NodeViewProps) => { - // Vanilla JS node views are recreated on each update. However, - // using `ReactNodeViewRenderer` makes it so the node view is - // only created once, so the block we get in the node view will - // be outdated. Therefore, we have to get the block in the - // `ReactNodeViewRenderer` instead. That position can be stale, - // so resolving it is guarded (see `useNodeViewBlock`). - const block = useNodeViewBlock(props, initialBlock); + const ContainerNodeView = (props: NodeViewProps) => { + // Container blocks are bnBlock nodes (no `blockContainer` + // wrapper), so the id lives on the node's own attrs and the + // block resolves by id. Position-based resolution + // (`useNodeViewBlock`) would walk up to a *parent* bnBlock — + // the wrong block here — and ids are also immune to the stale + // positions it has to guard against. + const id = (props.node.attrs as Record).id; + if (!id) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute.`, + ); + } + // The id lookup misses when the node was just removed from the + // document (e.g. a suggestion-mode deletion still rendering); + // fall back to converting the node the view was handed. + const block = + editor.getBlock(id) ?? + nodeToBlock(props.node, props.view.state.doc); + + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } - const ref = useReactNodeView().nodeViewContentRef; + const selected = props.selected; - if (!ref) { - throw new Error("nodeViewContentRef is not set"); + // Stamped imperatively rather than spread as JSX props: the root + // element belongs to the block's author, so there is nothing to + // spread onto. Runs after every render, since both the block's + // props and the author's root element can change. + useLayoutEffect(() => { + const root = authorRootDOM(); + if (!root) { + return; } - const BlockContent = blockImplementation.render; - return ( - - { - ref(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - element.dataset.nodeViewContent = ""; - } - }} - /> - + applyContainerAttributes( + root, + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, ); - }, - { - className: "bn-react-node-view-renderer", - }, - )(this.props!) as ReturnType; - } else { - const BlockContent = blockImplementation.render; - const output = renderToDOMSpec((refCB) => { + + // ProseMirror marks the outermost element with + // `ProseMirror-selectednode`, and that element carries + // `display: contents` for containers — which suppresses any + // outline drawn on it. So the state is mirrored onto the + // author's root, which is the block's actual box. + if (selected) { + root.setAttribute("data-selected", ""); + } else { + root.removeAttribute("data-selected"); + } + }); + + return ( + + { + ref(element); + if (element) { + element.dataset.nodeViewContent = ""; + } + }} + /> + + ); + }; + + const RegularNodeView = (props: NodeViewProps) => { + // The node view's position can be stale mid-render, so + // resolving it is guarded (see `useNodeViewBlock`). + const block = useNodeViewBlock(props, initialBlock); + + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } + return ( { - refCB(element); + contentRef={(element: HTMLElement | null) => { + ref(element); if (element) { element.className = mergeCSSClasses( "bn-inline-content", element.className, ); + element.dataset.nodeViewContent = ""; } }} /> ); + }; + + const nodeView = ReactNodeViewRenderer( + isContainer ? ContainerNodeView : RegularNodeView, + { + // The container class is separate because it *removes* the + // box the regular class relies on (see `Block.css`). + className: isContainer + ? "bn-react-node-view-renderer bn-container-node-view" + : "bn-react-node-view-renderer", + }, + )(this.props!) as ReturnType; + + if (isContainer) { + // TipTap appends its content host into whichever element the + // block passed `contentRef` to. `display: contents` keeps that + // host from contributing a box, so the block's editable region + // lays out exactly where the author put the ref — and, for a + // container that has its own content, the content and children + // regions sit there as siblings. + if (nodeView.contentDOM) { + nodeView.contentDOM.style.display = "contents"; + } + // Where core stamps the container attributes: the author's own + // element, not React's outermost wrapper (`dom`). + Object.defineProperty(nodeView, "rootDOM", { + get: authorRootDOM, + }); + } + + return nodeView; + } else { + const isContainer = isContainerType(blockConfig); + const BlockContent = blockImplementation.render as FC; + const output = renderToDOMSpec((refCB) => { + const content = ( + { + refCB(element); + if (element && !isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + /> + ); + // See `toExternalHTML` above: a container block owns its outer + // DOM, so its render output is the block's root element. + return isContainer ? ( + content + ) : ( + + {content} + + ); }, editor); return output; } diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts index 02393a2fd0..74d2e84037 100644 --- a/packages/react/src/schema/useNodeViewBlock.ts +++ b/packages/react/src/schema/useNodeViewBlock.ts @@ -42,6 +42,17 @@ export function useNodeViewBlock( const lastBlockRef = useRef(initialBlock); const doc = props.view.state.doc; + // Position-based resolution finds the nearest bnBlock *parent* of the + // position — correct for blockContent node views, but wrong-by-construction + // for container blocks, whose node IS the bnBlock: it would return an + // ancestor block. Guarded loudly so a container node view can't silently + // render the wrong block. + if (props.node.type.isInGroup("bnBlock")) { + throw new Error( + `useNodeViewBlock cannot resolve container block "${props.node.type.name}": position-based resolution returns the nearest bnBlock parent, which is the wrong block when the node view's node is the block itself. Resolve container blocks by id instead, e.g. editor.getBlock(props.node.attrs.id).`, + ); + } + try { // Deliberate render-phase write: a monotonic "last good value" cache, so a // repeated render (e.g. StrictMode's double invoke) recomputes the same diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index 2a835469db..ee43b8792d 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,7 +1,7 @@ import react from "@vitejs/plugin-react"; import * as path from "path"; import { webpackStats } from "rollup-plugin-webpack-stats"; -import { defineConfig, type UserConfig } from "vite-plus"; +import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; @@ -24,6 +24,9 @@ export default defineConfig( test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], + // `.browser.test` files need a real browser; the tests package's + // browser suite runs them. + exclude: [...configDefaults.exclude, "**/*.browser.test.*"], }, plugins: [react(), webpackStats()], // used so that vitest resolves the core package from the sources instead of the built version diff --git a/packages/react/vitestSetup.ts b/packages/react/vitestSetup.ts index beafe25357..1c3619a84d 100644 --- a/packages/react/vitestSetup.ts +++ b/packages/react/vitestSetup.ts @@ -1,10 +1,21 @@ import { afterEach, beforeEach } from "vite-plus/test"; +// This setup file also runs for test files that opt into the plain `node` +// environment (`@vitest-environment node`), where there is no `window` at all — +// everything below is a DOM mock, so it is a no-op there. +const hasWindow = typeof window !== "undefined"; + beforeEach(() => { + if (!hasWindow) { + return; + } (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; }); afterEach(() => { + if (!hasWindow) { + return; + } delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; }); @@ -32,28 +43,30 @@ class DragEventMock extends Event { }, }; } -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => { - // - }, // Deprecated - removeListener: () => { - // - }, // Deprecated - addEventListener: () => { - // - }, - removeEventListener: () => { - // - }, - dispatchEvent: () => { - // - }, - }), -}); +if (hasWindow) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => { + // + }, // Deprecated + removeListener: () => { + // + }, // Deprecated + addEventListener: () => { + // + }, + removeEventListener: () => { + // + }, + dispatchEvent: () => { + // + }, + }), + }); +} (global as any).DragEvent = DragEventMock; diff --git a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts index fec31293a5..34d60aa6bf 100644 --- a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts +++ b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts @@ -80,7 +80,7 @@ function createCollabEditor(text: string) { function selectWholeFirstBlock(editor: BlockNoteEditor) { const id = editor.document[0].id; const info = getBlockInfo(getNodeById(id, editor.prosemirrorState.doc)!); - if (!info.isBlockContainer) { + if (!info.isWrappedBlock) { throw new Error("not a block container"); } const from = info.blockContent.beforePos + 1; diff --git a/packages/xl-ai/src/prosemirror/agent.test.ts b/packages/xl-ai/src/prosemirror/agent.test.ts index 44d87c8108..d2a7d9178b 100644 --- a/packages/xl-ai/src/prosemirror/agent.test.ts +++ b/packages/xl-ai/src/prosemirror/agent.test.ts @@ -39,7 +39,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -72,7 +72,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -98,7 +98,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -128,7 +128,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -157,7 +157,7 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } diff --git a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts index 21454b7b79..edd8a3b1bb 100644 --- a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts +++ b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts @@ -21,7 +21,7 @@ function getExampleEditorWithSuggestions() { const blockPos = getNodeById("1", editor.prosemirrorState.doc)!; const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -56,7 +56,7 @@ it("should be able to apply changes to a clean doc (use invertMap)", async () => const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } @@ -85,7 +85,7 @@ it("should be able to apply changes to a clean doc (use rebaseTr)", async () => const block = getBlockInfo(blockPos); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a container"); } diff --git a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts index 6262f505cb..8bbcb29315 100644 --- a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts +++ b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts @@ -47,7 +47,7 @@ export const combinedOperationsTestCases: DocumentOperationTestCase[] = [ const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } return { diff --git a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts index 2261863430..3d4d25f152 100644 --- a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts +++ b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts @@ -41,7 +41,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ getTestSelection: (editor: BlockNoteEditor) => { const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } return { @@ -68,7 +68,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ getTestSelection: (editor: BlockNoteEditor) => { const posInfo = getNodeById("ref1", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } // 'ello, world! Dow are yo' @@ -737,7 +737,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ getTestSelection(editor) { const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; const block = getBlockInfo(posInfo); - if (!block.isBlockContainer) { + if (!block.isWrappedBlock) { throw new Error("Block is not a block container"); } return { diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 16e45a304f..13b602ee91 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -1,5 +1,6 @@ import { BlockNoteSchema, + createBlockSpec, defaultBlockSpecs, createPageBreakBlockSpec, PartialBlock, @@ -415,6 +416,82 @@ describe("exporter", () => { ); }); +describe("custom container blocks", () => { + const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + children: { min: 1 }, + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "box"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, + )(); + + const boxSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + box: Box, + }, + }); + + const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [ + { + type: "box", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + it("passes children to a custom container mapping", async () => { + const exporter = new DOCXExporter( + boxSchema, + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + box: ( + _block: any, + _exporter: any, + _nesting: any, + _index: any, + children: any, + ) => + new Paragraph({ + children: [new TextRun(`BOX(${children?.length ?? 0})`)], + }), + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const transformed = await exporter.transformBlocks(boxDocument as any); + expect(transformed).toHaveLength(1); + const xml = JSON.stringify(transformed[0]); + expect(xml).toContain("BOX(2)"); + }); + + it("throws a clear error for an unmapped container block", async () => { + const exporter = new DOCXExporter( + boxSchema, + docxDefaultSchemaMappings as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow( + /container block type "box"/, + ); + }); +}); + function prettify(sourceXml: string) { let ret = xmlFormat(sourceXml); diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index f987ad4a7d..5caf6b5606 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -116,7 +116,7 @@ export class DOCXExporter< for (const b of blocks) { let children = await this.transformBlocks(b.children, nestingLevel + 1); - if (!["columnList", "column"].includes(b.type)) { + if (!this.isContainerBlock(b.type)) { children = children.map((c, _i) => { // NOTE: nested tables not supported (we can't insert the new Tab before a table) if ( @@ -139,7 +139,7 @@ export class DOCXExporter< 0 /*unused*/, children, ); // TODO: any - if (["columnList", "column"].includes(b.type)) { + if (this.isContainerBlock(b.type)) { ret.push(self as Table); } else if (Array.isArray(self)) { ret.push(...self, ...children); diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx index 5f4eecf3c5..df9fafdf61 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx @@ -246,6 +246,24 @@ export class ReactEmailExporter< i = nextIndex; continue; } + if (this.isContainerBlock(b.type)) { + // Container blocks (columnList, column, custom containers): the + // mapping owns the placement of the children, so they are passed in + // and not rendered as an indented sibling list. + const containerChildren = await this.transformBlocks( + b.children, + nestingLevel + 1, + ); + const containerSelf = (await this.mapBlock( + b as any, + nestingLevel, + 0, + containerChildren as any, + )) as any; + ret.push({containerSelf}); + i++; + continue; + } // Non-list blocks const children = await this.transformBlocks(b.children, nestingLevel + 1); const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any; diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index 2e49261ec6..1ffd9db2ee 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,28 +1,81 @@ +import { createBlockSpec } from "@blocknote/core"; + +import { ColumnResizeExtension } from "../../extensions/ColumnResize/ColumnResizeExtension.js"; import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js"; -import { Column } from "../../pm-nodes/Column.js"; -import { ColumnList } from "../../pm-nodes/ColumnList.js"; -import { createBlockSpecFromTiptapNode } from "@blocknote/core"; +const COLUMN_WIDTH_DEFAULT = 1; -export const ColumnBlock = createBlockSpecFromTiptapNode( +export const ColumnBlock = createBlockSpec( { - node: Column, - type: "column", + type: "column" as const, + propSchema: { + width: { + default: COLUMN_WIDTH_DEFAULT, + }, + }, content: "none", + children: { exitOnEnter: false }, + placement: "containerOnly", }, { - width: { - default: 1, + meta: { + draggable: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.className = "bn-block-column"; + dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT); + + return { + dom, + contentDOM: dom, + update: (newNode: { + type: { name: string }; + attrs: { width?: number }; + }) => { + if (newNode.type.name !== "column") { + return false; + } + dom.style.flexGrow = String( + newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT, + ); + return true; + }, + }; }, }, - [MultiColumnDropHandlerExtension()], -); + [MultiColumnDropHandlerExtension(), ColumnResizeExtension()], +)(); -export const ColumnListBlock = createBlockSpecFromTiptapNode( +export const ColumnListBlock = createBlockSpec( { - node: ColumnList, - type: "columnList", + type: "columnList" as const, + propSchema: {}, content: "none", + children: { + allow: { blocks: false, containers: ["column"] }, + min: 2, + unwrapWhenEmptied: true, + exitOnEnter: false, + }, + }, + { + meta: { + isolating: false, + draggable: false, + }, + render: () => { + const dom = document.createElement("div"); + dom.className = "bn-block-column-list"; + dom.style.display = "flex"; + + return { + dom, + contentDOM: dom, + update: (newNode: { type: { name: string } }) => { + return newNode.type.name === "columnList"; + }, + }; + }, }, - {}, -); +)(); diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts index 5713466a6d..2a6374c3e6 100644 --- a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts +++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts @@ -1,6 +1,5 @@ -import { BlockNoteEditor, getNodeById } from "@blocknote/core"; +import { BlockNoteEditor, createExtension, getNodeById } from "@blocknote/core"; import { SideMenuExtension } from "@blocknote/core/extensions"; -import { Extension } from "@tiptap/core"; import { Node } from "prosemirror-model"; import { Plugin, PluginKey, PluginView } from "prosemirror-state"; import { Decoration, DecorationSet, EditorView } from "prosemirror-view"; @@ -438,12 +437,7 @@ const createColumnResizePlugin = (editor: BlockNoteEditor) => view: (view) => new ColumnResizePluginView(editor, view), }); -export const createColumnResizeExtension = ( - editor: BlockNoteEditor, -) => - Extension.create({ - name: "columnResize", - addProseMirrorPlugins() { - return [createColumnResizePlugin(editor)]; - }, - }); +export const ColumnResizeExtension = createExtension(({ editor }) => ({ + key: "columnResize", + prosemirrorPlugins: [createColumnResizePlugin(editor)], +})); diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts index 77d93b7f4a..8f05068da3 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts @@ -1,4 +1,8 @@ -import { type DropCursorHooks, getNearestBlockPos } from "@blocknote/core"; +import { + type DropCursorHooks, + getNearestBlockPos, + isContainerNode, +} from "@blocknote/core"; import type { EditorState } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; @@ -31,10 +35,16 @@ export function detectEdgePosition( const blockPos = getNearestBlockPos(state.doc, eventPos.pos); - // If we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it - // Why? Because we want to insert a new column in the columnList, instead of a new columnList inside of the column + // If we're at a block inside a column of a columnList, we want to compare + // the mouse position to the column, not the block inside it. + // Why? Because we want to insert a new sibling column in the columnList + // instead of a new container inside the column. let resolved = state.doc.resolve(blockPos.posBeforeNode); - if (resolved.parent.type.name === "column") { + if ( + isContainerNode(resolved.parent.type) && + resolved.depth > 0 && + state.doc.resolve(resolved.before()).parent.type.name === "columnList" + ) { resolved = state.doc.resolve(resolved.before()); } diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index a762f78d96..4d8b020ddd 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -4,6 +4,7 @@ import { createExtension, fragmentToBlocks, getBlockInfo, + isContainerNode, nodeToBlock, } from "@blocknote/core"; import { Plugin } from "prosemirror-state"; @@ -42,7 +43,14 @@ export function createMultiColumnHandleDropPlugin( } const draggedBlockIds = new Set(draggedBlocks.map((block) => block.id)); - if (blockInfo.blockNoteType === "column") { + // Whether the edge target is a `columnList` (after `detectEdgePosition` + // hoisted blocks inside a column to the column itself, the target's + // parent is the columnList). + const $target = view.state.doc.resolve(blockInfo.bnBlock.beforePos); + const targetInHorizontalContainer = + $target.node().type.name === "columnList"; + + if (targetInHorizontalContainer) { // The user is dropping the target column's entire contents on the // column's own edge - the new column would just replace the // emptied target in the same position, so do nothing. This also @@ -57,16 +65,22 @@ export function createMultiColumnHandleDropPlugin( return true; } - // Insert new column in existing columnList - const parentBlock = view.state.doc - .resolve(blockInfo.bnBlock.beforePos) - .node(); + // Insert a new sibling child in the existing horizontal container + // (e.g. a new column in the columnList). + const parentBlock = $target.node(); const columnList = nodeToBlock( parentBlock, view.state.doc, ); + // Whether the horizontal container's children are typed child + // containers (like `column`) that wrap the actual blocks, or plain + // blocks spliced in directly. + const targetIsChildContainer = isContainerNode( + blockInfo.bnBlock.node.type, + ); + // Normalize column widths to average of 1 // In a `columnList`, we expect that the average width of each column // is 1. However, there are cases in which this stops being true. For @@ -74,24 +88,31 @@ export function createMultiColumnHandleDropPlugin( // the average width to go down. This isn't really an issue until the // user tries to add a new column, which will, in this case, be wider // than expected. Therefore, we normalize the column widths to an - // average of 1 here to avoid this issue. - let sumColumnWidthPercent = 0; - columnList.children.forEach((column) => { - sumColumnWidthPercent += column.props.width as number; - }); - const avgColumnWidthPercent = - sumColumnWidthPercent / columnList.children.length; - - // If the average column width is not 1, normalize it. We're dealing - // with floats so we need a small margin to account for precision - // errors. - if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { - const scalingFactor = 1 / avgColumnWidthPercent; - + // average of 1 here to avoid this issue. (Only applies to child + // containers with a numeric `width` prop, i.e. columns.) + if ( + columnList.children.every( + (column) => typeof column.props.width === "number", + ) + ) { + let sumColumnWidthPercent = 0; columnList.children.forEach((column) => { - column.props.width = - (column.props.width as number) * scalingFactor; + sumColumnWidthPercent += column.props.width as number; }); + const avgColumnWidthPercent = + sumColumnWidthPercent / columnList.children.length; + + // If the average column width is not 1, normalize it. We're + // dealing with floats so we need a small margin to account for + // precision errors. + if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { + const scalingFactor = 1 / avgColumnWidthPercent; + + columnList.children.forEach((column) => { + column.props.width = + (column.props.width as number) * scalingFactor; + }); + } } const targetColumnId = blockInfo.bnBlock.node.attrs.id; @@ -103,20 +124,26 @@ export function createMultiColumnHandleDropPlugin( const remainingColumns = columnList.children // If any of the dragged blocks are in one of the columns, remove // them. - .map((column) => ({ - ...column, - children: column.children.filter((block) => { - if (!draggedBlockIds.has(block.id)) { - return true; - } - - blocksAlreadyInColumnList.add(block.id); - return false; - }), - })) + .map((column) => + targetIsChildContainer + ? { + ...column, + children: column.children.filter((block) => { + if (!draggedBlockIds.has(block.id)) { + return true; + } + + blocksAlreadyInColumnList.add(block.id); + return false; + }), + } + : column, + ) // Remove empty columns (can happen when dragged blocks are // removed). - .filter((column) => column.children.length > 0); + .filter( + (column) => !targetIsChildContainer || column.children.length > 0, + ); // The insertion index is computed on the remaining columns, as // removing an emptied column before the drop target shifts the @@ -134,15 +161,22 @@ export function createMultiColumnHandleDropPlugin( const insertionIndex = edgePos.position === "left" ? targetIndex : targetIndex + 1; - // Insert the dragged blocks as a new column in the correct - // position. - const newChildren = remainingColumns.toSpliced(insertionIndex, 0, { - type: "column", - children: draggedBlocks, - props: {}, - content: undefined, - id: UniqueID.options.generateID(), - }); + // Insert the dragged blocks in the correct position, wrapped in a + // new child container (e.g. a new `column`) when the container's + // children are typed containers. + const newChildren = remainingColumns.toSpliced( + insertionIndex, + 0, + targetIsChildContainer + ? { + type: blockInfo.blockNoteType, + children: draggedBlocks, + props: {}, + content: undefined, + id: UniqueID.options.generateID(), + } + : draggedBlocks[0], + ); const blocksToRemove = draggedBlocks.filter( (block) => diff --git a/packages/xl-multi-column/src/pm-nodes/Column.ts b/packages/xl-multi-column/src/pm-nodes/Column.ts deleted file mode 100644 index dccf60c74b..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/Column.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { suggestionMarks } from "@blocknote/core"; -import { Node } from "@tiptap/core"; - -import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js"; - -export const Column = Node.create({ - name: "column", - group: "bnBlock childContainer", - // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "blockContainer+", - priority: 40, - defining: true, - marks() { - return suggestionMarks(this.editor); - }, - addAttributes() { - return { - width: { - // Why does each column have a default width of 1, i.e. 100%? Because - // when creating a new column, we want to make sure that existing - // column widths are preserved, while the new one also has a sensible - // width. If we'd set it so all column widths must add up to 100% - // instead, then each time a new column is created, we'd have to assign - // it a width depending on the total number of columns and also adjust - // the widths of the other columns. The same can be said for using px - // instead of percent widths and making them add to the editor width. So - // using this method is both simpler and computationally cheaper. This - // is possible because we can set the `flex-grow` property to the width - // value, which handles all the resizing for us, instead of manually - // having to set the `width` property of each column. - default: 1, - parseHTML: (element) => { - const attr = element.getAttribute("data-width"); - if (attr === null) { - return null; - } - - const parsed = parseFloat(attr); - if (isFinite(parsed)) { - return parsed; - } - - return null; - }, - renderHTML: (attributes) => { - return { - "data-width": (attributes.width as number).toString(), - style: `flex-grow: ${attributes.width as number};`, - }; - }, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === this.name) { - return {}; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const column = document.createElement("div"); - column.className = "bn-block-column"; - column.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - column.setAttribute(attribute, value as any); // TODO as any - } - - return { - dom: column, - contentDOM: column, - }; - }, - - addExtensions() { - return [createColumnResizeExtension(this.options.editor)]; - }, -}); diff --git a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts deleted file mode 100644 index eeb06f4d4e..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { suggestionMarks } from "@blocknote/core"; -import { Node } from "@tiptap/core"; - -export const ColumnList = Node.create({ - name: "columnList", - group: "childContainer bnBlock blockGroupChild", - // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "column column+", // min two columns - priority: 40, // should be below blockContainer - defining: true, - marks() { - return suggestionMarks(this.editor); - }, - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === this.name) { - return {}; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const columnList = document.createElement("div"); - columnList.className = "bn-block-column-list"; - columnList.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - columnList.setAttribute(attribute, value as any); // TODO as any - } - columnList.style.display = "flex"; - - return { - dom: columnList, - contentDOM: columnList, - }; - }, -}); diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap similarity index 95% rename from packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap rename to packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap index 87b5f2e588..a5d8ddf91f 100644 --- a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test fixColumnList > First of two columns empty 1`] = ` +exports[`Test fixContainer > First of two columns empty 1`] = ` { "content": [ { @@ -35,7 +35,7 @@ exports[`Test fixColumnList > First of two columns empty 1`] = ` } `; -exports[`Test fixColumnList > Last of two columns empty 1`] = ` +exports[`Test fixContainer > Last of two columns empty 1`] = ` { "content": [ { @@ -70,7 +70,7 @@ exports[`Test fixColumnList > Last of two columns empty 1`] = ` } `; -exports[`Test fixColumnList > Two empty columns 1`] = ` +exports[`Test fixContainer > Two empty columns 1`] = ` { "content": [ { @@ -99,7 +99,7 @@ exports[`Test fixColumnList > Two empty columns 1`] = ` } `; -exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` +exports[`Test removeEmptyChildren > First of two columns empty 1`] = ` { "content": [ { @@ -176,7 +176,7 @@ exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` +exports[`Test removeEmptyChildren > Last of two columns empty 1`] = ` { "content": [ { @@ -253,7 +253,7 @@ exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` +exports[`Test removeEmptyChildren > Start and end columns empty 1`] = ` { "content": [ { @@ -336,7 +336,7 @@ exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Two empty columns 1`] = ` +exports[`Test removeEmptyChildren > Two empty columns 1`] = ` { "content": [ { diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts similarity index 91% rename from packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts rename to packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts index b5bd190c6d..d41cc00f72 100644 --- a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts +++ b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from "vite-plus/test"; import { setupTestEnv } from "../../setupTestEnv.js"; import { - fixColumnList, - isEmptyColumn, - removeEmptyColumns, -} from "@blocknote/core"; + fixContainer, + isEmptyContainerChild, + removeEmptyChildren, +} from "@blocknote/core/internal"; const getEditor = setupTestEnv(); -describe("Test isEmptyColumn", () => { +describe("Test isEmptyContainerChild", () => { it("Empty blocks", () => { const schema = getEditor()._tiptapEditor.schema; @@ -19,7 +19,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeTruthy(); + expect(isEmptyContainerChild(column)).toBeTruthy(); }); it("Multiple blocks", () => { @@ -34,7 +34,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Block with children", () => { @@ -51,7 +51,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Block with text", () => { @@ -65,7 +65,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Non-text block", () => { @@ -77,11 +77,11 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); }); -describe("Test removeEmptyColumns", () => { +describe("Test removeEmptyChildren", () => { it("Start and end columns empty", () => { const editor = getEditor(); const schema = editor._tiptapEditor.schema; @@ -116,7 +116,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -143,7 +143,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -170,7 +170,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -195,13 +195,13 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); }); -describe("Test fixColumnList", () => { +describe("Test fixContainer", () => { it("First of two columns empty", () => { const editor = getEditor(); const schema = editor._tiptapEditor.schema; @@ -224,7 +224,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -251,7 +251,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -276,7 +276,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html index 2237513b6b..72b0f2d7ab 100644 --- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html +++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html @@ -1 +1 @@ -

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file +

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html index 5876b3bd03..083e86c6ad 100644 --- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html +++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html @@ -1 +1 @@ -

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file +

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index 7c17cad0ad..ee7bde9e60 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -142,7 +142,7 @@ export class ODTExporter< numberedListIndex = 0; } - if (["columnList", "column"].includes(block.type)) { + if (this.isContainerBlock(block.type)) { const children = await this.transformBlocks(block.children, 0); const content = await this.mapBlock( block as any, diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx index f91ec93a86..1063ea5daa 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx @@ -176,7 +176,7 @@ export class PDFExporter< children, ); // TODO: any - if (["pageBreak", "columnList", "column"].includes(b.type)) { + if (b.type === "pageBreak" || this.isContainerBlock(b.type)) { ret.push(self); continue; } diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 155a460786..9ff34c88ed 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 that can wrap a paragraph followed by a code block, or any combination of nested blocks.\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 — anything goes.\n- Type a title into the title field — it\'s stored on `block.props.title`, not as document content.\n- Watch the JSON panel on the right update as you edit; the callout\'s children appear in `block.children`.\n- Insert a new callout via the Slash Menu (search "callout").\n\n**Relevant Docs:**\n\n- [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: "table-container", + fullSlug: "custom-schema/table-container", + pathFromRoot: "examples/06-custom-schema/12-table-container", + config: { + playground: true, + docs: false, + author: "nickthesick", + tags: [ + "Advanced", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Table From Container Blocks", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + "In this example, we build a table out of three nested container blocks — `gridTable > gridRow > gridCell` — instead of a `content: \"table\"` block. The whole nesting structure comes from each block's `children` config, so the document schema enforces it, and because a cell's `children` allows any block, cells can hold lists, headings, code blocks, or another table.\n\nThe blocks render real `
` / `` / `
` elements: a container block owns its outer DOM, so `contentRef` / `contentDOM` places its children directly inside. They're written with the vanilla `createBlockSpec` rather than `createReactBlockSpec` for exactly that reason — a React block renders through a `NodeViewWrapper` div, and a `
` between a `` and its rows gives you a table that looks right but isn't one to the DOM.\n\nWhat the container API can't express is the grid _semantics_: nothing in `children` can say \"every row has the same number of cells\", so the row/column buttons and the Tab navigation are ordinary code written against `insertBlocks` / `removeBlocks`. Column spans, rectangular cell selection, and column resizing aren't implemented at all.\n\n**Try it out:**\n\n- Type in a cell, then press Tab and Shift-Tab to move between cells — Tab past the last cell adds a row.\n- Use the `+ Row` / `+ Column` buttons below a table, and watch the shape readout next to them.\n- Press \"/\" inside a cell and insert a heading, list, or even another Grid Table.\n- Watch the JSON panel: a table is just `gridTable > gridRow > gridCell` in `block.children`.\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: "draggable-inline-content", fullSlug: "custom-schema/draggable-inline-content", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6decb347ed..cbcf97aa1e 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-plus: + specifier: ^0.1.24 + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(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-table-container: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite-plus: + specifier: ^0.1.24 + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': @@ -5115,9 +5207,9 @@ importers: '@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) + vite-plus: + specifier: ^0.1.24 + version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0) packages/ariakit: dependencies: @@ -8220,6 +8312,10 @@ packages: resolution: {integrity: sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==} engines: {node: '>= 20.0.0'} + '@oxc-project/runtime@0.133.0': + resolution: {integrity: sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-project/runtime@0.143.0': resolution: {integrity: sha512-zIuXUf+YGIgsPk0xlQmzTY8NCSc8jE/pSfDodlQ9H3EGZABmr+AtIjXRrnpQAXuXzhDSNqZz9cuhud8hDDLvpg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8227,51 +8323,103 @@ packages: '@oxc-project/types@0.124.0': resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@oxc-project/types@0.143.0': resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + '@oxfmt/binding-android-arm-eabi@0.52.0': + resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxfmt/binding-android-arm-eabi@0.62.0': resolution: {integrity: sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] + '@oxfmt/binding-android-arm64@0.52.0': + resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxfmt/binding-android-arm64@0.62.0': resolution: {integrity: sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxfmt/binding-darwin-arm64@0.52.0': + resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxfmt/binding-darwin-arm64@0.62.0': resolution: {integrity: sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxfmt/binding-darwin-x64@0.52.0': + resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxfmt/binding-darwin-x64@0.62.0': resolution: {integrity: sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxfmt/binding-freebsd-x64@0.52.0': + resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxfmt/binding-freebsd-x64@0.62.0': resolution: {integrity: sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxfmt/binding-linux-arm-gnueabihf@0.62.0': resolution: {integrity: sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxfmt/binding-linux-arm-musleabihf@0.62.0': resolution: {integrity: sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxfmt/binding-linux-arm64-gnu@0.52.0': + resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-arm64-gnu@0.62.0': resolution: {integrity: sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8279,6 +8427,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-arm64-musl@0.52.0': + resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxfmt/binding-linux-arm64-musl@0.62.0': resolution: {integrity: sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8286,6 +8441,13 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-ppc64-gnu@0.62.0': resolution: {integrity: sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8293,6 +8455,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-riscv64-gnu@0.62.0': resolution: {integrity: sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8300,6 +8469,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-riscv64-musl@0.52.0': + resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxfmt/binding-linux-riscv64-musl@0.62.0': resolution: {integrity: sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8307,6 +8483,13 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-linux-s390x-gnu@0.52.0': + resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-s390x-gnu@0.62.0': resolution: {integrity: sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8314,6 +8497,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-x64-gnu@0.52.0': + resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxfmt/binding-linux-x64-gnu@0.62.0': resolution: {integrity: sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8321,6 +8511,13 @@ packages: os: [linux] libc: [glibc] + '@oxfmt/binding-linux-x64-musl@0.52.0': + resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxfmt/binding-linux-x64-musl@0.62.0': resolution: {integrity: sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8328,102 +8525,205 @@ packages: os: [linux] libc: [musl] + '@oxfmt/binding-openharmony-arm64@0.52.0': + resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxfmt/binding-openharmony-arm64@0.62.0': resolution: {integrity: sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxfmt/binding-win32-arm64-msvc@0.52.0': + resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxfmt/binding-win32-arm64-msvc@0.62.0': resolution: {integrity: sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxfmt/binding-win32-ia32-msvc@0.52.0': + resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxfmt/binding-win32-ia32-msvc@0.62.0': resolution: {integrity: sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxfmt/binding-win32-x64-msvc@0.52.0': + resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxfmt/binding-win32-x64-msvc@0.62.0': resolution: {integrity: sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxlint-tsgolint/darwin-arm64@0.23.0': + resolution: {integrity: sha512-gOs9PVr2wEg4ox9z0aJo+RKhhImW86YL5N6yav8BK/rgPsIrwN/igSZ+pbRr723NFvUNKde9fgMhRA6JrXAOZw==} + cpu: [arm64] + os: [darwin] + '@oxlint-tsgolint/darwin-arm64@7.0.2001': resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} cpu: [arm64] os: [darwin] + '@oxlint-tsgolint/darwin-x64@0.23.0': + resolution: {integrity: sha512-kjJ8B+7n4tB9VJdxS5A9GdJt6/bYpzbu4lXp2uO1S3sRmCB5gDEABlGoiePNApRWaW+xqL4b4xgiE727jSLhuA==} + cpu: [x64] + os: [darwin] + '@oxlint-tsgolint/darwin-x64@7.0.2001': resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} cpu: [x64] os: [darwin] + '@oxlint-tsgolint/linux-arm64@0.23.0': + resolution: {integrity: sha512-6dCZuKNu135seMXilkRk9SpCx6i1XgmiipYGalLij5WVRX6ZYS8c4xI7preN/zv9fCXhsQclTIMDu2Y/cytTjw==} + cpu: [arm64] + os: [linux] + '@oxlint-tsgolint/linux-arm64@7.0.2001': resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} cpu: [arm64] os: [linux] + '@oxlint-tsgolint/linux-x64@0.23.0': + resolution: {integrity: sha512-3bdilnyA7kmSTjK27rvjIjSxL5SIg3wt7vwNiRkouWB83ytssyKnuGvxSYJxgMEmFpSutzaBzcCUM2jDtPGcgA==} + cpu: [x64] + os: [linux] + '@oxlint-tsgolint/linux-x64@7.0.2001': resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} cpu: [x64] os: [linux] + '@oxlint-tsgolint/win32-arm64@0.23.0': + resolution: {integrity: sha512-j+OEp44SVYiQ+ZD+uttsX7u6L9SvmbbQ77SO1pSFCcJlsVMeCk8qZsjhKfGKuT/jIA+ipOJMVs/+pqUfObBWNw==} + cpu: [arm64] + os: [win32] + '@oxlint-tsgolint/win32-arm64@7.0.2001': resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} cpu: [arm64] os: [win32] + '@oxlint-tsgolint/win32-x64@0.23.0': + resolution: {integrity: sha512-5MyjFuqf+g8OUPJBSGWHJtmoWnzFJYyOg4To9WMQshZYEWig/vtu7JtJ03VWnzHv9LJkAUeApY0gVCOywFR/iQ==} + cpu: [x64] + os: [win32] + '@oxlint-tsgolint/win32-x64@7.0.2001': resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} cpu: [x64] os: [win32] + '@oxlint/binding-android-arm-eabi@1.67.0': + resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxlint/binding-android-arm-eabi@1.77.0': resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] + '@oxlint/binding-android-arm64@1.67.0': + resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxlint/binding-android-arm64@1.77.0': resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@oxlint/binding-darwin-arm64@1.67.0': + resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxlint/binding-darwin-arm64@1.77.0': resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@oxlint/binding-darwin-x64@1.67.0': + resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxlint/binding-darwin-x64@1.77.0': resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@oxlint/binding-freebsd-x64@1.67.0': + resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxlint/binding-freebsd-x64@1.77.0': resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.67.0': + resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxlint/binding-linux-arm-musleabihf@1.77.0': resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@oxlint/binding-linux-arm64-gnu@1.67.0': + resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-arm64-gnu@1.77.0': resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8431,6 +8731,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-arm64-musl@1.67.0': + resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxlint/binding-linux-arm64-musl@1.77.0': resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8438,6 +8745,13 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-ppc64-gnu@1.67.0': + resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-ppc64-gnu@1.77.0': resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8445,6 +8759,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-riscv64-gnu@1.67.0': + resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-riscv64-gnu@1.77.0': resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8452,6 +8773,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-riscv64-musl@1.67.0': + resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxlint/binding-linux-riscv64-musl@1.77.0': resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8459,6 +8787,13 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-linux-s390x-gnu@1.67.0': + resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-s390x-gnu@1.77.0': resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8466,6 +8801,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-x64-gnu@1.67.0': + resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxlint/binding-linux-x64-gnu@1.77.0': resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8473,6 +8815,13 @@ packages: os: [linux] libc: [glibc] + '@oxlint/binding-linux-x64-musl@1.67.0': + resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxlint/binding-linux-x64-musl@1.77.0': resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8480,30 +8829,58 @@ packages: os: [linux] libc: [musl] + '@oxlint/binding-openharmony-arm64@1.67.0': + resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxlint/binding-openharmony-arm64@1.77.0': resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@oxlint/binding-win32-arm64-msvc@1.67.0': + resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxlint/binding-win32-arm64-msvc@1.77.0': resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.67.0': + resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxlint/binding-win32-ia32-msvc@1.77.0': resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] + '@oxlint/binding-win32-x64-msvc@1.67.0': + resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint/binding-win32-x64-msvc@1.77.0': resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@oxlint/plugins@1.61.0': + resolution: {integrity: sha512-nkOyZEF1vH527CkdQtOp1HMrVFEM4ResURvI2JFeGoup+h+43J/k/FgdOR9b9Isxg+Yae7qVDa7y3nssE8b3TQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@oxlint/plugins@1.73.0': resolution: {integrity: sha512-OhgMQeMmZA0dcFcX4/priaJZWdFECxiClgq6mRX6aatZEcV9PbKC3P3/v8U1hVjviT1i5U+vR8lAtBV6m4FXAA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -11265,6 +11642,69 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + '@voidzero-dev/vite-plus-core@0.1.24': + resolution: {integrity: sha512-iXPGBABnQnrDMx89H6MOCGcTZp+QW+3rY4YMVKdE6ydchSvPk2O3MI2vgaRVfOtWJ2IjnxSnf1n2yjP67ZBRFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.1 + '@tsdown/exe': 0.22.1 + '@types/node': ^25.6.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + '@voidzero-dev/vite-plus-core@0.2.9': resolution: {integrity: sha512-dWqScAAwa8h/i9jCiGAMs7YarzQWInHZ5gCJNbQkHXA6Zp6A2T2anN9YMFVPpb7CwVFwkI2iPF5yl/DXtq+zUA==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} @@ -11322,18 +11762,37 @@ packages: yaml: optional: true + '@voidzero-dev/vite-plus-darwin-arm64@0.1.24': + resolution: {integrity: sha512-Hpo9W9piSFlEsJzGkwzfDXhJGrnYByxHXF7NVQZ7g+SLOprddtlfTeM8t+gq9dxcuq0RzM8ddMAhDQP/K3fZQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@voidzero-dev/vite-plus-darwin-arm64@0.2.9': resolution: {integrity: sha512-/qJHqMfyy/LiCJk4UYZfFW6Comsfm8zDuy4P8UFWnFqRjmefYvZbMK4ThYNM87tKNWYWjsnClzssjJOmDTAc8w==} engines: {node: '>=20.0.0'} cpu: [arm64] os: [darwin] + '@voidzero-dev/vite-plus-darwin-x64@0.1.24': + resolution: {integrity: sha512-SwnnnZrEFBiU5iKlh/CZAVwn0RFt/Udrvt3kFLtdRxMtN5bKaqTFVA2H8Y/FPCWp1QX9bs4V9ZIAeXAk06zLkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@voidzero-dev/vite-plus-darwin-x64@0.2.9': resolution: {integrity: sha512-3MGnNeazgYAqQTaC7JIbZyrTHmxSXTWFr9G1yAdeItKasB1R8HKIkCS1Qnr49Ge4S/cyOODivdbcpqFkrT93ng==} engines: {node: '>=20.0.0'} cpu: [x64] os: [darwin] + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.24': + resolution: {integrity: sha512-ImM3eqDki4DpRuHjW6dEh4St8zvbcfOMR7KQZJX42ArriCLQ/QdaYhDRRbcDi27XsOBqRxm2eqUUEymPrYIHpA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.9': resolution: {integrity: sha512-6LmukER8qD4UBIRqMNv4Ilq7CxfRhngLUXlMv8vbTupeLRWPJSKvKEHyRxCwr6JP57Gxfr8KrX1ye42WzcZF0g==} engines: {node: '>=20.0.0'} @@ -11341,26 +11800,84 @@ packages: os: [linux] libc: [glibc] - '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.9': - resolution: {integrity: sha512-cBs626GWkyJlwKP0nsdHlMWpuTl9xOWRxAUoqtxXPtw80bVy4WM5eNS4SXPC5pX10jR7DRIkRpzNAsy7fv8Faw==} - engines: {node: '>=20.0.0'} + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.24': + resolution: {integrity: sha512-gj4mzbob/ls8Zs7iTuF9Gr0EFFF7tdpDiPxDPBkH8tJP5OkHABlzWUwJhU+9xxcUbTaXqpHDw68Mil7jm5dpMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.9': + resolution: {integrity: sha512-cBs626GWkyJlwKP0nsdHlMWpuTl9xOWRxAUoqtxXPtw80bVy4WM5eNS4SXPC5pX10jR7DRIkRpzNAsy7fv8Faw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.24': + resolution: {integrity: sha512-x7IYK7lI+WuF1n3jSzEYU6FgJxPX/R0rDmTTsOutooGGCU7uShZvfZqIoiTXK0eFnJU5ij5BfBgenenUfsaT/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.9': + resolution: {integrity: sha512-2Iy8x4PCPMNzXeu3pREevlggoeK8PwtdUiCpoSybAZBtR/aqMxsJREyt/eKv45F8lsiNlA8PbIWEvPxLSgzFLQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': + resolution: {integrity: sha512-JCy2w0eSVUlWQlggK5T47MnL+j0o4EY7hLskINVI8gi+aixQF4xnYBDobz0lbxkqz3/IfiLyXUx6TcU3thcsGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.9': + resolution: {integrity: sha512-zuGx+eRotWPd9cmh1X9AfsC2tN/Ad9Hk6LAzlxoKJUjEkTsY3WjVJ6Da0z48SAUFuenI0JkdqXnMCrePtGvWHg==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-test@0.1.24': + resolution: {integrity: sha512-9NiG6UadG0iOaPL1AMsO5sDKkx6MADHw4/mMOmHWZUhhUwqzfVtnnptMK37vD71e6KyR7yAscx19FrtOWWtjvA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^25.6.0 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 + happy-dom: '*' + jsdom: ^29.0.2 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.24': + resolution: {integrity: sha512-G+/lhLKVjyn3FmgXX8jeWgq7RcE5O1kdR7QyFayQOdlMX/ZRkvUwQD7bFaqhKzgJM6Oj3a1FH3HQPYk5QOYuCQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] - os: [linux] - libc: [musl] - - '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.9': - resolution: {integrity: sha512-2Iy8x4PCPMNzXeu3pREevlggoeK8PwtdUiCpoSybAZBtR/aqMxsJREyt/eKv45F8lsiNlA8PbIWEvPxLSgzFLQ==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@voidzero-dev/vite-plus-linux-x64-musl@0.2.9': - resolution: {integrity: sha512-zuGx+eRotWPd9cmh1X9AfsC2tN/Ad9Hk6LAzlxoKJUjEkTsY3WjVJ6Da0z48SAUFuenI0JkdqXnMCrePtGvWHg==} - engines: {node: '>=20.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] + os: [win32] '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.9': resolution: {integrity: sha512-kaKb5Q8ReYTBfvLgUhdpLJG3aoNF8HOVJpf8qBm+THsd4WRrkRwsBHp2ITsU8oXl2gnDjFGhPDaURegd/z6Wxw==} @@ -11368,6 +11885,12 @@ packages: cpu: [arm64] os: [win32] + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24': + resolution: {integrity: sha512-b0e5XohEV1w/RdzAtv8/Hm6tvHPXouPtBNsljjW/lDJZq3NCLND5s6lqe8H4IenrgmKSoqakHWtlqJqM36cFbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.9': resolution: {integrity: sha512-/fEk3gbQTJknCiYM/GTL/L++Azsav8rCAjmtKrjmCbqEif5IMzqTfvM68n+PiLB3JVoQJGF/mg2niODr0IE/2w==} engines: {node: '>=20.0.0'} @@ -12660,6 +13183,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -14558,6 +15084,19 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxfmt@0.52.0: + resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + oxfmt@0.62.0: resolution: {integrity: sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14571,10 +15110,27 @@ packages: vite-plus: optional: true + oxlint-tsgolint@0.23.0: + resolution: {integrity: sha512-3mBv3CoPbh8dFbzfDGIWa2ytZjn2v+3EX4aKRXjIhsoGFzG8GCjfRirz3rwZf1wYbZzsNLTSgpw8VjQuWdp/jA==} + hasBin: true + oxlint-tsgolint@7.0.2001: resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} hasBin: true + oxlint@1.67.0: + resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + oxlint@1.77.0: resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -14746,6 +15302,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pixelmatch@7.2.0: + resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} + hasBin: true + pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} @@ -16041,6 +16601,11 @@ packages: '@nuxt/kit': optional: true + vite-plus@0.1.24: + resolution: {integrity: sha512-b3fr6WtCiEhetjuzW/4KcEMOAMuZxoxZATWaXKmPzOLf1upG+pzKJOFZTb94D6wiPBlwcjxoaUtF7C3uAN+VjQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + vite-plus@0.2.9: resolution: {integrity: sha512-8uRNqAxh9no3AU4Lep8BEYhkim07+3NO+mhuxTWiN0k30syGT/2+ue/DtWYhtzQ7yi2f2WKpjOhoI4/QkWWbUg==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} @@ -18404,144 +18969,282 @@ snapshots: '@orama/orama@3.1.18': {} + '@oxc-project/runtime@0.133.0': {} + '@oxc-project/runtime@0.143.0': {} '@oxc-project/types@0.124.0': {} + '@oxc-project/types@0.133.0': {} + '@oxc-project/types@0.143.0': {} + '@oxfmt/binding-android-arm-eabi@0.52.0': + optional: true + '@oxfmt/binding-android-arm-eabi@0.62.0': optional: true + '@oxfmt/binding-android-arm64@0.52.0': + optional: true + '@oxfmt/binding-android-arm64@0.62.0': optional: true + '@oxfmt/binding-darwin-arm64@0.52.0': + optional: true + '@oxfmt/binding-darwin-arm64@0.62.0': optional: true + '@oxfmt/binding-darwin-x64@0.52.0': + optional: true + '@oxfmt/binding-darwin-x64@0.62.0': optional: true + '@oxfmt/binding-freebsd-x64@0.52.0': + optional: true + '@oxfmt/binding-freebsd-x64@0.62.0': optional: true + '@oxfmt/binding-linux-arm-gnueabihf@0.52.0': + optional: true + '@oxfmt/binding-linux-arm-gnueabihf@0.62.0': optional: true + '@oxfmt/binding-linux-arm-musleabihf@0.52.0': + optional: true + '@oxfmt/binding-linux-arm-musleabihf@0.62.0': optional: true + '@oxfmt/binding-linux-arm64-gnu@0.52.0': + optional: true + '@oxfmt/binding-linux-arm64-gnu@0.62.0': optional: true + '@oxfmt/binding-linux-arm64-musl@0.52.0': + optional: true + '@oxfmt/binding-linux-arm64-musl@0.62.0': optional: true + '@oxfmt/binding-linux-ppc64-gnu@0.52.0': + optional: true + '@oxfmt/binding-linux-ppc64-gnu@0.62.0': optional: true + '@oxfmt/binding-linux-riscv64-gnu@0.52.0': + optional: true + '@oxfmt/binding-linux-riscv64-gnu@0.62.0': optional: true + '@oxfmt/binding-linux-riscv64-musl@0.52.0': + optional: true + '@oxfmt/binding-linux-riscv64-musl@0.62.0': optional: true + '@oxfmt/binding-linux-s390x-gnu@0.52.0': + optional: true + '@oxfmt/binding-linux-s390x-gnu@0.62.0': optional: true + '@oxfmt/binding-linux-x64-gnu@0.52.0': + optional: true + '@oxfmt/binding-linux-x64-gnu@0.62.0': optional: true + '@oxfmt/binding-linux-x64-musl@0.52.0': + optional: true + '@oxfmt/binding-linux-x64-musl@0.62.0': optional: true + '@oxfmt/binding-openharmony-arm64@0.52.0': + optional: true + '@oxfmt/binding-openharmony-arm64@0.62.0': optional: true + '@oxfmt/binding-win32-arm64-msvc@0.52.0': + optional: true + '@oxfmt/binding-win32-arm64-msvc@0.62.0': optional: true + '@oxfmt/binding-win32-ia32-msvc@0.52.0': + optional: true + '@oxfmt/binding-win32-ia32-msvc@0.62.0': optional: true + '@oxfmt/binding-win32-x64-msvc@0.52.0': + optional: true + '@oxfmt/binding-win32-x64-msvc@0.62.0': optional: true + '@oxlint-tsgolint/darwin-arm64@0.23.0': + optional: true + '@oxlint-tsgolint/darwin-arm64@7.0.2001': optional: true + '@oxlint-tsgolint/darwin-x64@0.23.0': + optional: true + '@oxlint-tsgolint/darwin-x64@7.0.2001': optional: true + '@oxlint-tsgolint/linux-arm64@0.23.0': + optional: true + '@oxlint-tsgolint/linux-arm64@7.0.2001': optional: true + '@oxlint-tsgolint/linux-x64@0.23.0': + optional: true + '@oxlint-tsgolint/linux-x64@7.0.2001': optional: true + '@oxlint-tsgolint/win32-arm64@0.23.0': + optional: true + '@oxlint-tsgolint/win32-arm64@7.0.2001': optional: true + '@oxlint-tsgolint/win32-x64@0.23.0': + optional: true + '@oxlint-tsgolint/win32-x64@7.0.2001': optional: true + '@oxlint/binding-android-arm-eabi@1.67.0': + optional: true + '@oxlint/binding-android-arm-eabi@1.77.0': optional: true + '@oxlint/binding-android-arm64@1.67.0': + optional: true + '@oxlint/binding-android-arm64@1.77.0': optional: true + '@oxlint/binding-darwin-arm64@1.67.0': + optional: true + '@oxlint/binding-darwin-arm64@1.77.0': optional: true + '@oxlint/binding-darwin-x64@1.67.0': + optional: true + '@oxlint/binding-darwin-x64@1.77.0': optional: true + '@oxlint/binding-freebsd-x64@1.67.0': + optional: true + '@oxlint/binding-freebsd-x64@1.77.0': optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + optional: true + '@oxlint/binding-linux-arm-gnueabihf@1.77.0': optional: true + '@oxlint/binding-linux-arm-musleabihf@1.67.0': + optional: true + '@oxlint/binding-linux-arm-musleabihf@1.77.0': optional: true + '@oxlint/binding-linux-arm64-gnu@1.67.0': + optional: true + '@oxlint/binding-linux-arm64-gnu@1.77.0': optional: true + '@oxlint/binding-linux-arm64-musl@1.67.0': + optional: true + '@oxlint/binding-linux-arm64-musl@1.77.0': optional: true + '@oxlint/binding-linux-ppc64-gnu@1.67.0': + optional: true + '@oxlint/binding-linux-ppc64-gnu@1.77.0': optional: true + '@oxlint/binding-linux-riscv64-gnu@1.67.0': + optional: true + '@oxlint/binding-linux-riscv64-gnu@1.77.0': optional: true + '@oxlint/binding-linux-riscv64-musl@1.67.0': + optional: true + '@oxlint/binding-linux-riscv64-musl@1.77.0': optional: true + '@oxlint/binding-linux-s390x-gnu@1.67.0': + optional: true + '@oxlint/binding-linux-s390x-gnu@1.77.0': optional: true + '@oxlint/binding-linux-x64-gnu@1.67.0': + optional: true + '@oxlint/binding-linux-x64-gnu@1.77.0': optional: true + '@oxlint/binding-linux-x64-musl@1.67.0': + optional: true + '@oxlint/binding-linux-x64-musl@1.77.0': optional: true + '@oxlint/binding-openharmony-arm64@1.67.0': + optional: true + '@oxlint/binding-openharmony-arm64@1.77.0': optional: true + '@oxlint/binding-win32-arm64-msvc@1.67.0': + optional: true + '@oxlint/binding-win32-arm64-msvc@1.77.0': optional: true + '@oxlint/binding-win32-ia32-msvc@1.67.0': + optional: true + '@oxlint/binding-win32-ia32-msvc@1.77.0': optional: true + '@oxlint/binding-win32-x64-msvc@1.67.0': + optional: true + '@oxlint/binding-win32-x64-msvc@1.77.0': optional: true + '@oxlint/plugins@1.61.0': {} + '@oxlint/plugins@1.73.0': {} '@pkgjs/parseargs@0.11.0': @@ -21490,6 +22193,22 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@voidzero-dev/vite-plus-core@0.1.24(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.33.0 + postcss: 8.5.23 + optionalDependencies: + '@types/node': 25.6.0 + esbuild: 0.27.5 + fsevents: 2.3.3 + jiti: 2.6.1 + terser: 5.46.2 + tsx: 4.21.0 + typescript: 5.9.3 + yaml: 2.9.0 + '@voidzero-dev/vite-plus-core@0.2.9(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(typescript@7.0.2)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.143.0 @@ -21516,27 +22235,94 @@ snapshots: typescript: 7.0.2 yaml: 2.9.0 + '@voidzero-dev/vite-plus-darwin-arm64@0.1.24': + optional: true + '@voidzero-dev/vite-plus-darwin-arm64@0.2.9': optional: true + '@voidzero-dev/vite-plus-darwin-x64@0.1.24': + optional: true + '@voidzero-dev/vite-plus-darwin-x64@0.2.9': optional: true + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.1.24': + optional: true + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.9': optional: true + '@voidzero-dev/vite-plus-linux-arm64-musl@0.1.24': + optional: true + '@voidzero-dev/vite-plus-linux-arm64-musl@0.2.9': optional: true + '@voidzero-dev/vite-plus-linux-x64-gnu@0.1.24': + optional: true + '@voidzero-dev/vite-plus-linux-x64-gnu@0.2.9': optional: true + '@voidzero-dev/vite-plus-linux-x64-musl@0.1.24': + optional: true + '@voidzero-dev/vite-plus-linux-x64-musl@0.2.9': optional: true + '@voidzero-dev/vite-plus-test@0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/ui@4.1.5)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + es-module-lexer: 1.7.0 + obug: 2.1.1 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + 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) + ws: 8.20.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.6.0 + '@vitest/ui': 4.1.5(vitest@4.1.10) + jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.1.24': + optional: true + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.9': optional: true + '@voidzero-dev/vite-plus-win32-x64-msvc@0.1.24': + optional: true + '@voidzero-dev/vite-plus-win32-x64-msvc@0.2.9': optional: true @@ -22870,6 +23656,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: @@ -25077,6 +25865,31 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxfmt@0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.52.0 + '@oxfmt/binding-android-arm64': 0.52.0 + '@oxfmt/binding-darwin-arm64': 0.52.0 + '@oxfmt/binding-darwin-x64': 0.52.0 + '@oxfmt/binding-freebsd-x64': 0.52.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.52.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.52.0 + '@oxfmt/binding-linux-arm64-gnu': 0.52.0 + '@oxfmt/binding-linux-arm64-musl': 0.52.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.52.0 + '@oxfmt/binding-linux-riscv64-musl': 0.52.0 + '@oxfmt/binding-linux-s390x-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-gnu': 0.52.0 + '@oxfmt/binding-linux-x64-musl': 0.52.0 + '@oxfmt/binding-openharmony-arm64': 0.52.0 + '@oxfmt/binding-win32-arm64-msvc': 0.52.0 + '@oxfmt/binding-win32-ia32-msvc': 0.52.0 + '@oxfmt/binding-win32-x64-msvc': 0.52.0 + vite-plus: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0) + oxfmt@0.62.0(vite-plus@0.2.9(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(msw@2.11.5(@types/node@25.6.0)(typescript@7.0.2))(terser@5.46.2)(tsx@4.21.0)(typescript@7.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)): dependencies: tinypool: 2.1.0 @@ -25102,6 +25915,15 @@ snapshots: '@oxfmt/binding-win32-x64-msvc': 0.62.0 vite-plus: 0.2.9(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(msw@2.11.5(@types/node@25.6.0)(typescript@7.0.2))(terser@5.46.2)(tsx@4.21.0)(typescript@7.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + oxlint-tsgolint@0.23.0: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 0.23.0 + '@oxlint-tsgolint/darwin-x64': 0.23.0 + '@oxlint-tsgolint/linux-arm64': 0.23.0 + '@oxlint-tsgolint/linux-x64': 0.23.0 + '@oxlint-tsgolint/win32-arm64': 0.23.0 + '@oxlint-tsgolint/win32-x64': 0.23.0 + oxlint-tsgolint@7.0.2001: optionalDependencies: '@oxlint-tsgolint/darwin-arm64': 7.0.2001 @@ -25111,6 +25933,30 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 + oxlint@1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 + oxlint-tsgolint: 0.23.0 + vite-plus: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0) + oxlint@1.77.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.2.9(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(msw@2.11.5(@types/node@25.6.0)(typescript@7.0.2))(terser@5.46.2)(tsx@4.21.0)(typescript@7.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.77.0 @@ -25283,6 +26129,10 @@ snapshots: picomatch@4.0.4: {} + pixelmatch@7.2.0: + dependencies: + pngjs: 7.0.0 + pkg-types@2.3.0: dependencies: confbox: 0.2.4 @@ -26891,6 +27741,57 @@ snapshots: - typescript - ws + vite-plus@0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0): + dependencies: + '@oxc-project/types': 0.133.0 + '@oxlint/plugins': 1.61.0 + '@vitest/ui': 4.1.5(vitest@4.1.10) + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + '@voidzero-dev/vite-plus-test': 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/ui@4.1.5)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0) + oxfmt: 0.52.0(vite-plus@0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0)) + oxlint: 1.67.0(oxlint-tsgolint@0.23.0)(vite-plus@0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(vitest@4.1.10)(yaml@2.9.0)) + oxlint-tsgolint: 0.23.0 + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.1.24 + '@voidzero-dev/vite-plus-darwin-x64': 0.1.24 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.1.24 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.1.24 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.1.24 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.1.24 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.1.24 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.1.24 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@tsdown/css' + - '@tsdown/exe' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - vitest + - yaml + vite-plus@0.2.9(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.10)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(msw@2.11.5(@types/node@25.6.0)(typescript@7.0.2))(terser@5.46.2)(tsx@4.21.0)(typescript@7.0.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@oxc-project/types': 0.143.0 diff --git a/tests/src/end-to-end/containerblocks/CardApp.tsx b/tests/src/end-to-end/containerblocks/CardApp.tsx new file mode 100644 index 0000000000..2ec047df1e --- /dev/null +++ b/tests/src/end-to-end/containerblocks/CardApp.tsx @@ -0,0 +1,107 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { createReactBlockSpec, useCreateBlockNote } from "@blocknote/react"; + +import "./card.css"; + +// Fixture for the container-block browser tests: a "card" made of an ordered +// sequence of two child containers. The header is a *content-bearing* +// container (`content: "inline"` plus `children`), which is the shape the +// rendering tests exercise. + +// The card's header: its own rich-text title, plus optional extra blocks +// beneath it — one editable region holding the title first and the children +// after it, both placed with the single `contentRef`. +const createCardHeader = createReactBlockSpec( + { + type: "cardHeader", + propSchema: {}, + content: "inline", + children: { min: 0 }, + placement: "containerOnly", + }, + { + render: (props) =>
, + }, +); + +// The card's body: any blocks, at least one. +const createCardBody = createReactBlockSpec( + { + type: "cardBody", + propSchema: {}, + content: "none", + children: { min: 1 }, + placement: "containerOnly", + }, + { + render: (props) =>
, + }, +); + +// The card itself: `sequence` compiles to the ProseMirror content expression +// `cardHeader cardBody` — a header, then a body, exactly one of each. +const createCard = createReactBlockSpec( + { + type: "card", + propSchema: {}, + content: "none", + children: { + sequence: [ + { allow: { blocks: false, containers: ["cardHeader"] } }, + { allow: { blocks: false, containers: ["cardBody"] } }, + ], + }, + }, + { + render: (props) => ( +
+ {/* The card has no content of its own, so its editable region is + just its children — the header and the body, in that order. */} +
+
+ ), + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + card: createCard(), + cardHeader: createCardHeader(), + cardBody: createCardBody(), + }, +}); + +export default function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "card", + children: [ + { + type: "cardHeader", + content: "Ordered children", + }, + { + type: "cardBody", + children: [ + { + type: "paragraph", + content: "The body holds any blocks.", + }, + ], + }, + ], + }, + { + type: "paragraph", + }, + ], + }); + + return ; +} diff --git a/tests/src/end-to-end/containerblocks/card.css b/tests/src/end-to-end/containerblocks/card.css new file mode 100644 index 0000000000..720937bd4c --- /dev/null +++ b/tests/src/end-to-end/containerblocks/card.css @@ -0,0 +1,33 @@ +.card { + border: 1px solid #7c3aed; + border-radius: 6px; + display: flex; + flex-grow: 1; + gap: 8px; + overflow: hidden; + padding: 8px 12px; +} + +.card-slots { + flex-grow: 1; + min-width: 0; +} + +/* The header's two regions. A content-bearing container's editable region + holds its own content first and its children after it — both carry stable + attributes derived from the block type, so they can be styled separately + without any extra config. */ +.card-header [data-content-type="cardHeader"] { + border-bottom: 1px solid #7c3aed; + font-weight: 600; + padding-bottom: 4px; +} + +.card-header [data-children-of="cardHeader"] { + font-size: 0.875rem; + opacity: 0.8; +} + +.card-body { + padding-top: 4px; +} diff --git a/tests/src/end-to-end/containerblocks/containerblocks.test.tsx b/tests/src/end-to-end/containerblocks/containerblocks.test.tsx new file mode 100644 index 0000000000..343d0f984b --- /dev/null +++ b/tests/src/end-to-end/containerblocks/containerblocks.test.tsx @@ -0,0 +1,238 @@ +import CalloutApp from "@examples/06-custom-schema/09-container-block/src/App"; +import CardApp from "./CardApp.jsx"; +import { describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { userEvent } from "../../utils/context.js"; +import { DRAG_HANDLE_SELECTOR, EDITOR_SELECTOR } from "../../utils/const.js"; +import { sleep, waitForSelector } from "../../utils/editor.js"; +import { getRect, moveMouseOverElement } from "../../utils/mouse.js"; + +// A content-bearing container renders its own content and its children as two +// sibling regions inside a single ProseMirror `contentDOM`, which the framework +// hosts under `display: contents` so the author's layout is unaffected. None of +// that is observable without a layout engine, so these are the checks that +// can't live in the jsdom unit suites. + +const CARD_HEADER = `[data-node-type="cardHeader"]`; +const HEADER_CONTENT = `[data-content-type="cardHeader"]`; +const HEADER_CHILDREN = `[data-children-of="cardHeader"]`; + +/** Where the caret currently sits, in terms of the two container regions. */ +function caretRegion() { + const node = document.getSelection()?.anchorNode; + const element = node?.nodeType === Node.TEXT_NODE ? node.parentElement : node; + if (!(element instanceof Element)) { + return "none"; + } + + const children = element.closest("[data-children-of]"); + if (children) { + return `children-of:${children.getAttribute("data-children-of")}`; + } + + const content = element.closest("[data-content-type]"); + return content + ? `content:${content.getAttribute("data-content-type")}` + : "other"; +} + +describe("Container block rendering", () => { + test("Places content and children as adjacent siblings under a display:contents host", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + const header = await waitForSelector(CARD_HEADER); + const content = header.querySelector(HEADER_CONTENT)!; + const children = header.querySelector(HEADER_CHILDREN)!; + + expect(content).toBeTruthy(); + expect(children).toBeTruthy(); + + // Both regions must be direct children of one content host: ProseMirror + // rebuilds the whole node view if a child desc's DOM leaves its parent's + // contentDOM, so this adjacency is a correctness requirement, not a style. + expect(content.parentElement).toBe(children.parentElement); + expect( + content.compareDocumentPosition(children) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + // The host contributes no box, so the author's own layout applies to the + // regions as if they were written where the author placed `contentRef`. + expect(getComputedStyle(content.parentElement!).display).toBe("contents"); + }); + + test("Gives the wrapper React needs around a container no box of its own", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + const callout = await waitForSelector(".callout"); + const renderer = callout.closest( + ".bn-react-node-view-renderer", + )!; + + // Container blocks own their outer DOM, so React's node-view renderer must + // not be a box. What removes it is a stylesheet rule + // (`.bn-react-node-view-renderer.bn-container-node-view` in + // `packages/react/src/editor/styles.css`) — nothing but a real cascade can + // see whether it applied. Without it the renderer falls back to the + // `display: flex; width: 100%` box every *non*-container node view relies + // on, wrapping every container in a box its author never wrote. + expect(renderer.classList.contains("bn-container-node-view")).toBe(true); + + // Asserted as geometry rather than as a declaration: an element with + // `display: contents` generates no principal box, so it has no client + // rects at all. The author's own root, one level down, is where the + // block's box actually lives. + expect(renderer.getClientRects().length).toBe(0); + expect(callout.getClientRects().length).toBe(1); + expect(getRect(callout).height).toBeGreaterThan(0); + + // Supplement, so a failure says *why* the box came back. + expect(getComputedStyle(renderer).display).toBe("contents"); + }); + + test("Gives a container's own content a real box despite the display:contents host", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await waitForSelector(CARD_HEADER); + + const content = getRect(HEADER_CONTENT); + expect(content.width).toBeGreaterThan(0); + expect(content.height).toBeGreaterThan(0); + + // The body sits below the header rather than collapsing on top of it. + const body = getRect(`[data-node-type="cardBody"]`); + expect(body.top).toBeGreaterThanOrEqual(content.bottom); + }); +}); + +describe("Container block mouse interaction", () => { + test("Shows the drag handle for a block nested inside a container", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + const nested = await waitForSelector( + `[data-node-type="cardBody"] .bn-block-content`, + ); + const nestedRect = getRect(nested); + + await moveMouseOverElement(nested); + await sleep(150); + + // Hit-testing walks up from the hovered element; `display: contents` on an + // intermediate host must not break that walk or misplace the handle. + const handle = await waitForSelector(DRAG_HANDLE_SELECTOR); + const handleRect = getRect(handle); + + expect(handleRect.width).toBeGreaterThan(0); + expect(handleRect.x).toBeLessThan(nestedRect.x); + expect(Math.abs(handleRect.y - nestedRect.y)).toBeLessThan(60); + }); + + test("Puts the caret in the container's own content when its title is clicked", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + await userEvent.click(await waitForSelector(HEADER_CONTENT)); + await userEvent.keyboard("{End}"); + await userEvent.keyboard("EDITED"); + await sleep(200); + + expect(document.querySelector(HEADER_CONTENT)!.textContent).toContain( + "EDITED", + ); + expect(document.querySelector(HEADER_CHILDREN)!.textContent).not.toContain( + "EDITED", + ); + }); +}); + +describe("Container block keyboard interaction", () => { + test("Creates a first child on Enter at the end of a container's content", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + const before = document.querySelector(HEADER_CHILDREN)!.children.length; + + await userEvent.click(await waitForSelector(HEADER_CONTENT)); + await userEvent.keyboard("{End}"); + await userEvent.keyboard("{Enter}"); + await sleep(300); + + expect(document.querySelector(HEADER_CHILDREN)!.children.length).toBe( + before + 1, + ); + expect(caretRegion()).toBe("children-of:cardHeader"); + + // The caret really is in the new child, so typing does not touch the title. + await userEvent.keyboard("child text"); + await sleep(200); + expect(document.querySelector(HEADER_CHILDREN)!.textContent).toContain( + "child text", + ); + expect(document.querySelector(HEADER_CONTENT)!.textContent).not.toContain( + "child text", + ); + }); + + test("Moves the caret across the content/children boundary with the arrow keys", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + // Give the header a child to cross into. + await userEvent.click(await waitForSelector(HEADER_CONTENT)); + await userEvent.keyboard("{End}"); + await userEvent.keyboard("{Enter}"); + await sleep(300); + expect(caretRegion()).toBe("children-of:cardHeader"); + + // No keyboard handler implements this — it relies on ProseMirror's built-in + // vertical motion treating both regions as ordinary textblocks, which only + // holds if they lay out as real, separate lines. + await userEvent.keyboard("{ArrowUp}"); + await sleep(200); + expect(caretRegion()).toBe("content:cardHeader"); + + await userEvent.keyboard("{ArrowDown}"); + await sleep(200); + expect(caretRegion()).toBe("children-of:cardHeader"); + }); +}); + +describe("Container block attribute contract", () => { + test("Stamps node type and id onto the author's own root element", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + const callout = await waitForSelector(".callout"); + + // Not on a framework wrapper: the element the author returned from `render` + // is the one carrying the attributes, in the live editor as in serialized + // output. This is what makes `.callout[data-flavor=…]` selectors work. + expect(callout.getAttribute("data-node-type")).toBe("callout"); + expect(callout.getAttribute("data-id")).toBeTruthy(); + expect(callout.classList.contains("callout")).toBe(true); + }); + + test("Reflects a non-default prop as a data attribute the author's CSS can select", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + + const before = getComputedStyle( + await waitForSelector(".callout"), + ).backgroundColor; + + // Cycle the flavor off its default so the prop is actually written out — + // BlockNote omits `data-*` for props still at their default value. + await userEvent.click(await waitForSelector(".callout-icon-button")); + await sleep(300); + + const callout = await waitForSelector(".callout"); + const flavor = callout.getAttribute("data-flavor"); + + expect(flavor).toBeTruthy(); + expect(flavor).not.toBe("tip"); + expect(getComputedStyle(callout).backgroundColor).not.toBe(before); + }); +}); diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx index 77cd1cd21d..063ebb68cd 100644 --- a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx +++ b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx @@ -11,6 +11,7 @@ import { import { compareDocToSnapshot, focusOnEditor, + sleep, waitForSelector, } from "../../utils/editor.js"; import { @@ -134,3 +135,59 @@ describe("Check Multi-Column Behaviour", () => { await compareDocToSnapshot("deleteEndOfColumnList"); }); }); + +// Which block the side menu attaches to is resolved from live layout +// (`elementsFromPoint` / `posAtCoords`). The pieces below that are covered +// closer to the code: the arithmetic in +// `packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts` +// (node, plain rects) and the DOM/layout adapters in the `.browser.test.ts` +// beside it. This is the whole path, through a real column list. +// +// Each column carries 25px of its own left padding and the side menu renders +// into it, so the coordinates the lookup is handed belong to one column while +// horizontally overlapping the column before it. `SideMenu.ts` compensates by +// re-probing 50px further right once `isHorizontalContainer` recognises the +// column list; drop that (or let the detection fail — a `display: contents` +// element reports a zero rect, which is exactly what would silently defeat it) +// and the menu attaches to a block in the *previous* column instead. Only the +// padding is affected: hovering a block's own text resolves correctly either +// way, which is why this can't be tested by hovering a block. +describe("Check side menu placement inside a column list", () => { + /** Vertical centre of a rect — what the menu lines itself up with. */ + const centerY = (rect: DOMRect) => rect.y + rect.height / 2; + + test("Check drag handle resolves the block on the hovered row of a column", async () => { + await focusOnEditor(); + + // The last column is the only one holding several blocks, so it's the only + // place a wrongly resolved block is distinguishable by its row. + const target = page.getByText("Block 2").element(); + const columnRect = getRect(target.closest(".bn-block-column")!); + + await mouseSequence([ + { + type: "move", + x: columnRect.x + 5, + y: centerY(getRect(target)), + steps: 5, + }, + ]); + await waitForSelector(DRAG_HANDLE_SELECTOR); + await sleep(150); + const handleRect = getRect(DRAG_HANDLE_SELECTOR); + + expect(handleRect.x).toBeLessThan(getRect(target).x); + + // The handle lines up with the hovered block's row rather than any other + // block's — a stronger claim than a pixel tolerance would be, since every + // candidate is only a line-height away, and it's what distinguishes this + // column's blocks from the neighbouring column's. + const distance = (rect: DOMRect) => + Math.abs(centerY(handleRect) - centerY(rect)); + for (const other of ["Block 1", "Block 3", "So is this heading!"]) { + expect(distance(getRect(target))).toBeLessThan( + distance(getRect(page.getByText(other).element())), + ); + } + }); +}); diff --git a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx index f3e12560d9..2c768cff6c 100644 --- a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx +++ b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx @@ -348,8 +348,8 @@ function opToXml(op: DeltaInsertOp): string { // concurrent merge of two marks), which would otherwise make these // snapshots flaky. Sorted ascending => the alphabetically-first mark // ends up innermost (e.g. `world`). - for (const [name, value] of Object.entries(op.format ?? {}).sort(([a], [b]) => - a < b ? -1 : a > b ? 1 : 0, + for (const [name, value] of Object.entries(op.format ?? {}).sort( + ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0), )) { if (value !== null && typeof value === "object") { // Object value: trivial empty `{}` renders as a bare tag, richer diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx index 71101c7fa7..9f265e5380 100644 --- a/tests/src/unit/react/useNodeViewBlock.test.tsx +++ b/tests/src/unit/react/useNodeViewBlock.test.tsx @@ -27,8 +27,15 @@ const createReproBlock = createReactBlockSpec( { render: (props) =>

}, ); +// A container block, whose node view's node IS the bnBlock — resolved by id +// instead of by position. +const createBoxBlock = createReactBlockSpec( + { type: "box", propSchema: {}, content: "none", children: {} }, + { render: (props) =>

}, +); + const schema = BlockNoteSchema.create().extend({ - blockSpecs: { repro: createReproBlock() }, + blockSpecs: { repro: createReproBlock(), box: createBoxBlock() }, }); let editor: BlockNoteEditor; @@ -43,6 +50,7 @@ beforeEach(() => { { type: "paragraph", content: "first" }, { type: "repro", content: "target block" }, { type: "paragraph", content: "last" }, + { type: "box", children: [{ type: "paragraph", content: "inside" }] }, ], }) as BlockNoteEditor; @@ -78,11 +86,14 @@ function renderHook( return resolved; } -// Only the two fields `useNodeViewBlock` reads. Built structurally so `tests` -// doesn't need a dependency on `@tiptap/react` just for its prop types. -function makeProps(getPos: () => number | undefined) { +// Only the fields `useNodeViewBlock` reads. Built structurally so `tests` +// doesn't need a dependency on `@tiptap/react` just for its prop types. The +// `node` defaults to a regular (non-container) block's node shape; container +// tests pass the real PM node instead. +function makeProps(getPos: () => number | undefined, node?: unknown) { return { getPos, + node: node ?? { type: { isInGroup: () => false } }, view: { state: { doc: editor.prosemirrorState.doc } }, } as unknown as Parameters[0]; } @@ -170,4 +181,34 @@ describe("useNodeViewBlock", () => { expect(resolved.id).toBe(target.id); expect(resolved).not.toBe(seed); }); + + it("rejects container blocks loudly instead of resolving the wrong block", () => { + const box = editor.document[3]; + const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!; + const props = makeProps(() => undefined, node); + + let captured: unknown; + + function Probe() { + useNodeViewBlock(props, box); + return null; + } + + root = createRoot(div, { + // React 19 reports uncaught render errors here instead of rethrowing + // out of `flushSync`. + onUncaughtError: (error: unknown) => { + captured = error; + }, + }); + try { + flushSync(() => { + root!.render(); + }); + } catch (error) { + captured = error; + } + + expect(String(captured)).toMatch(/cannot resolve container block "box"/); + }); }); From e6adf593aed90231dd17977e74b6f72db0ff0400 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 20 Aug 2026 16:28:26 +0200 Subject: [PATCH 2/2] refactor: trim diff by removing extraneous comments, examples, and unrelated changes - Remove verbose JSDoc comments across new container block files where function names are already self-documenting - DRY up test fixture render functions in containers.fixture.ts - Remove table-container example (saved as patch for later) - Remove containerblocks e2e tests (custom example, not default UI) - Revert unrelated changes: inter.css whitespace, vanilla-custom-side-menu package.json, pm-nodes README rewrite --- .../12-table-container/.bnexample.json | 15 -- .../12-table-container/README.md | 20 -- .../12-table-container/index.html | 14 - .../12-table-container/main.tsx | 11 - .../12-table-container/package.json | 31 --- .../12-table-container/src/App.tsx | 201 -------------- .../12-table-container/src/gridTableBlocks.ts | 245 ------------------ .../src/gridTableNavigation.ts | 73 ------ .../src/gridTableOperations.ts | 219 ---------------- .../12-table-container/src/styles.css | 95 ------- .../12-table-container/tsconfig.json | 32 --- .../12-table-container/vite-env.d.ts | 1 - .../12-table-container/vite.config.ts | 35 --- .../vanilla-custom-side-menu/package.json | 2 +- .../commands/updateBlock/updateBlock.ts | 13 - .../containers/containerNav.ts | 57 +--- .../containers/containers.fixture.ts | 51 +--- .../containers/fixContainer.ts | 42 +-- .../src/api/nodeConversions/blockToNode.ts | 53 +--- .../api/nodeConversions/fragmentToBlocks.ts | 23 -- .../SideMenu/sideMenuContainerGeometry.ts | 59 +---- packages/core/src/pm-nodes/README.md | 89 ++----- packages/core/src/schema/blocks/children.ts | 102 +------- .../src/schema/blocks/containerAttributes.ts | 26 +- packages/core/src/schema/blocks/createSpec.ts | 94 +------ .../end-to-end/containerblocks/CardApp.tsx | 107 -------- tests/src/end-to-end/containerblocks/card.css | 33 --- .../containerblocks/containerblocks.test.tsx | 238 ----------------- 28 files changed, 45 insertions(+), 1936 deletions(-) delete mode 100644 examples/06-custom-schema/12-table-container/.bnexample.json delete mode 100644 examples/06-custom-schema/12-table-container/README.md delete mode 100644 examples/06-custom-schema/12-table-container/index.html delete mode 100644 examples/06-custom-schema/12-table-container/main.tsx delete mode 100644 examples/06-custom-schema/12-table-container/package.json delete mode 100644 examples/06-custom-schema/12-table-container/src/App.tsx delete mode 100644 examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts delete mode 100644 examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts delete mode 100644 examples/06-custom-schema/12-table-container/src/gridTableOperations.ts delete mode 100644 examples/06-custom-schema/12-table-container/src/styles.css delete mode 100644 examples/06-custom-schema/12-table-container/tsconfig.json delete mode 100644 examples/06-custom-schema/12-table-container/vite-env.d.ts delete mode 100644 examples/06-custom-schema/12-table-container/vite.config.ts delete mode 100644 tests/src/end-to-end/containerblocks/CardApp.tsx delete mode 100644 tests/src/end-to-end/containerblocks/card.css delete mode 100644 tests/src/end-to-end/containerblocks/containerblocks.test.tsx diff --git a/examples/06-custom-schema/12-table-container/.bnexample.json b/examples/06-custom-schema/12-table-container/.bnexample.json deleted file mode 100644 index 5f15de2aa8..0000000000 --- a/examples/06-custom-schema/12-table-container/.bnexample.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "playground": true, - "docs": false, - "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-table-container/README.md b/examples/06-custom-schema/12-table-container/README.md deleted file mode 100644 index 8ac3ae79e2..0000000000 --- a/examples/06-custom-schema/12-table-container/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Table From Container Blocks - -In this example, we build a table out of three nested container blocks — `gridTable > gridRow > gridCell` — instead of a `content: "table"` block. The whole nesting structure comes from each block's `children` config, so the document schema enforces it, and because a cell's `children` allows any block, cells can hold lists, headings, code blocks, or another table. - -The blocks render real `
` / `` / `
` elements: a container block owns its outer DOM, so `contentRef` / `contentDOM` places its children directly inside. They're written with the vanilla `createBlockSpec` rather than `createReactBlockSpec` for exactly that reason — a React block renders through a `NodeViewWrapper` div, and a `
` between a `` and its rows gives you a table that looks right but isn't one to the DOM. - -What the container API can't express is the grid _semantics_: nothing in `children` can say "every row has the same number of cells", so the row/column buttons and the Tab navigation are ordinary code written against `insertBlocks` / `removeBlocks`. Column spans, rectangular cell selection, and column resizing aren't implemented at all. - -**Try it out:** - -- Type in a cell, then press Tab and Shift-Tab to move between cells — Tab past the last cell adds a row. -- Use the `+ Row` / `+ Column` buttons below a table, and watch the shape readout next to them. -- Press "/" inside a cell and insert a heading, list, or even another Grid Table. -- Watch the JSON panel: a table is just `gridTable > gridRow > gridCell` in `block.children`. - -**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/12-table-container/index.html b/examples/06-custom-schema/12-table-container/index.html deleted file mode 100644 index f8e228611b..0000000000 --- a/examples/06-custom-schema/12-table-container/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Table From Container Blocks - - - -
- - - diff --git a/examples/06-custom-schema/12-table-container/main.tsx b/examples/06-custom-schema/12-table-container/main.tsx deleted file mode 100644 index 1260513388..0000000000 --- a/examples/06-custom-schema/12-table-container/main.tsx +++ /dev/null @@ -1,11 +0,0 @@ -// 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-table-container/package.json b/examples/06-custom-schema/12-table-container/package.json deleted file mode 100644 index 8e91af71db..0000000000 --- a/examples/06-custom-schema/12-table-container/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@blocknote/example-custom-schema-table-container", - "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", - "type": "module", - "private": true, - "version": "0.12.4", - "scripts": { - "start": "vp dev", - "dev": "vp dev", - "build:prod": "tsc && vp build", - "preview": "vp preview" - }, - "dependencies": { - "@blocknote/ariakit": "latest", - "@blocknote/core": "latest", - "@blocknote/mantine": "latest", - "@blocknote/react": "latest", - "@blocknote/shadcn": "latest", - "@mantine/core": "^9.0.2", - "@mantine/hooks": "^9.0.2", - "react": "^19.2.3", - "react-dom": "^19.2.3", - "react-icons": "^5.5.0" - }, - "devDependencies": { - "@types/react": "^19.2.3", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "vite-plus": "^0.1.24" - } -} diff --git a/examples/06-custom-schema/12-table-container/src/App.tsx b/examples/06-custom-schema/12-table-container/src/App.tsx deleted file mode 100644 index 58d626d3ab..0000000000 --- a/examples/06-custom-schema/12-table-container/src/App.tsx +++ /dev/null @@ -1,201 +0,0 @@ -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 { RiLayoutGridLine } from "react-icons/ri"; - -import { GridCellBlock, GridRowBlock, GridTableBlock } from "./gridTableBlocks"; -import "./styles.css"; - -// The three container blocks are ordinary members of the schema — the default -// `table` block (which uses `content: "table"`) is still here alongside them, -// so you can compare the two approaches in the same document. -const schema = BlockNoteSchema.create().extend({ - blockSpecs: { - ...defaultBlockSpecs, - gridTable: GridTableBlock(), - gridRow: GridRowBlock(), - gridCell: GridCellBlock(), - }, -}); - -const insertGridTable = (editor: typeof schema.BlockNoteEditor) => ({ - title: "Grid Table", - subtext: "Table built out of nested container blocks", - onItemClick: () => - // No `children` given, so BlockNote seeds the table from the block's - // `children.default` — a 2x2 grid with a header row. - insertOrUpdateBlockForSlashMenu(editor, { type: "gridTable" }), - aliases: ["grid", "gridtable", "container table"], - group: "Basic blocks", - icon: , -}); - -type AppBlock = (typeof schema.BlockNoteEditor)["document"][number]; -type AppPartialBlock = typeof schema.PartialBlock; - -// Two shorthands, so the table below reads as a table rather than as four -// levels of nested object literals. -function headerCell(text: string) { - return { - type: "gridCell" as const, - props: { header: true }, - children: [{ type: "paragraph" as const, content: text }], - }; -} - -function cell(...children: AppPartialBlock[]): AppPartialBlock { - // `cell()` with no arguments passes `children: []`, which the cell's - // `min: 1` fills back up to a single empty paragraph. - return { type: "gridCell", children }; -} - -export default function App() { - const [blocks, setBlocks] = useState([]); - - const editor = useCreateBlockNote({ - schema, - initialContent: [ - { - type: "paragraph", - content: - "The table below is not a table block — it's three container blocks nested into each other.", - }, - { - type: "gridTable", - children: [ - { - type: "gridRow", - children: [ - headerCell("Block"), - headerCell("children config"), - headerCell("Renders as"), - ], - }, - { - type: "gridRow", - children: [ - cell({ type: "paragraph", content: "gridTable" }), - cell({ type: "codeBlock", content: 'containers: ["gridRow"]' }), - cell({ type: "paragraph", content: "
" }), - ], - }, - { - type: "gridRow", - children: [ - cell({ type: "paragraph", content: "gridRow" }), - cell({ type: "codeBlock", content: 'containers: ["gridCell"]' }), - cell({ type: "paragraph", content: "" }), - ], - }, - { - type: "gridRow", - children: [ - cell({ type: "paragraph", content: "gridCell" }), - cell({ type: "codeBlock", content: "blocks: true" }), - cell({ type: "paragraph", content: "
/ " }), - ], - }, - ], - }, - { - type: "paragraph", - content: - 'Because a cell allows any block, it can hold things a `content: "table"` cell can\'t:', - }, - { - type: "gridTable", - children: [ - { - type: "gridRow", - children: [ - headerCell("A list in a cell"), - headerCell("A nested table"), - ], - }, - { - type: "gridRow", - children: [ - cell( - { type: "bulletListItem", content: "First" }, - { - type: "bulletListItem", - content: "Second", - children: [{ type: "bulletListItem", content: "Nested" }], - }, - { - type: "heading", - props: { level: 3 }, - content: "And a heading", - }, - ), - cell({ - type: "gridTable", - children: [ - { - type: "gridRow", - children: [cell(), cell()], - }, - { - type: "gridRow", - children: [cell(), cell()], - }, - ], - }), - ], - }, - ], - }, - { 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, - insertGridTable(editor), - ); - return filterSuggestionItems(defaultItems, query); - }} - /> - -
-
Document JSON:
-
-
-          {JSON.stringify(blocks, null, 2)}
-        
-
-
- ); -} diff --git a/examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts b/examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts deleted file mode 100644 index d577a540b2..0000000000 --- a/examples/06-custom-schema/12-table-container/src/gridTableBlocks.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { createBlockSpec } from "@blocknote/core"; - -import { GridTableNavigationExtension } from "./gridTableNavigation"; -import { - addColumn, - addRow, - removeColumn, - removeRow, - shapeFrom, -} from "./gridTableOperations"; - -/** - * A table built entirely out of container blocks — no `content: "table"`, no - * custom node spec, no ProseMirror plugin. Three container blocks nest into - * each other: - * - * gridTable > gridRow+ (a table is a list of rows) - * gridRow > gridCell+ (a row is a list of cells) - * gridCell > any block (a cell holds arbitrary blocks) - * - * The `children` config on each is what the ProseMirror content expression is - * compiled from, so the nesting rules above are enforced by the document - * schema rather than by repair code. - * - * What the schema does *not* enforce is that every row has the same number of - * cells: a content expression is a regular language over one node's children, - * and "as many cells as your sibling has" relates siblings to each other. So - * that invariant lives in `gridTableOperations`, which is exactly the point - * this example is here to make visible. - * - * These are deliberately vanilla `createBlockSpec` blocks rather than - * `createReactBlockSpec` ones, even though the surrounding app is React: a - * React block is rendered through a `NodeViewWrapper` div, and a `
` - * between a `` and its ``s produces a table that *looks* right - * (`display: contents` generates no box) but isn't one to the DOM — - * `table.rows` comes back empty, and React logs a nesting error. A vanilla - * block's `dom` is handed to ProseMirror as-is, so the elements below really - * do form a table. - */ - -// The subset of a ProseMirror node the table's `update` hook needs, so the -// example doesn't have to depend on `prosemirror-model` directly. -type GridNode = { - type: { name: string }; - childCount: number; - child(index: number): GridNode; -}; - -// A cell holds any blocks at all — paragraphs, headings, lists, even another -// grid table. That falls out of `allow.blocks: true` and costs nothing. -export const GridCellBlock = createBlockSpec( - { - type: "gridCell" as const, - propSchema: { - header: { default: false }, - }, - content: "none", - children: { - allow: { blocks: true }, - min: 1, - default: [{ type: "paragraph" }], - // A cell is not a list: Enter in the last block of a cell should add a - // block to the cell, not escape it. - exitOnEnter: false, - }, - // A cell only ever exists inside a row. The schema rejects one anywhere - // else, including at the document root. - placement: "containerOnly", - }, - { - meta: { - // Dragging a cell out of its row would leave a ragged table, and there is - // nowhere else a cell may legally go anyway. - draggable: false, - }, - render: (block) => { - // Container blocks own their outer DOM — they are not wrapped in a - // `blockContainer` div — so this really is a table cell. - const dom = document.createElement(block.props.header ? "th" : "td"); - dom.className = "grid-cell"; - - return { dom, contentDOM: dom }; - }, - }, -); - -export const GridRowBlock = createBlockSpec( - { - type: "gridRow" as const, - propSchema: {}, - content: "none", - children: { - // Rows hold cells and nothing else. `blocks: false` keeps a stray - // paragraph out; the explicit container list keeps every other container - // type out. - allow: { blocks: false, containers: ["gridCell"] }, - min: 1, - default: [{ type: "gridCell" }], - exitOnEnter: false, - }, - placement: "containerOnly", - }, - { - meta: { - draggable: false, - }, - render: () => { - const dom = document.createElement("tr"); - dom.className = "grid-row"; - - return { dom, contentDOM: dom }; - }, - }, -); - -export const GridTableBlock = createBlockSpec( - { - type: "gridTable" as const, - propSchema: {}, - content: "none", - children: { - allow: { blocks: false, containers: ["gridRow"] }, - min: 1, - // Inserting a bare `{ type: "gridTable" }` (from the Slash Menu, say) - // seeds this 2x2 grid. - default: [ - { - type: "gridRow", - children: [ - { type: "gridCell", props: { header: true } }, - { type: "gridCell", props: { header: true } }, - ], - }, - { - type: "gridRow", - children: [{ type: "gridCell" }, { type: "gridCell" }], - }, - ], - unwrapWhenEmptied: true, - }, - }, - { - render: (block, editor) => { - const dom = document.createElement("div"); - dom.className = "grid-table-wrapper"; - - const table = document.createElement("table"); - table.className = "grid-table"; - const tbody = document.createElement("tbody"); - table.append(tbody); - - // Grid-shape operations are plain calls to the public block API — - // `insertBlocks` / `removeBlocks` over `block.children`. The container - // API has no notion of a row or a column, so keeping the table - // rectangular is this code's job, not the schema's. - const controls = document.createElement("div"); - controls.className = "grid-table-controls"; - // Keeps ProseMirror from treating clicks in here as document input. - controls.contentEditable = "false"; - - const shapeLabel = document.createElement("span"); - shapeLabel.className = "grid-table-shape"; - controls.append(shapeLabel); - - const button = ( - label: string, - run: (table: NonNullable>) => void, - ) => { - const element = document.createElement("button"); - element.type = "button"; - element.textContent = label; - // Without this the button steals the selection from the editor, and - // the operation would run against a document with no cursor in it. - element.addEventListener("mousedown", (event) => - event.preventDefault(), - ); - element.addEventListener("click", () => { - // Re-read the block rather than closing over the one this render was - // handed: the node view outlives any single document state. - const current = editor.getBlock(block.id); - if (current) { - run(current); - } - }); - controls.append(element); - - return element; - }; - - button("+ Row", (current) => addRow(editor, current)); - const removeRowButton = button("- Row", (current) => - removeRow(editor, current), - ); - button("+ Column", (current) => addColumn(editor, current)); - const removeColumnButton = button("- Column", (current) => - removeColumn(editor, current), - ); - - dom.append(table, controls); - - const refresh = (node: GridNode) => { - const shape = shapeFrom( - Array.from({ length: node.childCount }, (_, row) => { - return node.child(row).childCount; - }), - ); - - shapeLabel.textContent = `${shape.rows} x ${shape.columns}${ - shape.ragged ? " (ragged)" : "" - }`; - removeRowButton.disabled = shape.rows <= 1; - removeColumnButton.disabled = shape.columns <= 1; - }; - - refresh({ - type: { name: "gridTable" }, - childCount: block.children.length, - child: (row) => - ({ childCount: block.children[row].children.length }) as GridNode, - }); - - return { - dom, - // The table's editable region is its : that is where the row - // blocks mount. A container's `contentDOM` is "put my children here", - // so it can point at a nested element like this one. - contentDOM: tbody, - // Containers honor `update`, and a table has to: recreating the node - // view on every edit would remount — and blur — every cell. - update: (node: GridNode) => { - if (node.type.name !== "gridTable") { - return false; - } - - refresh(node); - - return true; - }, - }; - }, - }, - // Cell-to-cell Tab movement isn't something the container API can express, so - // it ships as an extension alongside the block. - [GridTableNavigationExtension()], -); diff --git a/examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts b/examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts deleted file mode 100644 index 2b628e48d8..0000000000 --- a/examples/06-custom-schema/12-table-container/src/gridTableNavigation.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { type BlockNoteEditor, createExtension } from "@blocknote/core"; - -import { - addRow, - adjacentCell, - cursorTargetIn, - findCell, -} from "./gridTableOperations"; - -/** - * Tab / Shift-Tab movement between cells. - * - * The container API's keyboard story is one-dimensional and list-shaped - * (`exitOnEnter`, plus the built-in Enter/Backspace handling of children), so - * moving *across* a grid is entirely custom. This extension is attached to the - * `gridTable` block spec, which registers it ahead of BlockNote's own Tab - * binding (which indents blocks) — so the handler must return `false` whenever - * the cursor isn't in a grid cell, or Tab would stop indenting everywhere else. - */ -export const GridTableNavigationExtension = createExtension({ - key: "gridTableNavigation", - keyboardShortcuts: { - Tab: ({ editor }) => moveCell(editor, 1), - "Shift-Tab": ({ editor }) => moveCell(editor, -1), - }, -}); - -function moveCell(editor: BlockNoteEditor, direction: 1 | -1) { - const { block } = editor.getTextCursorPosition(); - const position = findCell(editor.document, block.id); - - if (!position) { - return false; - } - - const target = adjacentCell(position, direction); - - if (!target) { - // Tab past the last cell grows the table, the way every other table editor - // behaves. Shift-Tab at the very first cell just does nothing. - if (direction === -1) { - return true; - } - - addRow(editor, position.table); - - const grown = editor.getBlock(position.table.id); - const newRow = grown?.children.at(-1); - const firstCellOfNewRow = newRow?.children.at(0); - const cursorTarget = - firstCellOfNewRow && cursorTargetIn(firstCellOfNewRow, "start"); - - if (cursorTarget) { - editor.setTextCursorPosition(cursorTarget, "start"); - } - - return true; - } - - const cursorTarget = cursorTargetIn( - target, - direction === 1 ? "start" : "end", - ); - - if (cursorTarget) { - editor.setTextCursorPosition( - cursorTarget, - direction === 1 ? "start" : "end", - ); - } - - return true; -} diff --git a/examples/06-custom-schema/12-table-container/src/gridTableOperations.ts b/examples/06-custom-schema/12-table-container/src/gridTableOperations.ts deleted file mode 100644 index dda53b8ffd..0000000000 --- a/examples/06-custom-schema/12-table-container/src/gridTableOperations.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { Block, BlockNoteEditor } from "@blocknote/core"; - -/** - * Grid operations for the container-block table. - * - * Everything here is written against the *public* block API — `insertBlocks`, - * `removeBlocks`, and the `children` arrays on `editor.document`. None of it is - * declared in the `children` config, and that is the honest result of the - * experiment: the container API can say "a row holds cells", but it cannot say - * "every row holds the same number of cells", because a content expression is a - * regular language over one node's children and rectangularity is a constraint - * *between* siblings. So the invariant lives in imperative code, the way - * `prosemirror-tables` keeps it in `fixTables` rather than in its schema. - */ - -// The example's blocks are added to a schema the caller assembles, so these -// helpers are written against the generic editor/block types rather than a -// concrete schema. -type AnyEditor = BlockNoteEditor; -type AnyBlock = Block; - -export type GridShape = { - rows: number; - /** The widest row's cell count. */ - columns: number; - /** Whether the rows disagree on their cell count. */ - ragged: boolean; -}; - -export function gridShape(table: AnyBlock): GridShape { - return shapeFrom(table.children.map((row) => row.children.length)); -} - -/** {@link gridShape}, for a caller holding cell counts rather than blocks. */ -export function shapeFrom(cellCounts: number[]): GridShape { - return { - rows: cellCounts.length, - columns: cellCounts.length > 0 ? Math.max(...cellCounts) : 0, - ragged: new Set(cellCounts).size > 1, - }; -} - -function newCell(header: boolean) { - return { type: "gridCell" as const, props: { header } }; -} - -export function addRow(editor: AnyEditor, table: AnyBlock) { - const { columns } = gridShape(table); - const lastRow = table.children.at(-1); - const newRow = { - type: "gridRow" as const, - children: Array.from({ length: Math.max(columns, 1) }, () => - newCell(false), - ), - }; - - editor.transact(() => { - // A table always has at least one row (`min: 1`), so `lastRow` is only - // missing if something upstream is broken — but `"end"` addresses the - // container itself, so there is a correct answer either way. - if (lastRow) { - editor.insertBlocks([newRow], lastRow, "after"); - } else { - editor.insertBlocks([newRow], table, "end"); - } - }); -} - -export function removeRow(editor: AnyEditor, table: AnyBlock) { - const lastRow = table.children.at(-1); - // The schema requires one row, so removing the last one would just be - // re-filled by ProseMirror with an empty one. - if (!lastRow || table.children.length <= 1) { - return; - } - - editor.removeBlocks([lastRow]); -} - -export function addColumn(editor: AnyEditor, table: AnyBlock) { - // One transaction for the whole column, so undo takes the column back as a - // unit rather than one cell at a time. - editor.transact(() => { - for (const row of table.children) { - const lastCell = row.children.at(-1); - // Inherit the row's header-ness from the cell we're appending after, so - // adding a column to a table with a header row keeps the header intact. - const cell = newCell(lastCell?.props.header === true); - - if (lastCell) { - editor.insertBlocks([cell], lastCell, "after"); - } else { - editor.insertBlocks([cell], row, "end"); - } - } - }); -} - -export function removeColumn(editor: AnyEditor, table: AnyBlock) { - const cellsToRemove = table.children - // Same reasoning as `removeRow`: a row must keep at least one cell. - .filter((row) => row.children.length > 1) - .map((row) => row.children.at(-1)) - .filter((cell) => cell !== undefined); - - if (cellsToRemove.length === 0) { - return; - } - - editor.transact(() => editor.removeBlocks(cellsToRemove)); -} - -/** Where a cell sits in its table, plus the table it sits in. */ -export type CellPosition = { - table: AnyBlock; - row: number; - column: number; -}; - -/** - * Finds the cell the text cursor is in, by walking the document for a - * `gridCell` that contains the given block. - * - * This walk is the second thing the container API doesn't give you: blocks know - * their children, not their grid coordinates. `getTextCursorPosition` reaches - * one level up (`parentBlock`), which is not enough once a cell holds a list or - * a nested container. - */ -export function findCell( - document: AnyBlock[], - blockId: string, -): CellPosition | undefined { - for (const block of document) { - // Depth-first, so a table nested inside a cell wins over the outer table - // the cursor is also technically inside of. - const nested = findCell(block.children, blockId); - if (nested) { - return nested; - } - - if (block.type === "gridTable") { - const found = findCellInTable(block, blockId); - if (found) { - return found; - } - } - } - - return undefined; -} - -function findCellInTable( - table: AnyBlock, - blockId: string, -): CellPosition | undefined { - for (const [row, rowBlock] of table.children.entries()) { - for (const [column, cell] of rowBlock.children.entries()) { - if (cell.id === blockId || containsBlock(cell.children, blockId)) { - return { table, row, column }; - } - } - } - - return undefined; -} - -function containsBlock(blocks: AnyBlock[], blockId: string): boolean { - return blocks.some( - (block) => block.id === blockId || containsBlock(block.children, blockId), - ); -} - -/** - * The cell one step forward (or back) in reading order, or `undefined` at - * either end of the table. - */ -export function adjacentCell( - position: CellPosition, - direction: 1 | -1, -): AnyBlock | undefined { - const { table, row, column } = position; - const nextColumn = column + direction; - const cellsInRow = table.children[row]?.children ?? []; - - if (nextColumn >= 0 && nextColumn < cellsInRow.length) { - return cellsInRow[nextColumn]; - } - - const nextRow = table.children[row + direction]; - if (!nextRow) { - return undefined; - } - - return direction === 1 ? nextRow.children.at(0) : nextRow.children.at(-1); -} - -/** - * The block the text cursor should land on when moving into a cell: its first - * (or last) block, descending through any *container* blocks on the way — a - * cell may itself hold a column list or another grid table, and neither can - * take a text cursor. - */ -export function cursorTargetIn( - container: AnyBlock, - edge: "start" | "end", -): AnyBlock | undefined { - const child = - edge === "start" ? container.children.at(0) : container.children.at(-1); - - if (!child) { - return undefined; - } - - // A block with no content of its own and children to show for it is a - // container; anything else can hold the cursor. - const isContainer = child.content === undefined && child.children.length > 0; - - return isContainer ? cursorTargetIn(child, edge) : child; -} diff --git a/examples/06-custom-schema/12-table-container/src/styles.css b/examples/06-custom-schema/12-table-container/src/styles.css deleted file mode 100644 index 3cdfc70dfc..0000000000 --- a/examples/06-custom-schema/12-table-container/src/styles.css +++ /dev/null @@ -1,95 +0,0 @@ -.wrapper { - display: flex; - flex-direction: column; - height: 100%; -} - -.item { - border-radius: 0.5rem; - flex: 1; - /* The example's document is taller than the pane it sits in. */ - overflow: auto; -} - -.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; -} - -.grid-table-wrapper { - flex-grow: 1; - min-width: 0; -} - -.grid-table { - border-collapse: collapse; - /* Fixed layout keeps the columns even, which matters because nothing in the - schema knows what a column is. */ - table-layout: fixed; - width: 100%; -} - -.grid-cell { - border: 1px solid var(--grid-border, #d4d4d8); - padding: 6px 10px; - vertical-align: top; - text-align: left; -} - -th.grid-cell { - background-color: var(--grid-header-bg, #f4f4f5); - font-weight: 600; -} - -/* Cells hold whole blocks, so the block's own vertical rhythm has to be reined - in a little to sit inside a table cell. */ -.grid-cell > * { - padding-inline: 0; -} - -.grid-table-controls { - align-items: center; - display: flex; - gap: 6px; - margin-top: 6px; - user-select: none; -} - -.grid-table-shape { - color: #71717a; - font-size: 0.75rem; - margin-right: 4px; -} - -.grid-table-controls button { - background: none; - border: 1px solid var(--grid-border, #d4d4d8); - border-radius: 4px; - color: inherit; - cursor: pointer; - font-size: 0.75rem; - padding: 2px 8px; -} - -.grid-table-controls button:hover:not(:disabled) { - background-color: var(--grid-header-bg, #f4f4f5); -} - -.grid-table-controls button:disabled { - cursor: default; - opacity: 0.4; -} - -[data-color-scheme="dark"] { - --grid-border: #3f3f46; - --grid-header-bg: #27272a; -} diff --git a/examples/06-custom-schema/12-table-container/tsconfig.json b/examples/06-custom-schema/12-table-container/tsconfig.json deleted file mode 100644 index 2aa62c56e6..0000000000 --- a/examples/06-custom-schema/12-table-container/tsconfig.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "__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-table-container/vite-env.d.ts b/examples/06-custom-schema/12-table-container/vite-env.d.ts deleted file mode 100644 index 11f02fe2a0..0000000000 --- a/examples/06-custom-schema/12-table-container/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/06-custom-schema/12-table-container/vite.config.ts b/examples/06-custom-schema/12-table-container/vite.config.ts deleted file mode 100644 index 2ee49b1f1e..0000000000 --- a/examples/06-custom-schema/12-table-container/vite.config.ts +++ /dev/null @@ -1,35 +0,0 @@ -// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY -import react from "@vitejs/plugin-react"; -import * as fs from "fs"; -import * as path from "path"; -import { defineConfig } from "vite-plus"; -// https://vitejs.dev/config/ -export default defineConfig(((conf: { command: string }) => ({ - plugins: [react()], - optimizeDeps: {}, - build: { - sourcemap: true, - }, - resolve: { - alias: - conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) - ? {} - : ({ - // 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/vanilla-js/vanilla-custom-side-menu/package.json b/examples/vanilla-js/vanilla-custom-side-menu/package.json index 7487cf2429..a26d149f86 100644 --- a/examples/vanilla-js/vanilla-custom-side-menu/package.json +++ b/examples/vanilla-js/vanilla-custom-side-menu/package.json @@ -25,6 +25,6 @@ "@types/react": "^19.2.3", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", - "vite-plus": "^0.1.24" + "vite": "^8.0.0" } } diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 244e13094f..5ebc4a619e 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -211,17 +211,6 @@ export function updateBlockTr< } } -/** - * What a block's existing content becomes when the block is replaced wholesale - * by one of a different shape. - * - * Inline content survives wherever the target has somewhere to put it: a - * regular block's or a content-bearing container's `content`, or — for a *pure* - * container, which has no content of its own — a leading child block. It's - * dropped only when there is genuinely nowhere for it to go: a contentless - * block, or across a table boundary (table content fits nothing else, and - * nothing else fits a table). - */ function carryOverContent( existingContent: Block["content"], newBlockType: string, @@ -232,8 +221,6 @@ function carryOverContent( } { const nothing = { children: [] }; - // `Array.isArray` also rules out table content, which only fits a block that - // holds table content — and such a block accepts nothing else. if (!existingContent || !Array.isArray(existingContent)) { return nothing; } diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts index 4d79f8fec0..a7cd2df4b7 100644 --- a/packages/core/src/api/blockManipulation/containers/containerNav.ts +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -3,23 +3,6 @@ import type { Node, NodeType } from "prosemirror-model"; import { isContentContainerNode } from "../../../schema/blocks/children.js"; import { isContainerNode } from "./fixContainer.js"; -/** - * Position-based helpers for navigating container blocks. These generalize - * what the keyboard handlers used to hard-code for the exact - * `columnList > column > blockContainer` shape: they recurse through - * arbitrarily nested containers and consult the schema's content matches - * instead of assuming two levels. - */ - -/** - * Finds the deepest position inside `container` where a node of `nodeType` - * can be appended at the end, descending through trailing nested containers - * (e.g. into the last `column` of a `columnList`, which itself doesn't accept - * `blockContainer` children). Returns null if no level accepts the node. - * @param container The container node. - * @param containerBeforePos The position just before `container`. - * @param nodeType The node type to find an insertion position for. - */ export function descendToLastInsertionPos( container: Node, containerBeforePos: number, @@ -40,19 +23,12 @@ export function descendToLastInsertionPos( return null; } -/** - * Mirror of `descendToLastInsertionPos`: the deepest position inside - * `container` where a node of `nodeType` can be prepended at the start. - */ export function descendToFirstInsertionPos( container: Node, containerBeforePos: number, nodeType: NodeType, ): number | null { - // A container with its own content leads with that content, so the start of - // its *children* is one node further in. `descendToLastInsertionPos` needs no - // such case: it reaches the generated `__children` node as the last child, - // which is itself a container node. + // A content container's children start after the content node. if (isContentContainerNode(container)) { return descendToFirstInsertionPos( container.lastChild!, @@ -72,20 +48,10 @@ export function descendToFirstInsertionPos( return null; } -/** - * Descends through leading nested containers to the first non-container child - * (e.g. the first `blockContainer` inside the first `column` of a - * `columnList`). Returns null for a container whose leading chain has no - * such child. - * @param container The container node. - * @param containerBeforePos The position just before `container`. - */ export function getFirstLeafBlock( container: Node, containerBeforePos: number, ): { node: Node; beforePos: number } | null { - // A container with its own content leads with that content, which is not a - // block — its children are one node further in. if (isContentContainerNode(container)) { return getFirstLeafBlock( container.lastChild!, @@ -104,16 +70,6 @@ export function getFirstLeafBlock( return { node: firstChild, beforePos: firstChildBeforePos }; } -/** - * Climbs upward from a position until one is found where a node of - * `nodeType` may be inserted, moving to just before each enclosing container - * in turn (e.g. from before a first `column` — where only `column` nodes are - * allowed — to before the enclosing `columnList`). Returns null when an - * enclosing non-container parent still doesn't accept the node. - * @param doc The document to resolve positions in. - * @param pos The position to start climbing from. - * @param nodeType The node type to find an insertion position for. - */ export function ascendToInsertablePos( doc: Node, pos: number, @@ -133,13 +89,6 @@ export function ascendToInsertablePos( } } -/** - * Collects the chain of container-node ancestors at a position (deepest - * first) as `{ id, depth }` entries. Containers are re-located by id when - * repairing, since repairs shift positions. - * @param doc The document to resolve the position in. - * @param pos A position inside the containers of interest. - */ export function getAncestorContainers( doc: Node, pos: number, @@ -148,10 +97,6 @@ export function getAncestorContainers( const containers: { id: string; depth: number }[] = []; for (let depth = $pos.depth; depth > 0; depth--) { const ancestor = $pos.node(depth); - // A container with its own content is not itself a container node (its - // generated `__children` node is), but it's the node repair acts on — and - // the one carrying the id. `__children` has no id, so it's skipped here - // and reached through its owner instead. if ( (isContainerNode(ancestor.type) || isContentContainerNode(ancestor)) && ancestor.attrs.id diff --git a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts index faaea8df7c..e6ba95f584 100644 --- a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -2,12 +2,11 @@ import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; -// The container schema shared by `containers.test.ts` (node: document model) -// and `containers.browser.test.ts` (real browser: keymap + serialization), so -// both halves are describing the same blocks. +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; -// A vanilla (non-React) container block accepting any children, with -// `default` seeding — the callout from the container-block example. const Callout = createBlockSpec( { type: "callout" as const, @@ -23,18 +22,9 @@ const Callout = createBlockSpec( default: [{ type: "paragraph" }], }, }, - { - render: (block) => { - const dom = document.createElement("div"); - dom.className = "callout"; - dom.setAttribute("data-node-type", "callout"); - dom.setAttribute("data-id", block.id); - return { dom, contentDOM: dom }; - }, - }, + { render: renderDiv }, )(); -// A container that never exits on Enter (like columns). const LockedBox = createBlockSpec( { type: "lockedBox" as const, @@ -42,19 +32,9 @@ const LockedBox = createBlockSpec( content: "none", children: { min: 1, exitOnEnter: false }, }, - { - render: (block) => { - const dom = document.createElement("div"); - dom.setAttribute("data-node-type", "lockedBox"); - dom.setAttribute("data-id", block.id); - return { dom, contentDOM: dom }; - }, - }, + { render: renderDiv }, )(); -// A columnList-like pair: a horizontal container restricted to `gridCell` -// children (min 2) with column-style repair, and a non-top-level cell. -// Proves the "custom table-like structure" story is buildable. const Grid = createBlockSpec( { type: "grid" as const, @@ -66,15 +46,7 @@ const Grid = createBlockSpec( unwrapWhenEmptied: true, }, }, - { - render: (block) => { - const dom = document.createElement("div"); - dom.setAttribute("data-node-type", "grid"); - dom.setAttribute("data-id", block.id); - dom.style.display = "flex"; - return { dom, contentDOM: dom }; - }, - }, + { render: renderDiv }, )(); const GridCell = createBlockSpec( @@ -85,14 +57,7 @@ const GridCell = createBlockSpec( children: {}, placement: "containerOnly", }, - { - render: (block) => { - const dom = document.createElement("div"); - dom.setAttribute("data-node-type", "gridCell"); - dom.setAttribute("data-id", block.id); - return { dom, contentDOM: dom }; - }, - }, + { render: renderDiv }, )(); export const containerSchema = BlockNoteSchema.create().extend({ diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts index cd124b92f0..a756da8573 100644 --- a/packages/core/src/api/blockManipulation/containers/fixContainer.ts +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -16,14 +16,6 @@ export function isContainerNode(type: NodeType): boolean { return type.isInGroup("childContainer") && type.name !== "blockGroup"; } -/** - * A container child is "empty" when it contributes no meaningful content: - * a blockContainer with just an empty paragraph, or a nested container - * whose only child is itself empty (recursively). - * - * Multi-child nodes are never empty — collapsing user-built structure would - * be destructive. - */ export function isEmptyContainerChild(node: Node): boolean { if (node.type.name === "blockContainer") { const blockContent = node.firstChild; @@ -72,21 +64,11 @@ function isInsertableChild(node: Node): boolean { ); } -/** - * The two nodes repair works on. For a pure container they're the same node — - * it holds its children directly. A container that also has its own content - * keeps its children in a generated `__children` node, so children are counted - * there while unwrapping still has to remove the block's own node. - */ type ContainerRepairTarget = { - /** The block type whose `children` config governs the repair. */ blockType: string; - /** Position just before the block's own node — what unwrapping replaces. */ blockPos: number; blockNode: Node; - /** Position just before the node holding the children. */ childrenPos: number; - /** The container's own content node, when it has one. */ contentNode: Node | undefined; }; @@ -114,9 +96,7 @@ function getContainerRepairTarget( return undefined; } - // A generated `__children` node is a container node, but it isn't a block — - // its name isn't in the block schema and unwrapping it would strand the - // title. Normalize to the block that owns it. + // A `__children` node: normalize to the block that owns it. if (blockTypeOfContainerChildrenNode(node.type.name)) { return getContainerRepairTarget(doc, doc.resolve(containerPos).before()); } @@ -130,11 +110,6 @@ function getContainerRepairTarget( }; } -/** - * Repairs a container after children were removed, per `unwrapWhenEmptied`: - * drops empty children, then unwraps the container if fewer non-empty children - * remain than it requires. - */ export function fixContainer(tr: Transaction, containerPos: number) { const target = getContainerRepairTarget(tr.doc, containerPos); if (!target) { @@ -153,9 +128,7 @@ export function fixContainer(tr: Transaction, containerPos: number) { return; } - // Unwrapping removes the container's own node, and with it whatever the user - // typed into its content. Repair is a background pass triggered by unrelated - // edits, so it stops here rather than silently destroying that text. + // Don't silently destroy non-empty content. if (target.contentNode && target.contentNode.content.size > 0) { return; } @@ -230,10 +203,6 @@ export function fixContainer(tr: Transaction, containerPos: number) { tr.replaceWith(target.blockPos, blockEnd, Fragment.from(replacement)); } -/** - * Runs `fixContainer` on containers by id, deepest first. - * Re-locates each container before repair since positions shift. - */ export function fixContainersById( tr: Transaction, containers: { id: string; depth: number }[], @@ -249,10 +218,6 @@ export function fixContainersById( }); } -/** - * Flattens blocks that can't be inserted at the top level (e.g. `column`) - * into their children, recursively. - */ export function flattenNonInsertableBlocks< T extends { type?: string; content?: unknown; children?: T[] }, >(blocks: T[], pmSchema: Schema): T[] { @@ -267,9 +232,6 @@ export function flattenNonInsertableBlocks< block.children ?? [], pmSchema, ); - // Flattening drops the block itself. If it had content of its own — - // a container's title, text the user typed — that leads as a paragraph - // rather than disappearing with it. return Array.isArray(block.content) && block.content.length > 0 ? [ { type: "paragraph", content: block.content } as unknown as T, diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 2ecc0f8974..d32b040e45 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -359,14 +359,7 @@ function unwrapsWhenEmptied(blockType: string, schema: Schema): boolean { return !!children && resolveChildren(children).unwrapWhenEmptied; } -/** - * Assigns ids to any block nodes that don't have one. - * - * `createAndFill` builds missing children straight from the schema, and those - * carry `id: null`. The `UniqueID` plugin never gets a chance to fix them, - * because callers like `insertBlocks` convert the node back through - * `nodeToBlock` before the transaction is ever dispatched. - */ +// `createAndFill` produces nodes with `id: null`; patch them before use. function withGeneratedIds(node: Node): Node { if (node.isText) { return node; @@ -392,12 +385,6 @@ function withGeneratedIds(node: Node): Node { ); } -/** - * Produces child nodes from a container's `default` config, guarding against - * self-referential cycles. Returns `undefined` when seeding is not needed (no - * defaults, a container allowed to be empty, or the caller already supplied - * children). - */ function seedDefaultChildren( blockType: string, schema: Schema, @@ -436,10 +423,6 @@ function seedDefaultChildren( ); } -/** - * The nodes a block's inline content becomes, for a block whose content node - * isn't the block's own node (a container with its own content). - */ function partialContentToInlineNodes( block: PartialBlock, contentNodeName: string, @@ -463,16 +446,6 @@ function partialContentToInlineNodes( ); } -/** - * Creates the node holding a container's children when the caller didn't - * specify any: seeds the container's `default`, then lets ProseMirror supply - * whatever the content expression still requires. That's what keeps the - * minimal `children: {}` container from being created empty and throwing a raw - * ProseMirror error at the caller's `node.check()`. - * - * For a pure container that node is the block's own node (hence `attrs`); for - * a container with its own content it's the generated children node. - */ function createContainerChildrenNode( blockType: string, type: NodeType, @@ -488,9 +461,6 @@ function createContainerChildrenNode( seedingTypes, ); - // ...except for a container that unwraps as it empties out. Filling one of - // those produces a block that the next repair pass immediately deletes, so - // an empty one is refused rather than silently created and then lost. if (!seeded && unwrapsWhenEmptied(blockType, schema)) { return type.create(attrs); } @@ -506,25 +476,8 @@ function createContainerChildrenNode( return node; } -/** - * The node for a container whose children the caller gave explicitly. - * - * The children are kept exactly as given, but still filled out to what the - * content expression requires. `children: []` on a `min: 1` container is a - * caller asking for something the schema cannot represent, so it gets the same - * empty paragraph the omitted-children path would have produced — rather than - * an under-filled node that only the call sites which happen to run - * `node.check()` reject, and that `initialContent` lets straight through. - * - * Two cases are deliberately left unfilled: - * - * - A container that unwraps as it empties out. Padding one of those back up - * to its minimum adds a child the next repair pass immediately removes, so a - * one-column columnList stays a one-column columnList and unwraps. - * - Children that cannot be made to fit at all — a `sequence` given in the - * wrong order, say. Returning the unfilled node lets the caller's - * `node.check()` report what is actually wrong instead of a fill failure. - */ +// Skips `createAndFill` for unwrap-on-empty containers (fill would be undone +// by the next repair pass) and for unfittable content (let `node.check()` report it). function createExplicitChildrenNode( blockType: string, type: NodeType, diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 8aa1aa04b0..84c168e979 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -15,13 +15,6 @@ import { isContainerNode } from "../blockManipulation/containers/fixContainer.js import { getBlockSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; -/** - * The node holding a container's children, and the block type governing them: - * the node itself for a pure container, the generated `__children` node for a - * container that also has its own content. `undefined` for anything else — - * including a stray `__children` node, whose name isn't a block type and which - * is therefore always flattened. - */ function getContainerChildren( node: Node, ): { blockType: string; children: Node } | undefined { @@ -34,12 +27,6 @@ function getContainerChildren( return undefined; } -/** - * Whether a container node is "self-contained" — it has enough children to - * stand on its own and is allowed at the top level. Containers that aren't - * (e.g. a single selected column of a columnList) are flattened into their - * children. - */ function isSelfContainedContainer(node: Node): boolean { const container = getContainerChildren(node); if (!container) { @@ -57,10 +44,6 @@ function isSelfContainedContainer(node: Node): boolean { ); } -/** - * A container's own content as a standalone paragraph block, so flattening the - * container doesn't take the user's text with it. - */ function containerContentAsBlock< B extends BlockSchema, I extends InlineContentSchema, @@ -81,9 +64,6 @@ function containerContentAsBlock< ); } -/** - * Converts all Blocks within a fragment to BlockNote blocks. - */ export function fragmentToBlocks< B extends BlockSchema, I extends InlineContentSchema, @@ -107,9 +87,6 @@ export function fragmentToBlocks< fragment.descendants((node) => { if (node.type.name === "blockContainer") { if (node.firstChild?.type.name === "blockGroup") { - // Selection started within a block group — the fragment wraps the - // children in a blockContainer > blockGroup without a blockContent, - // so we descend into the blockGroup's children instead. return true; } } diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts index f54e540ba6..87f13f9c13 100644 --- a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts @@ -1,29 +1,11 @@ import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; -/** - * DOM hit-testing helpers the side menu uses to resolve which block a cursor - * is over inside container blocks. They read live layout geometry - * (`getBoundingClientRect`) rather than any declared layout flag, so container - * blocks don't have to describe their own layout — a container whose children - * happen to sit side-by-side (like a column list) is detected as such. - */ - -/** - * The selector matching any block that can be a direct child of a container: - * a regular block (`blockContainer`) or a nested container element. - */ function containerChildSelector(containerUIInfo: ContainerUIInfo): string { return containerUIInfo.containerSelector ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` : `[data-node-type="blockContainer"]`; } -/** - * The direct child block elements of a container element (in the block - * sense): the closest block element above each match must be the container - * itself. Matches `blockContainer`s (regular children) and nested container - * elements. - */ export function getDirectChildBlocks( container: Element, containerUIInfo: ContainerUIInfo, @@ -39,12 +21,6 @@ export function getDirectChildBlocks( return children; } -/** - * The part of a `DOMRect` these helpers actually read. Splitting the arithmetic - * out over this shape keeps it testable without a layout engine — see - * `sideMenuContainerGeometry.test.ts` — while the live-geometry claim itself is - * covered against real layout in `sideMenuContainerGeometry.browser.test.ts`. - */ export type BlockRect = { top: number; bottom: number; @@ -52,11 +28,6 @@ export type BlockRect = { right: number; }; -/** - * Whether a set of sibling rects is laid out side-by-side rather than stacked: - * two of them overlapping along the vertical axis can only be sitting next to - * each other horizontally. - */ export function rectsAreSideBySide(rects: BlockRect[]): boolean { for (let i = 0; i < rects.length; i++) { for (let j = i + 1; j < rects.length; j++) { @@ -68,13 +39,7 @@ export function rectsAreSideBySide(rects: BlockRect[]): boolean { return false; } -/** - * The index of the rect the cursor sits in, or `undefined` for none. A rect - * containing the cursor's x position wins over the first one merely containing - * its y position (which is how a side-by-side row is disambiguated); the - * y-only match is the fallback, so hovering a container's gutter still - * resolves to the child on that row. - */ +// X-match wins over y-only match (disambiguates side-by-side children). export function rectIndexAtCursor( rects: BlockRect[], mousePos: { x: number; y: number }, @@ -93,11 +58,6 @@ export function rectIndexAtCursor( return verticalMatch; } -/** - * Whether a container element lays its direct child blocks out side-by-side - * (like a column list), detected from geometry rather than a declared flag. - * The DOM adapter over {@link rectsAreSideBySide}. - */ export function isHorizontalContainer( container: Element, containerUIInfo: ContainerUIInfo, @@ -109,11 +69,6 @@ export function isHorizontalContainer( ); } -/** - * Whether `element` sits inside a horizontal container at any depth (e.g. a - * block inside a column of a columnList — the columnList, not the column, is - * the horizontal one). Walks up the container ancestor chain. - */ export function hasHorizontalContainerAncestor( element: Element, containerUIInfo: ContainerUIInfo, @@ -133,18 +88,6 @@ export function hasHorizontalContainerAncestor( return false; } -/** - * If `element` is a container block's element, finds its direct child block - * whose vertical range contains the cursor. Hovering a container's own - * chrome (padding, a title bar, the side-menu gutter) next to a child - * should attach the side menu to that child — mirroring how hovering a - * parent block's gutter next to a nested block attaches to the nested - * block. The container's own menu stays reachable on rows occupied only by - * its chrome. When children sit side-by-side, a child containing the - * cursor's x position wins over the first vertical match. - * - * The DOM adapter over {@link rectIndexAtCursor}. - */ export function getContainerChildAtCursor( element: Element, mousePos: { x: number; y: number }, diff --git a/packages/core/src/pm-nodes/README.md b/packages/core/src/pm-nodes/README.md index 6e9e1571c1..be57ead212 100644 --- a/packages/core/src/pm-nodes/README.md +++ b/packages/core/src/pm-nodes/README.md @@ -16,7 +16,7 @@ In the BlockNote API, recall that blocks look like this: } ``` -`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a container block (a block declaring `children`, such as a `column`, `columnList`, or a custom callout). +`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a `column` or `columnList`. `content` is the block's Inline Content. Inline content doesn't have any `id`, it's "loose" content within the node. @@ -61,92 +61,41 @@ group: "blockContent", Blocks that are part of the `blockContent` group define the appearance / behaviour of the main element of the block (i.e.: headings, paragraphs, list items, etc.). These are only used for "regular" blocks that are represented as `blockContainer` nodes. -## Container blocks +## Multi-column -A block config can declare `children`, marking the block as a _container -block_: a block that holds other blocks as its body. A container with -`content: "none"` holds them directly, and emits a single ProseMirror node -(built by `createSpec`'s `buildContainerNode`) with this shape: - -```typescript -name: blockConfig.type, -group: "bnBlock childContainer blockGroupChild", // blockGroupChild is dropped for `placement: "containerOnly"` -// From `children.allow`/`min`/`max`/`sequence`; defaults to `blockGroupChild+`. -// See `childrenContentExpression`: allowed regular blocks collapse to one -// `blockContainer` term (ordered first, so ProseMirror auto-fill picks it); -// allowed container types appear verbatim, one term per sequence slot. -content: "blockGroupChild{min,max}", -priority: 40, // below blockContainer (50), so `blockGroupChild` auto-fill never recurses into containers -``` - -Unlike regular blocks, a container block's PM node **is** the `bnBlock` — there -is no `blockContainer` wrapper and no `blockGroup` around the children; child -blocks sit directly inside the container node. The children are exposed as -`block.children` in the BlockNote API. - -### Container blocks with their own content - -A container can also declare `content: "inline"` / `"plain"`. It then needs two -holes rather than one, and ProseMirror nodes are either inline-content or -block-content, never both — so `buildContentContainerNode` emits two generated -companion nodes alongside the block's own: - -```typescript -name: "toggle", -group: "bnBlock blockGroupChild", -content: "toggle__content toggle__children", - -name: "toggle__content", // group: "containerContent", content: "inline*" | "text*" -name: "toggle__children", // group: "childContainer", content: -``` - -`toggle__children` is in the `childContainer` group, so `nodeToBlock` finds the -children exactly where it does for a regular block, and the resulting `Block` -JSON is byte-identical to a nested regular block's. `containerContent` is -deliberately _not_ `blockContent`: a `blockContent` node is a legal child of -`blockContainer`, so a paste could otherwise produce -`blockContainer > toggle__content`, whose block type would resolve to the -generated node name. - -Note that the block's own node is a `childContainer` in the first shape and not -in the second — `isContainerNode` is `false` for `toggle` and `true` for -`toggle__children`. `isContentContainerNode` answers the other question. - -The `xl-multi-column` package's blocks are the canonical (pure) containers: +The `multi-column` package makes it possible to order blocks side by side in +columns. It adds the `columnList` and `column` nodes to the schema. ### ColumnList ```typescript -// children: { allow: { blocks: false, containers: ["column"] }, min: 2, unwrapWhenEmptied: true } name: "columnList", -group: "bnBlock childContainer blockGroupChild", -content: "column{2,}", // min two columns +group: "childContainer bnBlock blockGroupChild", +// A block always contains content, and optionally a blockGroup which contains nested blocks +content: "column column+", // min two columns ``` -The column list contains 2 or more columns. Its `unwrapWhenEmptied` config -makes `fixContainer` drop emptied columns and unwrap the list when fewer than -two non-empty columns remain. +The column list contains 2 or more columns. ### Column ```typescript -// children: { exitOnEnter: false }, placement: "containerOnly" name: "column", -group: "bnBlock childContainer", // not blockGroupChild: only valid inside a columnList -content: "blockGroupChild+", +group: "bnBlock childContainer", +// A block always contains content, and optionally a blockGroup which contains nested blocks +content: "blockContainer+", ``` -The column contains 1 or more blocks. +The column contains 1 or more block containers. # Groups We use Prosemirror "groups" to help organize this schema. Here is a list of the different groups: - `blockContent`: described above (contain the content for blocks that are represented as `BlockContainer` nodes) -- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and top-level container blocks (e.g. `columnList`) -- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`; a pure container block node (`columnList`, `column`, a custom callout) is also a `childContainer`, and for a container with its own content it is the generated `__children` node. -- `containerContent`: the generated `__content` node of a container block that has its own content. Deliberately not `blockContent` — see above. -- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. `blockContainer` and every container block node are part of this group. +- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and `columnList` +- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`, but for columns, both `columnList` and `column` are considered to be `childContainer` nodes. +- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. Both `blockContainer`, `column` and `columnList` are part of this group. _Note that the last two groups, `bnBlock` and `childContainer`, are not used anywhere in the schema. They are however helpful while programming. For example, we can check whether a node is a `bnBlock`, and then we know it corresponds to a BlockNote Block. Or, we can check whether a node is a `childContainer`, and then we know it's a container of a BlockNote Block's `children`. See `getBlockInfoFromPos` for an example of how this is used._ @@ -184,14 +133,6 @@ _Note that the last two groups, `bnBlock` and `childContainer`, are not used any - - A container block's own content - - - Its body - - - ``` diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts index 5d965c8e29..18761ea485 100644 --- a/packages/core/src/schema/blocks/children.ts +++ b/packages/core/src/schema/blocks/children.ts @@ -13,21 +13,11 @@ export const CHILD_CONTAINER_GROUP = "childContainer"; export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; -/** - * The group holding the generated content node of a *content-bearing* - * container — a container that has its own inline content as well as children. - * - * Deliberately not `blockContent`: a `blockContent` node is a legal child of - * `blockContainer`, so a paste could produce `blockContainer > toggle__content` - * whose block type would resolve to the generated node name ("Block is of an - * unrecognized type"). A group of its own makes that unrepresentable. - */ +// Not `blockContent` — that's a legal child of `blockContainer`, so a paste +// could produce an unrepresentable `blockContainer > toggle__content`. export const CONTAINER_CONTENT_GROUP = "containerContent"; -// The two generated companion nodes of a content-bearing container. The -// separator is `__` rather than something more obviously generated like `$` -// because these names appear in the container's own content expression, and -// ProseMirror's expression parser only accepts word characters in a node name. +// `__` because PM's expression parser only accepts word characters in node names. const CONTENT_NODE_SUFFIX = "__content"; const CHILDREN_NODE_SUFFIX = "__children"; @@ -39,7 +29,6 @@ export function containerChildrenNodeName(blockType: string): string { return `${blockType}${CHILDREN_NODE_SUFFIX}`; } -/** Inverse of {@link containerContentNodeName}. */ export function blockTypeOfContainerContentNode( nodeName: string, ): string | undefined { @@ -48,7 +37,6 @@ export function blockTypeOfContainerContentNode( : undefined; } -/** Inverse of {@link containerChildrenNodeName}. */ export function blockTypeOfContainerChildrenNode( nodeName: string, ): string | undefined { @@ -57,26 +45,12 @@ export function blockTypeOfContainerChildrenNode( : undefined; } -/** - * Whether `node` is the node of a container block that has its own content — - * one whose children live in a generated `__children` node rather than - * directly inside it. - * - * Derived from the node's *shape*, the same way `getBlockInfoWithManualOffset` - * discriminates: a `containerContent` node only ever appears as the first - * child of the block that generated it. Note this is not the same question as - * `isContainerNode`, which is `false` for this node and `true` for its - * generated `__children` node. - */ +// Whether `node` is a container that has its own content (children live in +// a generated `__children` node). Not the same as `isContainerNode`. export function isContentContainerNode(node: Node): boolean { return !!node.firstChild?.type.isInGroup(CONTAINER_CONTENT_GROUP); } -/** - * The generated content & children node types of a container block that has - * its own content, or `undefined` if the block type is anything else (a - * regular block, or a pure container whose node holds its children directly). - */ export function getContentContainerNodeTypes( schema: Schema, blockType: string, @@ -89,39 +63,15 @@ export function getContentContainerNodeTypes( : undefined; } -// Below `blockContainer`'s priority (50): PM's `fillBefore` picks the first -// matching type in a group, and `blockContainer` must win so auto-fill doesn't -// recurse through nested containers. +// Below `blockContainer`'s priority (50) so PM's `fillBefore` picks +// `blockContainer` first, avoiding recursion through nested containers. export const CONTAINER_NODE_PRIORITY = 40; -/** - * The band container nodes register in, strictly below `blockContainer`'s 50. - * `runsBefore` can move a container within the band — never out of it. - */ const CONTAINER_PRIORITY_BAND = { min: 30, max: 49 }; - -/** - * The priority a spec with no `runsBefore` (and which nothing else declares a - * `runsBefore` on) gets from `sortByDependencies`. Priorities are spaced 10 - * apart around it. - */ const DEFAULT_SPEC_PRIORITY = 101; -/** - * Maps the priority `sortByDependencies` computed from a container's - * `runsBefore` into the container band. - * - * Containers can't simply use the computed value: every container node has to - * register below `blockContainer` (see {@link CONTAINER_NODE_PRIORITY}), while - * the computed priorities sit around 101. So the relative order is preserved - * and the magnitude is not — a container with no `runsBefore` lands exactly on - * `CONTAINER_NODE_PRIORITY`, and each level of `runsBefore` moves it one step. - * - * The consequence, which `validateContainerRunsBefore` turns into an error - * rather than a surprise: a container can be ordered before another *container* - * but never before a regular block, because the whole band is below every - * regular block's priority. - */ +// Maps `sortByDependencies` priority into the container band (30–49). +// Preserves relative order but keeps all containers below regular blocks. export function containerNodePriority(priority: number | undefined): number { if (priority === undefined) { return CONTAINER_NODE_PRIORITY; @@ -147,31 +97,14 @@ export function isContainerType(config: { return config.children !== undefined; } -/** - * Whether a block can be inserted anywhere a regular block goes — the document - * root, or nested under any other block. `placement: "containerOnly"` blocks - * (e.g. a `column`, which only ever lives inside a `columnList`) are left out - * of the `blockGroupChild` group, so the schema rejects them elsewhere. - */ export function isPlaceableAnywhere(config: { placement?: BlockConfig["placement"]; }): boolean { return config.placement !== "containerOnly"; } -// Resolving is pure, and downstream code asks the same questions of the same -// config object over and over (per node build, per repair pass, per keystroke -// in some paths). Cache by config identity — which also keeps us from ever -// mutating the user's object, so `blockSchema[type]` identity checks stay -// valid across schema instances. const resolvedCache = new WeakMap(); -/** - * Normalizes a `children` config into the single shape every consumer reads: - * an ordered list of slots. The uniform form (`allow`/`min`/`max`) is exactly - * sugar for a one-slot sequence, so nothing downstream has to branch on which - * form the author wrote. - */ export function resolveChildren(children: ChildrenConfig): ResolvedChildren { const cached = resolvedCache.get(children); if (cached) { @@ -210,8 +143,6 @@ export function resolveChildren(children: ChildrenConfig): ResolvedChildren { return resolved; } -// A slot names a *position*, so it holds exactly one child unless it says -// otherwise — hence `count` rather than `min`/`max`. function resolveSlot(slot: ChildSlot): ResolvedSlot { return { ...resolveAllow(slot.allow), ...resolveCount(slot.count) }; } @@ -235,8 +166,6 @@ function resolveAllow( const containers = allow?.containers ?? true; return { blocks: allow?.blocks ?? true, - // `false` and `[]` mean the same thing downstream, so collapse them here - // rather than making every consumer handle both. containers: containers === false ? [] : containers, }; } @@ -245,24 +174,11 @@ export function getMinChildCount(children: ChildrenConfig): number { return resolveChildren(children).minCount; } -/** - * Context a single block spec can't know on its own: which of the schema's - * block types are containers, and what they're called. - */ export type ContainerTypeContext = { isContainerBlockType: (blockType: string) => boolean; containerBlockTypes: () => readonly string[]; }; -/** - * Builds the ProseMirror content expression for a container block. - * - * Container children are PM *nodes*: a container-type block is its own node - * type, while every regular block lives inside a shared `blockContainer` node. - * That asymmetry is why `allow` has two fields instead of one list — the - * schema can name container types exactly, but cannot tell one regular block - * type from another. - */ export function childrenContentExpression( children: ChildrenConfig, ctx: ContainerTypeContext, diff --git a/packages/core/src/schema/blocks/containerAttributes.ts b/packages/core/src/schema/blocks/containerAttributes.ts index 47e5fe6842..e0657b6e88 100644 --- a/packages/core/src/schema/blocks/containerAttributes.ts +++ b/packages/core/src/schema/blocks/containerAttributes.ts @@ -1,17 +1,6 @@ import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; -/** - * The attributes BlockNote relies on being present on a container block's root - * element: the `data-node-type` marker the generated parse rules read, each - * non-default prop as a kebab-cased `data-*` attribute, and (when known) the - * block's `data-id`. - * - * The single source of truth for every path that puts these on an element — - * the node view (`applyContainerAttributes`) and HTML serialization - * (`fillContainerAttributes`) — so the live and serialized DOM carry the same - * attributes on the same element. - */ export function getContainerAttributes( blockType: string, blockProps: Partial>, @@ -34,13 +23,6 @@ export function getContainerAttributes( return attributes; } -/** - * Applies the attributes BlockNote relies on to a container block's root - * element. Called from the container node view (both initial render and - * `update`) so block renders don't have to stamp these themselves. The node - * view owns the element, so this overwrites existing attributes and clears the - * ones for props that are back at their default. - */ export function applyContainerAttributes( dom: HTMLElement | DocumentFragment | undefined | null, blockType: string, @@ -71,13 +53,7 @@ export function applyContainerAttributes( } } -/** - * Fills in the `data-node-type` marker and non-default prop `data-*` - * attributes a container block's serialized root needs to parse back, without - * clobbering any the block's own render already set (author-set attributes - * win). Used by internal/external HTML serialization, where there is no node - * view to own the element. - */ +// Like `applyContainerAttributes` but won't overwrite existing attributes. export function fillContainerAttributes( dom: HTMLElement, blockType: string, diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 02e697b6f8..ad4b48607a 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -44,10 +44,6 @@ import { LooseBlockSpec, } from "./types.js"; -/** - * Full-schema context that node construction needs but a single block spec - * can't know on its own. - */ export type BlockSchemaContext = ContainerTypeContext; const NO_SCHEMA_CONTEXT: BlockSchemaContext = { @@ -82,21 +78,9 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) { }; } -/** - * Places the fragment a container block's `parseContent` returned into the - * shape the container's content expression can hold. - * - * A container's content is blocks, not inline nodes, so a `parseContent` author - * who returns inline nodes (the natural thing to return from a DOM element) - * would otherwise have them dropped. Runs of inline nodes become paragraphs, - * block-level nodes pass through untouched, and ProseMirror's `findWrapping` - * then inserts the `blockContainer` (and, for a content-bearing container, the - * generated `__children`) wrappers around them. - * - * The one exception is a *leading* run of inline nodes in a content-bearing - * container: that is the block's own content, and it's left inline so it lands - * in the generated `__content` node. - */ +// Wraps inline runs from `parseContent` into paragraphs so they fit a +// container's block content expression. A leading inline run in a +// content-bearing container stays inline (it's the block's own content). function toContainerChildren( fragment: Fragment, schema: PMSchema, @@ -110,8 +94,6 @@ function toContainerChildren( if (inlineRun.length === 0) { return; } - // A leading inline run in a content-bearing container is its own content, - // so it stays inline; anything else has to become a block. out.push( ...(hasOwnContent && !seenBlock ? inlineRun @@ -134,23 +116,7 @@ function toContainerChildren( return Fragment.fromArray(out); } -/** - * Function that uses the 'parse' function of a blockConfig to create a - * TipTap node's `parseHTML` property. This is only used for parsing content - * from the clipboard. - * - * `kind` picks the default rule and how `parseContent` is honored. It is a - * parameter rather than a second function because everything else — the - * `implementation.parse` rule, its `getAttrs`, whitespace handling — is - * identical for both, and forking it is how `implementation.parse` came to be - * silently ignored for containers in the first place. - * - * **What `children.allow` does not do:** it does not filter what a user can - * paste into a container. HTML paste parses with `blockGroup` as the top node - * (see `parseHTML.ts`), so ProseMirror's fitting algorithm places content the - * container's expression rejects *after* the container rather than dropping - * it. `allow` constrains the document model, not the parser. - */ +// Creates `parseHTML` rules for clipboard parsing. export function getParseRules< TName extends string, TProps extends PropSchema, @@ -162,10 +128,6 @@ export function getParseRules< ) { const isContainer = kind === "container"; - // A container owns its outer element rather than living inside a - // `blockContent` div, so it's matched by the marker its own render (and the - // serializer) puts on that element — and it has no `contentElement`: its - // children are blocks, parsed by the normal block rules. const rules: TagParseRule[] = [ isContainer ? { tag: `[data-node-type=${config.type}]` } @@ -193,11 +155,6 @@ export function getParseRules< }, // Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed preserveWhitespace: true, - // `getContent` is omitted entirely for a container without - // `parseContent`: that leaves ProseMirror to parse the element's - // children with the normal block rules, which is exactly what a - // container wants. Supplying it (even to return the same thing) would - // mean re-implementing block parsing. getContent: isContainer ? implementation.parseContent ? (node, schema) => @@ -206,9 +163,6 @@ export function getParseRules< el: node as HTMLElement, schema, }) ?? - // `parseContent` returns undefined to fall through to the - // default parsing, which for a container is "parse my - // children as blocks". DOMParser.fromSchema(schema).parse(node as HTMLElement, { topNode: schema.nodes["blockGroup"].create(), preserveWhitespace: true, @@ -355,12 +309,6 @@ function buildContainerNode( }); } -/** - * The block author's own root element: `dom` for a vanilla render, and - * `rootDOM` when a framework put wrappers of its own above it. `null` means - * the framework hasn't rendered it yet — there is nothing to stamp, and - * falling back to `dom` would stamp a wrapper instead. - */ function containerRootDOM(output: { dom: HTMLElement | DocumentFragment; rootDOM?: HTMLElement | null; @@ -368,11 +316,6 @@ function containerRootDOM(output: { return output.rootDOM === undefined ? output.dom : output.rootDOM; } -/** - * The node view shared by every container block, with or without its own - * content: the block's `render` output, with the attributes BlockNote relies - * on stamped onto it. - */ function containerNodeView< TName extends string, TProps extends PropSchema, @@ -387,8 +330,6 @@ function containerNodeView< blockContentDOMAttributes: Record; }, ): NodeView { - // A container's node *is* the block — convert directly rather than - // walking up to a parent (which would find the enclosing block). const block = nodeToBlock(props.node, props.view.state.doc); const nodeView = blockImplementation.render.call( @@ -402,8 +343,6 @@ function containerNodeView< context.editor as any, ); - // Read lazily: a framework render may not have produced its root element - // yet, and its identity can change across re-renders. const rootDOM = () => containerRootDOM(nodeView); applyContainerAttributes( @@ -422,9 +361,6 @@ function containerNodeView< ignoreNonContentMutations(typedNodeView); - // Unlike regular blocks, containers honor an `update` hook: - // recreating the node view would remount every child block, so a - // container patches its own DOM in place instead. const update = typedNodeView.update?.bind(typedNodeView); if (update) { typedNodeView.update = (node, decorations, innerDecorations) => { @@ -448,25 +384,6 @@ function containerNodeView< return typedNodeView; } -/** - * Builds the nodes for a container block that also has its own content: - * - * ``` - * toggle group: "bnBlock blockGroupChild" - * content: "toggle__content toggle__children" - * ├── toggle__content group: "containerContent" content: "inline*" - * └── toggle__children group: "childContainer" content: - * ``` - * - * A ProseMirror node holds either inline content or block content, never both, - * so the two regions are necessarily two sibling nodes. Keeping the children in - * a `childContainer` node — with the same content expression a pure container - * would compile to — is what makes the resulting `Block` JSON identical to a - * nested regular block's, and `allow`/`min`/`max`/`sequence` enforce the same - * way for both kinds of container. - * - * All props stay on the outer node; the content node declares none. - */ function buildContentContainerNode< TName extends string, TProps extends PropSchema, @@ -531,9 +448,6 @@ function buildContentContainerNode< name: contentName, group: CONTAINER_CONTENT_GROUP, content: blockConfig.content === "plain" ? "text*" : "inline*", - // Same reasoning as for a regular block's content node: "plain" content - // holds unstyled text, so it disallows formatting marks but keeps the - // non-formatting ones (comments and suggestions/diffs). marks() { return blockConfig.content === "plain" ? nonFormattingMarks(this.editor) diff --git a/tests/src/end-to-end/containerblocks/CardApp.tsx b/tests/src/end-to-end/containerblocks/CardApp.tsx deleted file mode 100644 index 2ec047df1e..0000000000 --- a/tests/src/end-to-end/containerblocks/CardApp.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; -import "@blocknote/core/fonts/inter.css"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; -import { createReactBlockSpec, useCreateBlockNote } from "@blocknote/react"; - -import "./card.css"; - -// Fixture for the container-block browser tests: a "card" made of an ordered -// sequence of two child containers. The header is a *content-bearing* -// container (`content: "inline"` plus `children`), which is the shape the -// rendering tests exercise. - -// The card's header: its own rich-text title, plus optional extra blocks -// beneath it — one editable region holding the title first and the children -// after it, both placed with the single `contentRef`. -const createCardHeader = createReactBlockSpec( - { - type: "cardHeader", - propSchema: {}, - content: "inline", - children: { min: 0 }, - placement: "containerOnly", - }, - { - render: (props) =>
, - }, -); - -// The card's body: any blocks, at least one. -const createCardBody = createReactBlockSpec( - { - type: "cardBody", - propSchema: {}, - content: "none", - children: { min: 1 }, - placement: "containerOnly", - }, - { - render: (props) =>
, - }, -); - -// The card itself: `sequence` compiles to the ProseMirror content expression -// `cardHeader cardBody` — a header, then a body, exactly one of each. -const createCard = createReactBlockSpec( - { - type: "card", - propSchema: {}, - content: "none", - children: { - sequence: [ - { allow: { blocks: false, containers: ["cardHeader"] } }, - { allow: { blocks: false, containers: ["cardBody"] } }, - ], - }, - }, - { - render: (props) => ( -
- {/* The card has no content of its own, so its editable region is - just its children — the header and the body, in that order. */} -
-
- ), - }, -); - -const schema = BlockNoteSchema.create().extend({ - blockSpecs: { - ...defaultBlockSpecs, - card: createCard(), - cardHeader: createCardHeader(), - cardBody: createCardBody(), - }, -}); - -export default function App() { - const editor = useCreateBlockNote({ - schema, - initialContent: [ - { - type: "card", - children: [ - { - type: "cardHeader", - content: "Ordered children", - }, - { - type: "cardBody", - children: [ - { - type: "paragraph", - content: "The body holds any blocks.", - }, - ], - }, - ], - }, - { - type: "paragraph", - }, - ], - }); - - return ; -} diff --git a/tests/src/end-to-end/containerblocks/card.css b/tests/src/end-to-end/containerblocks/card.css deleted file mode 100644 index 720937bd4c..0000000000 --- a/tests/src/end-to-end/containerblocks/card.css +++ /dev/null @@ -1,33 +0,0 @@ -.card { - border: 1px solid #7c3aed; - border-radius: 6px; - display: flex; - flex-grow: 1; - gap: 8px; - overflow: hidden; - padding: 8px 12px; -} - -.card-slots { - flex-grow: 1; - min-width: 0; -} - -/* The header's two regions. A content-bearing container's editable region - holds its own content first and its children after it — both carry stable - attributes derived from the block type, so they can be styled separately - without any extra config. */ -.card-header [data-content-type="cardHeader"] { - border-bottom: 1px solid #7c3aed; - font-weight: 600; - padding-bottom: 4px; -} - -.card-header [data-children-of="cardHeader"] { - font-size: 0.875rem; - opacity: 0.8; -} - -.card-body { - padding-top: 4px; -} diff --git a/tests/src/end-to-end/containerblocks/containerblocks.test.tsx b/tests/src/end-to-end/containerblocks/containerblocks.test.tsx deleted file mode 100644 index 343d0f984b..0000000000 --- a/tests/src/end-to-end/containerblocks/containerblocks.test.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import CalloutApp from "@examples/06-custom-schema/09-container-block/src/App"; -import CardApp from "./CardApp.jsx"; -import { describe, expect, test } from "vite-plus/test"; -import { render } from "vitest-browser-react"; -import { userEvent } from "../../utils/context.js"; -import { DRAG_HANDLE_SELECTOR, EDITOR_SELECTOR } from "../../utils/const.js"; -import { sleep, waitForSelector } from "../../utils/editor.js"; -import { getRect, moveMouseOverElement } from "../../utils/mouse.js"; - -// A content-bearing container renders its own content and its children as two -// sibling regions inside a single ProseMirror `contentDOM`, which the framework -// hosts under `display: contents` so the author's layout is unaffected. None of -// that is observable without a layout engine, so these are the checks that -// can't live in the jsdom unit suites. - -const CARD_HEADER = `[data-node-type="cardHeader"]`; -const HEADER_CONTENT = `[data-content-type="cardHeader"]`; -const HEADER_CHILDREN = `[data-children-of="cardHeader"]`; - -/** Where the caret currently sits, in terms of the two container regions. */ -function caretRegion() { - const node = document.getSelection()?.anchorNode; - const element = node?.nodeType === Node.TEXT_NODE ? node.parentElement : node; - if (!(element instanceof Element)) { - return "none"; - } - - const children = element.closest("[data-children-of]"); - if (children) { - return `children-of:${children.getAttribute("data-children-of")}`; - } - - const content = element.closest("[data-content-type]"); - return content - ? `content:${content.getAttribute("data-content-type")}` - : "other"; -} - -describe("Container block rendering", () => { - test("Places content and children as adjacent siblings under a display:contents host", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - const header = await waitForSelector(CARD_HEADER); - const content = header.querySelector(HEADER_CONTENT)!; - const children = header.querySelector(HEADER_CHILDREN)!; - - expect(content).toBeTruthy(); - expect(children).toBeTruthy(); - - // Both regions must be direct children of one content host: ProseMirror - // rebuilds the whole node view if a child desc's DOM leaves its parent's - // contentDOM, so this adjacency is a correctness requirement, not a style. - expect(content.parentElement).toBe(children.parentElement); - expect( - content.compareDocumentPosition(children) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); - - // The host contributes no box, so the author's own layout applies to the - // regions as if they were written where the author placed `contentRef`. - expect(getComputedStyle(content.parentElement!).display).toBe("contents"); - }); - - test("Gives the wrapper React needs around a container no box of its own", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - const callout = await waitForSelector(".callout"); - const renderer = callout.closest( - ".bn-react-node-view-renderer", - )!; - - // Container blocks own their outer DOM, so React's node-view renderer must - // not be a box. What removes it is a stylesheet rule - // (`.bn-react-node-view-renderer.bn-container-node-view` in - // `packages/react/src/editor/styles.css`) — nothing but a real cascade can - // see whether it applied. Without it the renderer falls back to the - // `display: flex; width: 100%` box every *non*-container node view relies - // on, wrapping every container in a box its author never wrote. - expect(renderer.classList.contains("bn-container-node-view")).toBe(true); - - // Asserted as geometry rather than as a declaration: an element with - // `display: contents` generates no principal box, so it has no client - // rects at all. The author's own root, one level down, is where the - // block's box actually lives. - expect(renderer.getClientRects().length).toBe(0); - expect(callout.getClientRects().length).toBe(1); - expect(getRect(callout).height).toBeGreaterThan(0); - - // Supplement, so a failure says *why* the box came back. - expect(getComputedStyle(renderer).display).toBe("contents"); - }); - - test("Gives a container's own content a real box despite the display:contents host", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - await waitForSelector(CARD_HEADER); - - const content = getRect(HEADER_CONTENT); - expect(content.width).toBeGreaterThan(0); - expect(content.height).toBeGreaterThan(0); - - // The body sits below the header rather than collapsing on top of it. - const body = getRect(`[data-node-type="cardBody"]`); - expect(body.top).toBeGreaterThanOrEqual(content.bottom); - }); -}); - -describe("Container block mouse interaction", () => { - test("Shows the drag handle for a block nested inside a container", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - const nested = await waitForSelector( - `[data-node-type="cardBody"] .bn-block-content`, - ); - const nestedRect = getRect(nested); - - await moveMouseOverElement(nested); - await sleep(150); - - // Hit-testing walks up from the hovered element; `display: contents` on an - // intermediate host must not break that walk or misplace the handle. - const handle = await waitForSelector(DRAG_HANDLE_SELECTOR); - const handleRect = getRect(handle); - - expect(handleRect.width).toBeGreaterThan(0); - expect(handleRect.x).toBeLessThan(nestedRect.x); - expect(Math.abs(handleRect.y - nestedRect.y)).toBeLessThan(60); - }); - - test("Puts the caret in the container's own content when its title is clicked", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - await userEvent.click(await waitForSelector(HEADER_CONTENT)); - await userEvent.keyboard("{End}"); - await userEvent.keyboard("EDITED"); - await sleep(200); - - expect(document.querySelector(HEADER_CONTENT)!.textContent).toContain( - "EDITED", - ); - expect(document.querySelector(HEADER_CHILDREN)!.textContent).not.toContain( - "EDITED", - ); - }); -}); - -describe("Container block keyboard interaction", () => { - test("Creates a first child on Enter at the end of a container's content", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - const before = document.querySelector(HEADER_CHILDREN)!.children.length; - - await userEvent.click(await waitForSelector(HEADER_CONTENT)); - await userEvent.keyboard("{End}"); - await userEvent.keyboard("{Enter}"); - await sleep(300); - - expect(document.querySelector(HEADER_CHILDREN)!.children.length).toBe( - before + 1, - ); - expect(caretRegion()).toBe("children-of:cardHeader"); - - // The caret really is in the new child, so typing does not touch the title. - await userEvent.keyboard("child text"); - await sleep(200); - expect(document.querySelector(HEADER_CHILDREN)!.textContent).toContain( - "child text", - ); - expect(document.querySelector(HEADER_CONTENT)!.textContent).not.toContain( - "child text", - ); - }); - - test("Moves the caret across the content/children boundary with the arrow keys", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - // Give the header a child to cross into. - await userEvent.click(await waitForSelector(HEADER_CONTENT)); - await userEvent.keyboard("{End}"); - await userEvent.keyboard("{Enter}"); - await sleep(300); - expect(caretRegion()).toBe("children-of:cardHeader"); - - // No keyboard handler implements this — it relies on ProseMirror's built-in - // vertical motion treating both regions as ordinary textblocks, which only - // holds if they lay out as real, separate lines. - await userEvent.keyboard("{ArrowUp}"); - await sleep(200); - expect(caretRegion()).toBe("content:cardHeader"); - - await userEvent.keyboard("{ArrowDown}"); - await sleep(200); - expect(caretRegion()).toBe("children-of:cardHeader"); - }); -}); - -describe("Container block attribute contract", () => { - test("Stamps node type and id onto the author's own root element", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - const callout = await waitForSelector(".callout"); - - // Not on a framework wrapper: the element the author returned from `render` - // is the one carrying the attributes, in the live editor as in serialized - // output. This is what makes `.callout[data-flavor=…]` selectors work. - expect(callout.getAttribute("data-node-type")).toBe("callout"); - expect(callout.getAttribute("data-id")).toBeTruthy(); - expect(callout.classList.contains("callout")).toBe(true); - }); - - test("Reflects a non-default prop as a data attribute the author's CSS can select", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - - const before = getComputedStyle( - await waitForSelector(".callout"), - ).backgroundColor; - - // Cycle the flavor off its default so the prop is actually written out — - // BlockNote omits `data-*` for props still at their default value. - await userEvent.click(await waitForSelector(".callout-icon-button")); - await sleep(300); - - const callout = await waitForSelector(".callout"); - const flavor = callout.getAttribute("data-flavor"); - - expect(flavor).toBeTruthy(); - expect(flavor).not.toBe("tip"); - expect(getComputedStyle(callout).backgroundColor).not.toBe(before); - }); -});