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/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..5ebc4a619e 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,41 @@ export function updateBlockTr< } } +function carryOverContent( + existingContent: Block["content"], + newBlockType: string, + pmSchema: Schema, +): { + content?: PartialBlock["content"]; + children: PartialBlock[]; +} { + const nothing = { children: [] }; + + 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 +609,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 +725,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..a7cd2df4b7 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -0,0 +1,108 @@ +import type { Node, NodeType } from "prosemirror-model"; + +import { isContentContainerNode } from "../../../schema/blocks/children.js"; +import { isContainerNode } from "./fixContainer.js"; + +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; +} + +export function descendToFirstInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + // A content container's children start after the content 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; +} + +export function getFirstLeafBlock( + container: Node, + containerBeforePos: number, +): { node: Node; beforePos: number } | null { + 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 }; +} + +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; + } +} + +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); + 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..e6ba95f584 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -0,0 +1,71 @@ +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +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: renderDiv }, +)(); + +const LockedBox = createBlockSpec( + { + type: "lockedBox" as const, + propSchema: {}, + content: "none", + children: { min: 1, exitOnEnter: false }, + }, + { render: renderDiv }, +)(); + +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + children: { + allow: { blocks: false, containers: ["gridCell"] }, + min: 2, + unwrapWhenEmptied: true, + }, + }, + { render: renderDiv }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + children: {}, + placement: "containerOnly", + }, + { render: renderDiv }, +)(); + +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..a756da8573 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,244 @@ +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"; +} + +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") + ); +} + +type ContainerRepairTarget = { + blockType: string; + blockPos: number; + blockNode: Node; + childrenPos: number; + 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 `__children` node: 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, + }; +} + +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; + } + + // Don't silently destroy non-empty content. + 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)); +} + +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); + }); +} + +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, + ); + 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..d32b040e45 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,147 @@ 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; +} + +// `createAndFill` produces nodes with `id: null`; patch them before use. +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, + ); +} + +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, + ), + ); +} + +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}".`, + ); +} + +function createContainerChildrenNode( + blockType: string, + type: NodeType, + schema: Schema, + styleSchema: StyleSchema, + seedingTypes: ReadonlySet, + attrs: Attrs | null = null, +): Node { + const seeded = seedDefaultChildren( + blockType, + schema, + styleSchema, + seedingTypes, + ); + + 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; +} + +// 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, + 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 +499,7 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + seedingTypes: ReadonlySet = EMPTY_SEEDING, ) { let id = block.id; @@ -352,7 +511,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 +519,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 +542,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..84c168e979 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,60 +1,98 @@ -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"; -/** - * Converts all Blocks within a fragment to BlockNote blocks. - */ +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; +} + +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) + ); +} + +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, + ); +} + export function fragmentToBlocks< B extends BlockSchema, 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 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..87f13f9c13 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts @@ -0,0 +1,107 @@ +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; + +function containerChildSelector(containerUIInfo: ContainerUIInfo): string { + return containerUIInfo.containerSelector + ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` + : `[data-node-type="blockContainer"]`; +} + +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; +} + +export type BlockRect = { + top: number; + bottom: number; + left: number; + right: number; +}; + +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; +} + +// X-match wins over y-only match (disambiguates side-by-side children). +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; +} + +export function isHorizontalContainer( + container: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + return rectsAreSideBySide( + getDirectChildBlocks(container, containerUIInfo).map((child) => + child.getBoundingClientRect(), + ), + ); +} + +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; +} + +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/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..18761ea485 --- /dev/null +++ b/packages/core/src/schema/blocks/children.ts @@ -0,0 +1,239 @@ +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"; + +// 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"; + +// `__` because PM's expression parser only accepts word characters in node names. +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}`; +} + +export function blockTypeOfContainerContentNode( + nodeName: string, +): string | undefined { + return nodeName.endsWith(CONTENT_NODE_SUFFIX) + ? nodeName.slice(0, -CONTENT_NODE_SUFFIX.length) + : undefined; +} + +export function blockTypeOfContainerChildrenNode( + nodeName: string, +): string | undefined { + return nodeName.endsWith(CHILDREN_NODE_SUFFIX) + ? nodeName.slice(0, -CHILDREN_NODE_SUFFIX.length) + : undefined; +} + +// 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); +} + +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) so PM's `fillBefore` picks +// `blockContainer` first, avoiding recursion through nested containers. +export const CONTAINER_NODE_PRIORITY = 40; + +const CONTAINER_PRIORITY_BAND = { min: 30, max: 49 }; +const DEFAULT_SPEC_PRIORITY = 101; + +// 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; + } + + 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; +} + +export function isPlaceableAnywhere(config: { + placement?: BlockConfig["placement"]; +}): boolean { + return config.placement !== "containerOnly"; +} + +const resolvedCache = new WeakMap(); + +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; +} + +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, + containers: containers === false ? [] : containers, + }; +} + +export function getMinChildCount(children: ChildrenConfig): number { + return resolveChildren(children).minCount; +} + +export type ContainerTypeContext = { + isContainerBlockType: (blockType: string) => boolean; + containerBlockTypes: () => readonly string[]; +}; + +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..e0657b6e88 --- /dev/null +++ b/packages/core/src/schema/blocks/containerAttributes.ts @@ -0,0 +1,75 @@ +import { camelToDataKebab } from "../../util/string.js"; +import { PropSchema, Props } from "../propTypes.js"; + +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; +} + +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); + } +} + +// Like `applyContainerAttributes` but won't overwrite existing attributes. +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..ad4b48607a 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,23 @@ import { LooseBlockSpec, } from "./types.js"; +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 +78,45 @@ 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. +// 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, + hasOwnContent: boolean, +): Fragment { + const out: PMNode[] = []; + let inlineRun: PMNode[] = []; + let seenBlock = false; + + const flush = () => { + if (inlineRun.length === 0) { + return; + } + 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); +} + +// Creates `parseHTML` rules for clipboard parsing. export function getParseRules< TName extends string, TProps extends PropSchema, @@ -55,12 +124,17 @@ export function getParseRules< >( config: BlockConfig, implementation: BlockImplementation, + kind: "regular" | "container" = "regular", ) { + const isContainer = kind === "container"; + 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 +155,25 @@ 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: isContainer + ? implementation.parseContent + ? (node, schema) => + toContainerChildren( + implementation.parseContent!({ + el: node as HTMLElement, + schema, + }) ?? + 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 +256,425 @@ 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 || {}, + }); + }, + }); +} + +function containerRootDOM(output: { + dom: HTMLElement | DocumentFragment; + rootDOM?: HTMLElement | null; +}): HTMLElement | DocumentFragment | null | undefined { + return output.rootDOM === undefined ? output.dom : output.rootDOM; +} + +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 { + 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, + ); + + 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); + + 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; +} + +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*", + 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 +687,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 +702,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 +721,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 +737,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 +860,8 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + const isContainer = getChildrenConfig(blockConfig) !== undefined; + return { config: blockConfig, implementation: { @@ -470,6 +880,11 @@ export function createBlockSpec< return undefined; } + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -489,6 +904,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/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"/); + }); });