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..0d0f1364c4 --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,202 @@ +--- +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, or a multi-column layout. + +## Declaring a Container Block + +Add the `children` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The only required field is `allow`, so the smallest container is: + +```typescript +import { createReactBlockSpec } from "@blocknote/react"; + +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: {}, + content: "none", + // Makes this a container: its body is other blocks. + children: { allow: "any" }, + }, + { + // Child blocks mount into the element you attach `contentRef` to. + render: (props) =>
, + }, +); +``` + +`children: { allow: "any" }` accepts any block, requires at least one, and never throws. When a container is created without children, BlockNote fills it with whatever its schema requires. + +A container block always declares `content: "none"`: its body is its children. Combining `children` with any other `content` is a schema-creation error. For an editable title or caption, use a string prop rendered as an ``, as the demo below does. + +At runtime the contained blocks live on `block.children`, the same field used for indented (nested) blocks. In fact, every regular block behaves as if it were declared with `children: { allow: "any", min: 0 }`; declaring `children` yourself is how you take control of the counts, the allowed types, and the rendering of that same field: + +```json +{ + "id": "callout-1", + "type": "callout", + "props": {}, + "children": [ + { + "id": "para-1", + "type": "paragraph", + "content": [{ "type": "text", "text": "Hello", "styles": {} }], + "children": [] + } + ] +} +``` + +### Where children render + +There is only one placement mechanism, and it is the one you already use for inline content. `contentRef` (React) / `contentDOM` (vanilla) marks the block's editable region. What goes in that region depends on the block: + +| block | `contentRef` element holds | +| --- | --- | +| `content: "inline"`, no `children` | its inline content | +| `content: "none"` + `children` | its child blocks | + +A `content: "none"` block *without* `children` is the only kind with nothing to place, and it's the only kind that isn't offered a `contentRef` at all. + +Container blocks own their entire outer DOM. BlockNote doesn't wrap them in the usual block element: whatever element your `render` returns *is* the block's element, and BlockNote stamps the attributes it relies on for parsing and UI positioning onto it (`data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute). You write a plain `
` and `data-flavor="info"` lands on it, in the live editor and in serialized HTML alike. + + + _The framework wrappers React puts above your element carry `display: + contents`, so they contribute no box and your element lays out exactly as if + it were the block's root. Selection is mirrored onto it as a `data-selected` + attribute, so `[data-selected]` is what you style for the selected state._ + + +The demo below puts this together: a callout block that can contain any other blocks. Its title is a regular `` backed by a string prop rather than document content — the pattern to reach for whenever a container needs an editable heading, caption, or label of its own: + + + +## `children` options + +| Option | Default | Description | +| --- | --- | --- | +| `allow` | (required) | What may appear as a child: `"any"`, `"blocks"`, `"containers"`, or an array of container block types. See [Restricting children](#restricting-children). | +| `min` / `max` | `1` / unbounded | How many children are allowed. Compiled into the editor schema. | +| `default` | none | Partial blocks to create the container with when it's inserted without an explicit `children` array, and the source of `"refill"` top-ups. Validated against the rest of the config when the schema is created. See [Defaults and refilling](#defaults-and-refilling). | +| `whenEmptied` | `"refill"` | What happens when fewer non-empty children remain than `min`: `"refill"` tops the container back up from `default`; `"unwrap"` replaces the container with its surviving children, or removes it entirely when none are left. Column lists use `"unwrap"` so emptied columns disappear and a one-column list dissolves. | +| `boundary` | `"isolated"` | What crosses the container's edge: the caret, selections, or nothing. See [Boundaries](#boundaries). | + +`placement` sits next to `children` on the block config rather than inside it, because it's a fact about *this* block rather than about its children: + +| Option | Default | Description | +| --- | --- | --- | +| `placement` | `"anywhere"` | `"containerOnly"` restricts the block to containers that name it in their `children.allow` array, like a `column`, which only makes sense inside a `columnList`. It also requires the block to be a container itself. `"anywhere"` is valid on any block; on a regular block it simply restates the default. | + +Purely behavioral options that apply to *every* block kind stay in the block implementation's `meta`: + +| Meta option | Default | Description | +| --- | --- | --- | +| `draggable` | `true` | Whether the block gets a side menu drag handle. A block that opts out is skipped when looking for a handle, so the handle falls through to the nearest draggable ancestor. | + + + _`whenEmptied` never destroys typed text: only empty children are dropped._ + + +## Defaults and refilling + +`default` is an insertion template: a container inserted without an explicit `children` array is created with those blocks. Omit it and BlockNote fills the container with empty blocks its schema accepts. + +The same template drives `whenEmptied: "refill"`. When a refill container's non-empty children drop below `min`, say `k` remain, BlockNote appends `default[k]` through `default[min - 1]` at the end, falling back to empty blocks where `default` is absent or has no entry for a position. A checklist with `min: 2` and a two-entry `default` that loses its second item gets `default[1]` back, not a bare paragraph. + +## Boundaries + +`boundary` declares what may cross a container's edge. On an open or isolated edge, editing gestures move blocks across it: Backspace at the start of the first child moves that child out, and Enter on an empty last child escapes below the container. A sealed edge blocks all of that, so the container behaves as a single unit. + +| Value | Crosses the edge | Use for | +| --- | --- | --- | +| `"open"` | Caret, editing gestures, and text selections. A selection can span children and reach outside the container. | Flow regions where a selection should cross child boundaries, like the columns of a `columnList`. | +| `"isolated"` (default) | Caret and editing gestures, but not a text selection. | Most containers, like a callout. | +| `"sealed"` | Nothing implicitly. The caret won't wander in, and from outside the container selects and deletes as one unit. | Compartments that should stay put, like a table cell. | + +```typescript +// A cell: holds any blocks, but nothing crosses its edge implicitly. +children: { allow: "any", boundary: "sealed" }, +placement: "containerOnly", +``` + +The block manipulation API ignores `boundary` entirely. An `insertBlocks` call is an intentional crossing, so it can always place content inside a sealed container. + +## Restricting children + +`allow` takes one of four forms: + +```typescript +allow: "any" | "blocks" | "containers" | string[] +``` + +- `"any"`: any regular block, plus any container placeable anywhere. +- `"blocks"`: regular blocks only, no containers. +- `"containers"`: any anywhere-placeable container, no regular blocks. +- `string[]`: only the named container block types. + +The wildcard forms (`"any"`, `"containers"`) exclude `placement: "containerOnly"` types: a `column` never shows up inside your callout just because the callout accepts "any" block. A containerOnly type appears only where a parent names it in an array. + +The array form is exact because each container block type is distinct in the schema, while every regular block (paragraph, heading, code block) shares one underlying type. So "only headings" is not something the schema can enforce yet. Naming a regular block type in the array is a startup error; per-type filtering of regular blocks is not yet supported, and the array is where it will land later with no API change. + +This is exactly how the multi-column blocks are defined: + +```typescript +// The outer container: only columns, at least two of them; +// unwraps when it drops to one, and selections span its columns. +children: { + allow: ["column"], + min: 2, + whenEmptied: "unwrap", + boundary: "open", +} + +// The column: holds any blocks, but only lives inside a columnList. +children: { allow: "any" }, +placement: "containerOnly", +``` + +## Inserting into a container + +[`editor.insertBlocks`](/docs/reference/editor/manipulating-content#inserting-blocks) takes two nested placements alongside the sibling ones: + +```typescript +// Siblings of the reference block: +editor.insertBlocks([{ type: "paragraph" }], calloutId, "before"); +editor.insertBlocks([{ type: "paragraph" }], calloutId, "after"); + +// Nested inside it, as its first or last child: +editor.insertBlocks([{ type: "paragraph" }], calloutId, "start"); +editor.insertBlocks([{ type: "paragraph" }], calloutId, "end"); +``` + +The nested placements are what addresses a container with no children to point at. A `min: 0` container that is currently empty has no child block to insert before or after. Whether a block fits is answered by the schema, so it's your `children` config that decides. + +## Validation + +Configurations are checked when the schema is created, and fail up front with a message naming the block. Beyond unknown block types and impossible `default` children, this catches: + +- an `allow` that permits nothing: an empty array, or a wildcard form when no anywhere-placeable container exists; +- an `allow` array naming an unknown type, or naming a regular block type (per-type filtering of regular blocks is [not yet supported](#restricting-children)); +- `children` combined with any `content` other than `"none"`; +- a `placement: "containerOnly"` block that no container's `allow` array names, or `placement: "containerOnly"` on a regular block; +- container cycles: a container that (transitively) requires a child that requires it back could never be created. An `allow` that permits regular blocks breaks the cycle, since they're always satisfiable. + +## Parsing HTML into a container + +Containers parse like any other custom block. The default rule matches `[data-node-type=""]` so BlockNote's own HTML round-trips, and `implementation.parse` recognizes foreign HTML. Both work exactly as described for [custom blocks](/docs/features/custom-schemas/custom-blocks). + +What's specific to a container is its body. By default BlockNote parses the element's children with the normal block rules, so `

` becomes a card with a paragraph and a heading. Supply `parseContent` only when you need to build the body yourself. + + + _`allow` does not filter what a user pastes. Content your container rejects is + placed after the container rather than dropped. `allow` constrains the + document model, not the parser._ + + +## Interop behavior + +Containers serialize to a `
` with their children nested inside, and round-trip losslessly. For lossy targets you place the children yourself: return a `childrenDOM` from `toExternalHTML` (this is how toggles export as `
`), and give container blocks an explicit mapping in the DOCX, PDF, ODT, and email exporters, which throw on a missing one. Markdown flattens containers, exporting their children in order. diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx index ff25cf838c..204381e785 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"`._ + + _A `content: "none"` block can also hold other blocks as its body by + declaring the `children` option. See [Container + Blocks](/docs/features/custom-schemas/container-blocks)._ + + `propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior. ```typescript diff --git a/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx index 1a9c97c222..873a186f17 100644 --- a/docs/content/docs/reference/editor/manipulating-content.mdx +++ b/docs/content/docs/reference/editor/manipulating-content.mdx @@ -141,11 +141,11 @@ editor.forEachBlock((block) => { insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before" + placement: "before" | "after" | "start" | "end" = "before" ): void ``` -Inserts new blocks relative to an existing block. +Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"start"` and `"end"` nest them inside it, as its first or last children. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container). ```typescript // Insert a paragraph before an existing block @@ -164,6 +164,13 @@ editor.insertBlocks( "existing-block-id", "after", ); + +// Insert a paragraph as the last child of a container block +editor.insertBlocks( + [{ type: "paragraph", content: "Nested paragraph" }], + "container-block-id", + "end", +); ``` ### Updating Blocks diff --git a/examples/06-custom-schema/09-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json new file mode 100644 index 0000000000..3de7330631 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "nickthesick", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md new file mode 100644 index 0000000000..070dd71987 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/README.md @@ -0,0 +1,22 @@ +# Container Block + +In this example, we create a custom `Callout` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block. + +The block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime. + +The callout's **title** demonstrates the complementary "string prop slot" pattern: a field that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block's own `content: "inline"` instead. + +We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks. + +**Try it out:** + +- Press the "/" key inside the callout's body and add a code block, heading, or list. +- Type a title into the title field. It's stored on `block.props.title`, not as document content. +- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`. +- Insert a new callout via the Slash Menu (search "callout"). + +**Relevant Docs:** + +- [Container Blocks](/docs/features/custom-schemas/container-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html new file mode 100644 index 0000000000..19321f77b5 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/index.html @@ -0,0 +1,14 @@ + + + + + Container Block + + + +
+ + + diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json new file mode 100644 index 0000000000..29778f9255 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx new file mode 100644 index 0000000000..3d6cf55ba1 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/App.tsx @@ -0,0 +1,118 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + filterSuggestionItems, + insertOrUpdateBlockForSlashMenu, +} from "@blocknote/core/extensions"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { useEffect, useState } from "react"; +import { RiChatQuoteLine } from "react-icons/ri"; + +import { createCallout } from "./Callout"; +import "./styles.css"; + +// Schema with the default blocks plus our custom Callout container block. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: createCallout(), + }, +}); + +// Slash menu item to insert a Callout. Because Callout is a container block, +// inserting one with no children causes BlockNote to seed it with the block's +// configured `children.default` (a single paragraph here). +const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Callout", + subtext: "Container block that wraps other blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "callout", + }), + aliases: ["callout", "container", "alert", "note", "tip", "info"], + group: "Basic blocks", + icon: , +}); + +type AppBlock = (typeof schema.BlockNoteEditor)["document"][number]; + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "Welcome! This demo shows the new container block kind.", + }, + { + type: "callout", + props: { flavor: "tip" }, + children: [ + { + type: "paragraph", + content: "Callouts can hold any block as their body.", + }, + { + type: "paragraph", + content: + "Try pressing '/' inside this callout to add a heading or code block.", + }, + ], + }, + { + type: "paragraph", + content: "Press '/' anywhere to insert a new Callout.", + }, + { + type: "paragraph", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ { + setBlocks(editor.document); + }} + > + { + const defaultItems = getDefaultReactSlashMenuItems(editor); + const lastBasicBlockIndex = defaultItems.findLastIndex( + (item) => item.group === "Basic blocks", + ); + defaultItems.splice( + lastBasicBlockIndex + 1, + 0, + insertCallout(editor), + ); + return filterSuggestionItems(defaultItems, query); + }} + /> + +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx new file mode 100644 index 0000000000..b150cead50 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -0,0 +1,104 @@ +import { createReactBlockSpec } from "@blocknote/react"; +import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md"; + +import "./styles.css"; + +// The flavors of callout the user can switch between. +export const calloutTypes = [ + { value: "tip", title: "Tip", icon: MdLightbulb }, + { value: "info", title: "Info", icon: MdInfo }, + { value: "warning", title: "Warning", icon: MdWarning }, + { value: "success", title: "Success", icon: MdCheckCircle }, +] as const; + +// The Callout block. Declared with `content: "none"` plus the `children` +// config: the block hosts arbitrary child blocks in its body, exposed at +// runtime as `block.children`. +// +// The callout's title shows a related pattern: content that shouldn't be +// part of the rich-text document (no formatting, comments, or multiplayer +// cursors needed) can live in a plain string prop, edited through a regular +// rendered inside the block. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + title: { + default: "", + }, + }, + content: "none", + // `children: { allow: "any" }` is the entire container declaration: any + // block is allowed, at least one is required, and BlockNote fills the + // callout with an empty paragraph when it's created. `min` / `max` / + // `default` / `whenEmptied` / `boundary` tune this. + children: { allow: "any" }, + }, + { + render: (props) => { + const flavor = + calloutTypes.find((c) => c.value === props.block.props.flavor) ?? + calloutTypes[0]; + const Icon = flavor.icon; + + const cycleFlavor = () => { + const idx = calloutTypes.findIndex( + (c) => c.value === props.block.props.flavor, + ); + const next = calloutTypes[(idx + 1) % calloutTypes.length]; + props.editor.updateBlock(props.block, { + type: "callout", + props: { flavor: next.value }, + }); + }; + + const commitTitle = (title: string) => { + if (title !== props.block.props.title) { + props.editor.updateBlock(props.block, { + type: "callout", + props: { title }, + }); + } + }; + + return ( +
+ +
+ {/* The title lives in a string prop, not in document content, + and is edited via a plain input. `contentEditable={false}` + keeps ProseMirror from treating typing here as document + input. */} +
+ commitTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> +
+
+
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css new file mode 100644 index 0000000000..8ecdb8f8b9 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/styles.css @@ -0,0 +1,123 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.item { + border-radius: 0.5rem; + flex: 1; + overflow: hidden; +} + +.item.bordered { + border: 1px solid gray; +} + +.item pre { + border-radius: 0.5rem; + height: 100%; + overflow: auto; + padding-block: 1rem; + padding-inline: 54px; + width: 100%; + white-space: pre-wrap; +} + +.callout { + display: flex; + align-items: flex-start; + gap: 12px; + flex-grow: 1; + border-radius: 6px; + padding: 12px 16px; + border-left: 4px solid var(--callout-accent, #888); + background-color: var(--callout-bg, #f3f4f6); +} + +/* `tip` is the prop's default value, and BlockNote only writes a `data-*` + attribute for props that differ from their default. A callout left on + `tip` carries no `data-flavor` at all, so style its absence alongside it. */ +.callout:not([data-flavor]), +.callout[data-flavor="tip"] { + --callout-accent: #d97706; + --callout-bg: #fff7ed; +} + +.callout[data-flavor="info"] { + --callout-accent: #507aff; + --callout-bg: #e6ebff; +} + +.callout[data-flavor="warning"] { + --callout-accent: #b91c1c; + --callout-bg: #fef2f2; +} + +.callout[data-flavor="success"] { + --callout-accent: #16a34a; + --callout-bg: #ecfdf5; +} + +[data-color-scheme="dark"] .callout:not([data-flavor]), +[data-color-scheme="dark"] .callout[data-flavor="tip"] { + --callout-bg: #432e0e; +} + +[data-color-scheme="dark"] .callout[data-flavor="info"] { + --callout-bg: #1e2a5c; +} + +[data-color-scheme="dark"] .callout[data-flavor="warning"] { + --callout-bg: #4a1212; +} + +[data-color-scheme="dark"] .callout[data-flavor="success"] { + --callout-bg: #0d3b21; +} + +.callout-icon-button { + background: none; + border: none; + cursor: pointer; + padding: 4px; + color: var(--callout-accent, #888); + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; +} + +.callout-icon-button:hover { + opacity: 0.75; +} + +.callout-main { + flex-grow: 1; + min-width: 0; +} + +.callout-title-wrapper { + margin-bottom: 4px; +} + +.callout-title-input { + width: 100%; + border: none; + background: none; + outline: none; + font-weight: 600; + font-size: 1rem; + color: inherit; + padding: 0; +} + +.callout-title-input::placeholder { + color: var(--callout-accent, #888); + opacity: 0.5; +} + +.callout-body { + flex-grow: 1; + min-width: 0; +} diff --git a/examples/06-custom-schema/09-container-block/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/tsconfig.json @@ -0,0 +1,32 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true, + "paths": { + "@shared/*": ["../../../shared/*"] + } + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/09-container-block/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/09-container-block/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts new file mode 100644 index 0000000000..a96f1f04ff --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite.config.ts @@ -0,0 +1,35 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // The repo-wide alias for the shared test-utils directory (private, + // so it only resolves inside the monorepo). Harmless for examples + // that don't use it. + "@shared": path.resolve(__dirname, "../../../shared/"), + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/06-custom-schema/12-container-table/.bnexample.json b/examples/06-custom-schema/12-container-table/.bnexample.json new file mode 100644 index 0000000000..55cd80cdc6 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "nickthesick", + "tags": [ + "Advanced", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/12-container-table/README.md b/examples/06-custom-schema/12-container-table/README.md new file mode 100644 index 0000000000..3441f4aab3 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/README.md @@ -0,0 +1,18 @@ +# Table Built From Container Blocks + +In this example, we rebuild BlockNote's table as four container blocks: `table`, `tableRow`, `tableCell`, and `tableHeader`. There is no `prosemirror-tables` and no special `"table"` content type. A table is a container of rows, a row is a container of cells, and a cell is a container of arbitrary blocks, so cells can hold lists, headings, images, or even nested tables. The JSON shape is the same `children` array every other block uses. + +Cells declare `boundary: "sealed"`, which makes them behave like compartments: Backspace, Delete, and arrow keys never implicitly move content or the caret across a cell's edge, and Enter adds another block _inside_ the cell. Header cells are a distinct block type rather than table metadata, so toggling the header row is just `updateBlock` with a new type. All structural operations, from adding and removing rows and columns to Tab-to-next-cell, are plain calls to the public block manipulation API: `insertBlocks`, `removeBlocks`, `updateBlock`, `getParentBlock`, and `setTextCursorPosition`. + +**Try it out:** + +- Press Tab / Shift-Tab to move between cells. Tab in the last cell adds a new row. +- Press Enter inside a cell to stack more blocks in it, or "/" to add a list or heading. +- Hover the table to reveal the row/column controls, and watch the JSON panel update. + +**Relevant Docs:** + +- [Container Blocks](/docs/features/custom-schemas/container-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Manipulating Blocks](/docs/reference/editor/manipulating-content) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/12-container-table/index.html b/examples/06-custom-schema/12-container-table/index.html new file mode 100644 index 0000000000..f3c064f4c0 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/index.html @@ -0,0 +1,14 @@ + + + + + Table Built From Container Blocks + + + +
+ + + diff --git a/examples/06-custom-schema/12-container-table/main.tsx b/examples/06-custom-schema/12-container-table/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/06-custom-schema/12-container-table/package.json b/examples/06-custom-schema/12-container-table/package.json new file mode 100644 index 0000000000..8f6d3a1eec --- /dev/null +++ b/examples/06-custom-schema/12-container-table/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-table", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/06-custom-schema/12-container-table/src/App.tsx b/examples/06-custom-schema/12-container-table/src/App.tsx new file mode 100644 index 0000000000..33ca4d686d --- /dev/null +++ b/examples/06-custom-schema/12-container-table/src/App.tsx @@ -0,0 +1,184 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { + filterSuggestionItems, + insertOrUpdateBlockForSlashMenu, +} from "@blocknote/core/extensions"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { useEffect, useState } from "react"; +import { TbTable } from "react-icons/tb"; + +import { + createTable, + createTableCell, + createTableHeader, + createTableRow, +} from "./Table"; +import "./styles.css"; + +// Drop the built-in table (the one with the special `"table"` content type) +// and replace it with our container-based implementation under the same +// `table` type name. The specs are passed to `create` rather than `extend`, +// as `extend` can only add new block types, not replace existing ones. +const { table: _defaultTable, ...remainingBlockSpecs } = defaultBlockSpecs; + +const schema = BlockNoteSchema.create({ + blockSpecs: { + ...remainingBlockSpecs, + table: createTable(), + tableRow: createTableRow(), + tableCell: createTableCell(), + tableHeader: createTableHeader(), + }, +}); + +// Inserting a table with no explicit children seeds it from the block's +// configured `children.default`: a 3-column table with a header row. +const insertTable = (editor: typeof schema.BlockNoteEditor) => ({ + title: "Table", + subtext: "Table built from container blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "table", + }), + aliases: ["table", "grid", "cells"], + group: "Basic blocks", + icon: , +}); + +type AppBlock = (typeof schema.BlockNoteEditor)["document"][number]; + +export default function App() { + const [blocks, setBlocks] = useState([]); + + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "This table is built entirely from container blocks, with no special table content type.", + }, + { + type: "table", + children: [ + { + type: "tableRow", + children: [ + { + type: "tableHeader", + children: [{ type: "paragraph", content: "Name" }], + }, + { + type: "tableHeader", + children: [{ type: "paragraph", content: "Notes" }], + }, + ], + }, + { + type: "tableRow", + children: [ + { + type: "tableCell", + children: [{ type: "paragraph", content: "Alice" }], + }, + { + type: "tableCell", + children: [ + { + type: "paragraph", + content: "Cells hold any blocks:", + }, + { + type: "bulletListItem", + content: "lists,", + }, + { + type: "bulletListItem", + content: "headings, images…", + }, + ], + }, + ], + }, + { + type: "tableRow", + children: [ + { + type: "tableCell", + children: [{ type: "paragraph", content: "Bob" }], + }, + { + type: "tableCell", + children: [ + { + type: "paragraph", + content: "Tab / Shift-Tab move between cells.", + }, + ], + }, + ], + }, + ], + }, + { + type: "paragraph", + content: + "Tab in the last cell adds a row. Press '/' to insert a new table.", + }, + { + type: "paragraph", + }, + ], + }); + + useEffect(() => setBlocks(editor.document), [editor]); + + return ( +
+
BlockNote Editor:
+
+ { + setBlocks(editor.document); + }} + > + { + // Swap the built-in Table item (which inserts the old + // `tableContent` shape) for one that inserts our container + // table. + const defaultItems = getDefaultReactSlashMenuItems(editor).filter( + (item) => item.title !== "Table", + ); + const lastBasicBlockIndex = defaultItems.findLastIndex( + (item) => item.group === "Basic blocks", + ); + defaultItems.splice( + lastBasicBlockIndex + 1, + 0, + insertTable(editor), + ); + return filterSuggestionItems(defaultItems, query); + }} + /> + +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/12-container-table/src/Table.tsx b/examples/06-custom-schema/12-container-table/src/Table.tsx new file mode 100644 index 0000000000..e63b916db4 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/src/Table.tsx @@ -0,0 +1,346 @@ +import { + createExtension, + type Block, + type BlockNoteEditor, +} from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +// A table built entirely out of container blocks, without `prosemirror-tables` +// or the special `"table"` content type. A table is a container of rows, a row +// is a container of cells, and a cell is a container of arbitrary blocks: +// +// table > tableRow > tableCell / tableHeader > (any blocks) +// +// The JSON shape is the same `children` array every other container block +// uses, and every structural operation (add/remove row or column, toggle the +// header row) is a plain `insertBlocks` / `removeBlocks` / `updateBlock` call. + +type AnyEditor = BlockNoteEditor; +type AnyBlock = Block; + +function isCellType(type: string): boolean { + return type === "tableCell" || type === "tableHeader"; +} + +// --------------------------------------------------------------------------- +// Cell navigation (Tab / Shift-Tab) +// --------------------------------------------------------------------------- + +// Finds the cell / row / table the text cursor is currently inside, by +// walking up the ancestor chain with `editor.getParentBlock`. Returns +// undefined when the cursor isn't in a table. +function getCellContext( + editor: AnyEditor, +): { cell: AnyBlock; row: AnyBlock; table: AnyBlock } | undefined { + let current: AnyBlock | undefined = editor.getTextCursorPosition().block; + while (current && !isCellType(current.type)) { + current = editor.getParentBlock(current); + } + if (!current) { + return undefined; + } + + const row = editor.getParentBlock(current); + if (!row || row.type !== "tableRow") { + return undefined; + } + const table = editor.getParentBlock(row); + if (!table || table.type !== "table") { + return undefined; + } + + return { cell: current, row, table }; +} + +// Places the cursor inside a cell. Descends through nested tables so the +// cursor always lands on a block that can actually hold it. +function placeCursorInCell( + editor: AnyEditor, + cell: AnyBlock, + placement: "start" | "end", +) { + let target = cell; + while ( + target.children.length > 0 && + (target.type === "table" || + target.type === "tableRow" || + isCellType(target.type)) + ) { + target = + placement === "start" + ? target.children[0] + : target.children[target.children.length - 1]; + } + editor.setTextCursorPosition(target, placement); +} + +function createRow( + numColumns: number, + cellType: "tableCell" | "tableHeader" = "tableCell", +) { + return { + type: "tableRow" as const, + children: Array.from({ length: numColumns }, () => ({ type: cellType })), + }; +} + +// Moves the cursor to the next/previous cell, wrapping across rows. Tab past +// the last cell grows the table by a row, like in a spreadsheet, with a +// single `insertBlocks` call. +function moveToAdjacentCell(editor: AnyEditor, direction: 1 | -1): boolean { + const context = getCellContext(editor); + if (!context) { + // Not in a table: let BlockNote's default Tab (indent) behavior run. + return false; + } + const { cell, row, table } = context; + + const rows = table.children; + const rowIndex = rows.findIndex((r) => r.id === row.id); + const cellIndex = row.children.findIndex((c) => c.id === cell.id); + + let targetRowIndex = rowIndex; + let targetCellIndex = cellIndex + direction; + if (targetCellIndex >= row.children.length) { + targetRowIndex += 1; + targetCellIndex = 0; + } else if (targetCellIndex < 0) { + targetRowIndex -= 1; + targetCellIndex = + targetRowIndex >= 0 ? rows[targetRowIndex].children.length - 1 : 0; + } + + // Shift-Tab at the very first cell: stay put (but consume the key so the + // cell's content isn't un-indented out of the table). + if (targetRowIndex < 0) { + return true; + } + + // Tab at the very last cell: append a new row and move into it. + if (targetRowIndex >= rows.length) { + editor.insertBlocks( + [createRow(row.children.length)], + rows[rows.length - 1], + "after", + ); + const updatedTable = editor.getBlock(table.id); + const newRow = updatedTable?.children[updatedTable.children.length - 1]; + if (newRow) { + placeCursorInCell(editor, newRow.children[0], "start"); + } + return true; + } + + placeCursorInCell( + editor, + rows[targetRowIndex].children[targetCellIndex], + direction === 1 ? "start" : "end", + ); + return true; +} + +// Registered on the `table` block spec, so the shortcuts are only added when +// the block is in the schema. Block-spec extensions run before BlockNote's +// default keyboard handlers, so Tab reaches us before the default indent. +const TableKeyboardExtension = createExtension({ + key: "containerTableKeyboard", + keyboardShortcuts: { + Tab: ({ editor }) => moveToAdjacentCell(editor, 1), + "Shift-Tab": ({ editor }) => moveToAdjacentCell(editor, -1), + }, +}); + +// --------------------------------------------------------------------------- +// Structural operations, using only the public block manipulation API +// --------------------------------------------------------------------------- + +function getTable(editor: AnyEditor, tableId: string): AnyBlock | undefined { + const table = editor.getBlock(tableId); + return table?.type === "table" ? table : undefined; +} + +export function addRow(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table) { + return; + } + const lastRow = table.children[table.children.length - 1]; + editor.insertBlocks([createRow(lastRow.children.length)], lastRow, "after"); +} + +export function removeRow(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table || table.children.length <= 1) { + return; + } + editor.removeBlocks([table.children[table.children.length - 1]]); +} + +export function addColumn(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table) { + return; + } + editor.transact(() => { + for (const row of table.children) { + const lastCell = row.children[row.children.length - 1]; + // Match the row's cell kind, so a header row grows a header cell. + editor.insertBlocks([{ type: lastCell.type }], lastCell, "after"); + } + }); +} + +export function removeColumn(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table || table.children.some((row) => row.children.length <= 1)) { + return; + } + editor.transact(() => { + for (const row of table.children) { + editor.removeBlocks([row.children[row.children.length - 1]]); + } + }); +} + +// Flips the first row between header cells and regular cells. Because header +// cells are a distinct block type (not table metadata), this is just +// `updateBlock` with a new type. Children are carried over automatically. +export function toggleHeaderRow(editor: AnyEditor, tableId: string) { + const table = getTable(editor, tableId); + if (!table) { + return; + } + const firstRow = table.children[0]; + const allHeaders = firstRow.children.every((c) => c.type === "tableHeader"); + const type = allHeaders ? "tableCell" : "tableHeader"; + editor.transact(() => { + for (const cell of firstRow.children) { + editor.updateBlock(cell, { type }); + } + }); +} + +// --------------------------------------------------------------------------- +// The four block specs +// --------------------------------------------------------------------------- + +// The table itself: a container that only accepts rows. Inserting one with +// no explicit children seeds it from `children.default`: a header row plus +// two body rows, three columns wide. +export const createTable = createReactBlockSpec( + { + type: "table", + propSchema: {}, + content: "none", + children: { + allow: ["tableRow"], + default: [ + createRow(3, "tableHeader"), + createRow(3, "tableCell"), + createRow(3, "tableCell"), + ], + }, + }, + { + render: (props) => { + // `props.block` is captured at render time; the control handlers + // re-fetch the table by id so they always operate on fresh children. + const { editor } = props; + const tableId = props.block.id; + + // Keep focus (and the text selection) in the editor when clicking the + // controls. + const keepFocus = (event: React.MouseEvent) => event.preventDefault(); + + return ( +
+
+
+ + + + + +
+
+ ); + }, + // Recognizes pasted foreign HTML tables. + parse: (el) => (el.tagName === "TABLE" ? {} : undefined), + }, + [TableKeyboardExtension], +); + +// A row: only lives inside a table (`placement: "containerOnly"`), only +// holds cells. +export const createTableRow = createReactBlockSpec( + { + type: "tableRow", + propSchema: {}, + content: "none", + children: { allow: ["tableCell", "tableHeader"] }, + placement: "containerOnly", + }, + { + // No drag handle of its own; the side menu handle falls through to the + // table. + meta: { draggable: false }, + render: (props) => ( +
+ ), + parse: (el) => (el.tagName === "TR" ? {} : undefined), + }, +); + +// A cell: holds any blocks, and is `boundary: "sealed"`, so the caret and +// content never implicitly cross its edge (Backspace at the start of a cell +// does nothing, Delete at its end doesn't pull the next block in, arrow keys +// from outside treat the table as a unit). Enter inside a cell just adds +// another block to the cell. +export const createTableCell = createReactBlockSpec( + { + type: "tableCell", + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "sealed" }, + placement: "containerOnly", + }, + { + meta: { draggable: false }, + render: (props) => ( +
+ ), + parse: (el) => (el.tagName === "TD" ? {} : undefined), + }, +); + +// A header cell: identical to a regular cell, but a distinct block type. +// The structure itself encodes which cells are headers, instead of +// `headerRows` metadata on the table. +export const createTableHeader = createReactBlockSpec( + { + type: "tableHeader", + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "sealed" }, + placement: "containerOnly", + }, + { + meta: { draggable: false }, + render: (props) => ( +
+ ), + parse: (el) => (el.tagName === "TH" ? {} : undefined), + }, +); diff --git a/examples/06-custom-schema/12-container-table/src/styles.css b/examples/06-custom-schema/12-container-table/src/styles.css new file mode 100644 index 0000000000..cfb39149f8 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/src/styles.css @@ -0,0 +1,100 @@ +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} + +.item { + border-radius: 0.5rem; + flex: 1; + overflow: hidden; +} + +.item.bordered { + border: 1px solid gray; +} + +.item pre { + border-radius: 0.5rem; + height: 100%; + overflow: auto; + padding-block: 1rem; + padding-inline: 54px; + width: 100%; + white-space: pre-wrap; +} + +/* The grid is plain CSS tables on divs. The React node-view wrappers between + the regions carry `display: contents`, so the row and cell boxes end up + direct children of the table box as far as layout is concerned. */ +.container-table { + flex-grow: 1; + min-width: 0; +} + +.container-table-rows { + display: table; + border-collapse: collapse; + width: 100%; + table-layout: fixed; +} + +.container-table-row { + display: table-row; +} + +.container-table-cell { + display: table-cell; + border: 1px solid #d0d0d0; + padding: 4px 8px; + vertical-align: top; +} + +.container-table-header { + background-color: #f3f4f6; + font-weight: 600; +} + +[data-color-scheme="dark"] .container-table-cell { + border-color: #4b4b4b; +} + +[data-color-scheme="dark"] .container-table-header { + background-color: #2e2e2e; +} + +.container-table-controls { + display: flex; + gap: 4px; + padding-top: 4px; + /* Only reveal the controls while working in the table. */ + opacity: 0; + transition: opacity 0.15s; +} + +.container-table:hover .container-table-controls, +.container-table:focus-within .container-table-controls { + opacity: 1; +} + +.container-table-controls button { + border: 1px solid #d0d0d0; + border-radius: 4px; + background: none; + color: inherit; + font-size: 0.75rem; + padding: 2px 8px; + cursor: pointer; +} + +.container-table-controls button:hover { + background-color: #f3f4f6; +} + +[data-color-scheme="dark"] .container-table-controls button { + border-color: #4b4b4b; +} + +[data-color-scheme="dark"] .container-table-controls button:hover { + background-color: #2e2e2e; +} diff --git a/examples/06-custom-schema/12-container-table/tsconfig.json b/examples/06-custom-schema/12-container-table/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/tsconfig.json @@ -0,0 +1,32 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true, + "paths": { + "@shared/*": ["../../../shared/*"] + } + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/12-container-table/vite-env.d.ts b/examples/06-custom-schema/12-container-table/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/12-container-table/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/12-container-table/vite.config.ts b/examples/06-custom-schema/12-container-table/vite.config.ts new file mode 100644 index 0000000000..a96f1f04ff --- /dev/null +++ b/examples/06-custom-schema/12-container-table/vite.config.ts @@ -0,0 +1,35 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + ? {} + : ({ + // The repo-wide alias for the shared test-utils directory (private, + // so it only resolves inside the monorepo). Harmless for examples + // that don't use it. + "@shared": path.resolve(__dirname, "../../../shared/"), + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/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..21a5006fad 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,92 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { isContainerNode } 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"; + +/** + * 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 cover containers that have no children to point at: + * a `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. + * + * Shared by `insertBlocks` and the move commands, so "does this block fit + * here?" is answered 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 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; + + const descend = (holder: Node, pos: number) => + placement === "start" + ? descendToFirstInsertionPos(holder, pos, nodeType) + : descendToLastInsertionPos(holder, pos, nodeType); + + 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. The descent helpers ignore sealed + // boundaries by default, which is correct here: an explicit `insertBlocks` + // placement is an intentional crossing. + if (isContainerNode(node.type)) { + const pos = descend(node, posBeforeNode); + + 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 = descend(node.lastChild!, blockGroupPos); + + return pos === null ? null : { pos }; +} export function insertBlocks< BSchema extends BlockSchema, @@ -21,7 +103,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 +119,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..132eafe1b1 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -0,0 +1,183 @@ +// @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"; + +// The editor stays headless, so these blocks are never rendered. `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, + // Why `"start"`/`"end"` exist: 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: { allow: "any", 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: ["cell"], min: 2 }, + }), + cell: container("cell", { + content: "none", + children: { allow: "any" }, + placement: "containerOnly", + }), + // A container that is full once it has one child. + single: container("single", { + content: "none", + children: { allow: "any", 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: "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", + "last", + ]); + }); + + 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("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, with or without existing children", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); + + editor.insertBlocks([{ id: "existing", type: "paragraph" }], "p-0", "end"); + 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 threw 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..5d1f0e3b51 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,6 +1,7 @@ import { Node } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; +import { isSealed } from "../../../../schema/blocks/children.js"; import { BlockInfo, getBlockInfoFromResolvedPos, @@ -90,8 +91,20 @@ 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) { +export const getBottomNestedBlockInfo = ( + doc: Node, + blockInfo: BlockInfo, + // Callers that move content stop the descent at a sealed container, getting + // the container itself rather than a block inside it. Caret-only callers + // descend through. Sealed boundaries govern content, not navigation. + opts?: { stopAtSealed?: boolean }, +) => { + // 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) { + if (opts?.stopAtSealed && isSealed(blockInfo.childContainer.node)) { + break; + } const group = blockInfo.childContainer.node; const newPos = doc @@ -105,10 +118,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 +133,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 +160,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, 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..ea97ae8869 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -1,3 +1,4 @@ +import { NodeType } from "prosemirror-model"; import { NodeSelection, Selection, @@ -14,7 +15,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 +133,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 +161,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 +201,43 @@ 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 comes from the schema rather +// than from a rule restated here. +function checkPlacementIsValid( + editor: BlockNoteEditor, + referenceBlock: Block, + placement: "before" | "after", + nodeType: NodeType, +): boolean { + return editor.transact((tr) => { + const posInfo = getNodeById(referenceBlock.id, tr.doc); + if (!posInfo) { + return false; + } + return getInsertionPos(tr.doc, posInfo, placement, nodeType) !== null; + }); +} + +// The PM node type `insertBlocks` validates a destination against: the first +// flattened block's own node type when it's a container (e.g. `callout`), +// otherwise the generic `blockContainer` wrapper. Mirrors what `moveBlocks` +// inserts (`flattenNonInsertableBlocks` + `insertBlocks`), so the placement +// pre-check agrees with the insertion instead of always assuming a regular +// block. +function movedNodeType( + editor: BlockNoteEditor, + block: Block, +): NodeType { + const blockContainer = editor.pmSchema.nodes["blockContainer"]; + const first = flattenNonInsertableBlocks([block], editor.pmSchema)[0]; + const nodeType = first?.type ? editor.pmSchema.nodes[first.type] : undefined; + return nodeType?.isInGroup("bnBlock") ? nodeType : blockContainer; } // Gets the placement for moving a block up. This has 3 cases: @@ -227,6 +252,7 @@ function checkPlacementIsValid(parentBlock?: Block): boolean { // the block is already at the top of the document. function getMoveUpPlacement( editor: BlockNoteEditor, + nodeType: NodeType, prevBlock?: Block, parentBlock?: Block, ): @@ -253,10 +279,11 @@ function getMoveUpPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement, nodeType)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveUpPlacement( editor, + nodeType, placement === "after" ? referenceBlock : editor.getPrevBlock(referenceBlock), @@ -279,6 +306,7 @@ function getMoveUpPlacement( // the block is already at the bottom of the document. function getMoveDownPlacement( editor: BlockNoteEditor, + nodeType: NodeType, nextBlock?: Block, parentBlock?: Block, ): @@ -305,10 +333,11 @@ function getMoveDownPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement, nodeType)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveDownPlacement( editor, + nodeType, placement === "before" ? referenceBlock : editor.getNextBlock(referenceBlock), @@ -338,6 +367,7 @@ export function moveBlocksUp( const moveUpPlacement = getMoveUpPlacement( editor, + movedNodeType(editor, sourceBlock), editor.getPrevBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); @@ -369,20 +399,28 @@ export function moveBlocksDown( ) { editor.transact(() => { let sourceBlock: Block | undefined; + // The block whose position anchors the move (the last of a selection when + // moving down) vs. the first block that gets inserted, which is what the + // placement check must validate against. + let firstMovedBlock: Block | undefined; if (blockIdentifier) { sourceBlock = editor.getBlock(blockIdentifier); if (!sourceBlock) { return; } + firstMovedBlock = sourceBlock; } else { const selection = editor.getSelection(); sourceBlock = selection?.blocks[selection?.blocks.length - 1] || editor.getTextCursorPosition().block; + firstMovedBlock = + selection?.blocks[0] || editor.getTextCursorPosition().block; } const moveDownPlacement = getMoveDownPlacement( editor, + movedNodeType(editor, firstMovedBlock), editor.getNextBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index a0f76fdff0..243e4532dd 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..75305b501d 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..ad2cc151f3 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,8 @@ 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 { isContainerType } 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 +84,49 @@ 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; + 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 && + newNodeType.isInGroup("bnBlock") + ) { 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 +139,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 +181,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 (isContainerType(targetConfig)) { + return { + children: [{ type: "paragraph", content: existingContent } as any], + }; + } + + return nothing; +} + function updateBlockContentNode< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -521,7 +579,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 +695,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/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts new file mode 100644 index 0000000000..211fee190c --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -0,0 +1,123 @@ +import type { Node, NodeType } from "prosemirror-model"; + +import { isContainerNode, isSealed } from "../../../schema/blocks/children.js"; + +/** + * Seal handling for the navigation helpers below. By default the helpers + * ignore seals. The block manipulation API crosses them freely, since an + * explicit placement is an intentional crossing. Gesture code (keyboard + * merges and moves) opts in with `respectSealed`, so content never + * implicitly crosses a sealed boundary. + */ +type SealOpts = { respectSealed?: boolean }; + +export function descendToLastInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, + opts?: SealOpts, +): number | null { + if (opts?.respectSealed && isSealed(container)) { + return 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, + opts, + ); + } + return null; +} + +// No seal handling: its only callers are API code, which crosses seals by +// construction. +export function descendToFirstInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + 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, + opts?: SealOpts, +): { node: Node; beforePos: number } | null { + // With `respectSealed`, a sealed container's leaf blocks are not reachable + // from outside. + if (opts?.respectSealed && isSealed(container)) { + return null; + } + const firstChild = container.firstChild; + if (!firstChild) { + return null; + } + const firstChildBeforePos = containerBeforePos + 1; + if (isContainerNode(firstChild.type)) { + return getFirstLeafBlock(firstChild, firstChildBeforePos, opts); + } + return { node: firstChild, beforePos: firstChildBeforePos }; +} + +/** + * Climbs out of containers until it reaches a position where `nodeType` fits. + * `side` picks which edge of each climbed container to land on: `"before"` for + * moves that put a block above the containers it leaves (Backspace move-out), + * `"after"` for moves that put it below them (Enter-exit). + */ +export function ascendToInsertablePos( + doc: Node, + pos: number, + nodeType: NodeType, + opts?: SealOpts, + side: "before" | "after" = "before", +): number | null { + for (;;) { + const $pos = doc.resolve(pos); + const parent = $pos.node(); + if (parent.contentMatchAt($pos.index()).matchType(nodeType)) { + return pos; + } + if ($pos.depth > 0 && isContainerNode(parent.type)) { + // With `respectSealed`, climbing out of a sealed container would move + // content across its boundary. + if (opts?.respectSealed && isSealed(parent)) { + return null; + } + pos = side === "before" ? $pos.before() : $pos.after(); + 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) && 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..a313b69901 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts @@ -0,0 +1,480 @@ +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"; + +// Keymap tests for container blocks, split off from the node-environment +// `containers.test.ts`. tiptap can only reach `handleKeyDown` through a +// mounted view, so the editor is mounted and focused here and the keys are +// pressed for real. + +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 at the given position and presses the key. */ +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" }); + + 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 moves out with the block. + expect(editor.getTextCursorPosition().block.id).toBe("c-p-1"); + }); + + it("Enter escape ascends past levels that can't hold the block", async () => { + // A grid holds only cells, so a block escaping the last cell can't stop + // at the grid level. It lands below the grid itself. + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "g-c-0", + children: [{ id: "g-p-0", type: "paragraph", content: "A" }], + }, + { + type: "gridCell", + id: "g-c-1", + children: [ + { id: "g-p-1", type: "paragraph", content: "B" }, + { id: "g-p-2", type: "paragraph", content: "" }, + ], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "g-p-2", placement: "end" }); + + expect(editor.getBlock("g-c-1")!.children.map((child) => child.id)).toEqual( + ["g-p-1"], + ); + expect(editor.document.map((block) => block.id)).toEqual([ + "g-0", + "g-p-2", + "trailing", + ]); + expect(editor.getTextCursorPosition().block.id).toBe("g-p-2"); + }); + + it("Enter on an empty block mid-container stays inside", async () => { + // The escape only fires at the end of the container. An empty block with + // siblings after it never ejects. + 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: "c-p-2", type: "paragraph", content: "World" }, + ], + }, + ]); + + await pressKey("Enter", { block: "c-p-1", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + expect(editor.getBlock("c-0")!.children).toHaveLength(4); + }); + + 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" }); + + 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"]); + }); +}); + +// Sealed-boundary counterparts to the open cases above. Every implicit +// crossing must be a no-op on a sealed container, while edits within the +// container keep working. +describe("sealed boundary keyboard handling", () => { + function documentShape() { + return editor.document.map((block) => [ + block.id, + block.children.map((child) => child.id), + ]); + } + + it("Backspace at the start of the first child does not move it out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "First" }, + { id: "s-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "s-p-0", placement: "start" }); + + expect(documentShape()).toEqual(shape); + expect(editor.getTextCursorPosition().block.id).toBe("s-p-0"); + }); + + it("Backspace at the start of the second child still merges within", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "First" }, + { id: "s-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "s-p-1", placement: "start" }); + + // Asserting the merge guards against the suite passing because + // keystrokes never arrive. + const children = editor.getBlock("s-0")!.children; + expect(children).toHaveLength(1); + expect(children[0].content).toEqual([ + { type: "text", text: "FirstSecond", styles: {} }, + ]); + }); + + it("Backspace after the container does not move the block inside", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [{ id: "s-p-0", type: "paragraph", content: "Sealed" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(documentShape()).toEqual(shape); + // With no way in, the fallback node-selects the container, so a second + // Backspace deletes it explicitly. + const selection = editor.transact((tr) => tr.selection); + expect("node" in selection && (selection.node as any).type.name).toBe( + "sealedBox", + ); + }); + + it("Backspace after the container does not replace its trailing empty block", async () => { + // The previous case falls through the "descend into the previous + // container" branch; this one targets the "previous block is empty" + // branch, which descends to the bottom nested block, here the empty + // paragraph inside the sealed container. + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "Sealed" }, + { id: "s-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Delete before the container does not pull its first child out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "First" }, + { id: "s-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + const shape = documentShape(); + + await pressKey("Delete", { block: "before", placement: "end" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Delete at the end of the last child does not pull the next block in", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [{ id: "s-p-0", type: "paragraph", content: "Sealed" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Delete", { block: "s-p-0", placement: "end" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Delete at the end of a nested last block does not reach past the boundary", async () => { + // The climb in "delete next block at any level" starts from a nested + // block, where the direct last-child branch doesn't apply. Without its + // own gate, Delete here would consume "after" into the sealed container. + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { + id: "s-p-0", + type: "paragraph", + content: "Parent", + children: [{ id: "s-n-0", type: "paragraph", content: "Nested" }], + }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Delete", { block: "s-n-0", placement: "end" }); + + expect(documentShape()).toEqual(shape); + }); + + it("Backspace after an isolated container of sealed ones selects it", async () => { + // Same shape as tables: the grid itself is not sealed, but every place a + // descent could land is sealed. The block can't move in, so the grid is + // selected for an explicit second-Backspace delete instead. + editor.replaceBlocks(editor.document, [ + { + type: "sealedGrid", + id: "g-0", + children: [ + { + type: "sealedBox", + id: "g-c-0", + children: [{ id: "g-p-0", type: "paragraph", content: "Cell" }], + }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const shape = documentShape(); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(documentShape()).toEqual(shape); + const selection = editor.transact((tr) => tr.selection); + expect("node" in selection && (selection.node as any).type.name).toBe( + "sealedGrid", + ); + }); + + it("Enter on an empty last child stays inside the container", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "sealedBox", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "Hello" }, + { id: "s-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "s-p-1", placement: "end" }); + + // A sealed boundary means Enter never moves content out, so there is no + // double-Enter escape. The new block is created inside. + expect(editor.document.map((block) => block.type)).toEqual([ + "sealedBox", + "paragraph", + ]); + const children = editor.getBlock("s-0")!.children; + expect(children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe(children[2].id); + }); +}); + +// HTML round-trips (full, external, clipboard) live with the parse rules in +// `schema/blocks/containerParse.browser.test.ts`. +describe("children conversion", () => { + 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..f6149f65b5 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -0,0 +1,133 @@ +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: { + allow: "any", + default: [{ type: "paragraph" }], + }, + }, + { render: renderDiv }, +)(); + +// A compartment-style container, like a table cell. Content never implicitly +// crosses its boundary. +const SealedBox = createBlockSpec( + { + type: "sealedBox" as const, + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "sealed" }, + }, + { render: renderDiv }, +)(); + +// An open container, like a column list. Everything crosses its edge +// (PM `isolating: false`). +const OpenBox = createBlockSpec( + { + type: "openBox" as const, + propSchema: {}, + content: "none", + children: { allow: "any", boundary: "open" }, + }, + { render: renderDiv }, +)(); + +// Same shape as tables: an isolated container (the default) that holds only +// sealed ones, so any descent into it bottoms out at a sealed boundary. +const SealedGrid = createBlockSpec( + { + type: "sealedGrid" as const, + propSchema: {}, + content: "none", + children: { allow: ["sealedBox"] }, + }, + { render: renderDiv }, +)(); + +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + children: { + allow: ["gridCell"], + min: 2, + whenEmptied: "unwrap", + }, + }, + { render: renderDiv }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + children: { allow: "any" }, + placement: "containerOnly", + }, + { render: renderDiv }, +)(); + +// A refill (default `whenEmptied`) container whose `default` has content. +// Dropping below `min` tops it back up from the unconsumed tail of `default`. +const SeededPair = createBlockSpec( + { + type: "seededPair" as const, + propSchema: {}, + content: "none", + children: { + allow: "any", + min: 2, + default: [ + { type: "paragraph", content: "Seed A" }, + { type: "paragraph", content: "Seed B" }, + ], + }, + }, + { render: renderDiv }, +)(); + +// A container that only accepts regular blocks, not other container blocks. +// Used to check that placement validation matches on the moved block's real +// node type rather than always assuming a `blockContainer`. +const BlocksOnlyBox = createBlockSpec( + { + type: "blocksOnlyBox" as const, + propSchema: {}, + content: "none", + children: { allow: "blocks" }, + }, + { render: renderDiv }, +)(); + +export const containerSchema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + sealedBox: SealedBox, + openBox: OpenBox, + sealedGrid: SealedGrid, + grid: Grid, + gridCell: GridCell, + seededPair: SeededPair, + blocksOnlyBox: BlocksOnlyBox, + } 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..405c22f8a5 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,393 @@ +// @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. Everything is `Block` JSON in and out, so the editor +// runs headless with no DOM. +// +// The keymap (tiptap can only reach it through a mounted view) and +// HTML/markdown serialization (builds real DOM) are tested 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"); + }); + + // Regression: `min` defaults to 1 and nothing seeded a container without + // `default`, so inserting one threw a raw ProseMirror + // `RangeError: Invalid content for node ...`. + it("fills a container that has no `default`, with real child ids", () => { + expect(() => + editor.insertBlocks([{ type: "sealedBox", 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"); + // Auto-filled nodes come from the schema with `id: null`, and the + // UniqueID plugin never sees them because `insertBlocks` converts back + // through `nodeToBlock` before the transaction is dispatched. + expect(box.children[0].id).toBeTruthy(); + expect(editor.getBlock(box.children[0].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, + ); + }); + + // Regression: `children: []` was taken at face value, building a node below + // `min: 1`, and `insertBlocks` 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(); + }); + + // A container that unwraps as it empties 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(); + }); + + 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("enforces a restricted container's allow list", () => { + editor.insertBlocks( + [ + { + type: "grid", + id: "g-0", + children: [{ type: "gridCell" }, { type: "gridCell" }], + }, + ], + "p-1", + "after", + ); + expect(editor.getBlock("g-0")!.children.map((child) => child.type)).toEqual( + ["gridCell", "gridCell"], + ); + + expect(() => + editor.insertBlocks( + [ + { + type: "grid", + children: [ + { type: "paragraph", content: "not a cell" }, + { type: "paragraph", content: "not a cell" }, + ], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); + + // The `allow: "any"` wildcard compiles to the containers placeable + // anywhere, so a containerOnly block only fits where a parent names it + // explicitly: not at the root, and not under a wildcard container. + it("rejects a containerOnly block outside a parent that names it", () => { + expect(() => + editor.insertBlocks( + [{ type: "gridCell", children: [{ type: "paragraph" }] }], + "p-1", + "after", + ), + ).toThrow(); + + expect(() => + editor.insertBlocks( + [ + { + type: "callout", + children: [{ type: "gridCell", children: [{ type: "paragraph" }] }], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); +}); + +describe("boundary", () => { + it("derives ProseMirror `isolating` from `boundary`", () => { + const nodes = editor.pmSchema.nodes; + expect(nodes["openBox"].spec.isolating).toBe(false); + // "isolated" is the default. + expect(nodes["callout"].spec.isolating).toBe(true); + // "sealed" also isolates. + expect(nodes["sealedBox"].spec.isolating).toBe(true); + }); +}); + +// `initialContent` is the only path that builds a document without validating +// it, since `blockToNode` is deliberately lenient and `createDocument` builds +// from JSON. Regression: blocks that `insertBlocks` rejects loaded without +// error, and a container below its `min` stayed there 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("refills below `min` from the unconsumed tail of `default`", () => { + editor.replaceBlocks(editor.document, [ + { + type: "seededPair", + id: "s-0", + children: [ + { id: "s-p-0", type: "paragraph", content: "Kept" }, + { id: "s-p-1", type: "paragraph", content: "Removed" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["s-p-1"]); + + // One child survives (k = 1), so the top-up seeds `default[1]`, not an + // empty paragraph and not `default[0]`. + const pair = editor.getBlock("s-0")!; + expect(pair.children).toHaveLength(2); + expect(pair.children[0].content).toEqual([ + { type: "text", text: "Kept", styles: {} }, + ]); + expect(pair.children[1].content).toEqual([ + { type: "text", text: "Seed B", styles: {} }, + ]); + }); + + 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.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"); + }); +}); + +describe("empty unwrap container", () => { + // A `min >= 1`, `whenEmptied: "unwrap"` container with no `default` used to + // build a schema-invalid node, so `node.check()` (run before the repair + // pass) threw a raw RangeError instead of inserting. + it("inserting an empty unwrap container does not throw", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); + + expect(() => + editor.insertBlocks([{ type: "grid" }] as any, "p-0", "after"), + ).not.toThrow(); + }); +}); + +describe("moveBlocks placement validation", () => { + // `checkPlacementIsValid` used to probe with `blockContainer`, so it accepted + // a placement inside a blocks-only container for a container block (e.g. a + // `callout`) that `insertBlocks` then rejected, throwing. It must validate + // against the moved block's real node type and skip the invalid placement. + it("moving a container block past a blocks-only container does not throw", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { + id: "box", + type: "blocksOnlyBox", + children: [{ id: "box-p", type: "paragraph", content: "Inside" }], + }, + { + id: "c-0", + type: "callout", + children: [{ id: "c-p", type: "paragraph", content: "Callout" }], + }, + ]); + + expect(() => editor.moveBlocksUp("c-0")).not.toThrow(); + // The callout can't nest in the blocks-only box, so it lands directly + // above it as a top-level sibling rather than being forced inside. + expect(editor.getParentBlock("c-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "p-0", + "c-0", + "box", + ]); + }); +}); 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..325e0622d2 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,299 @@ +import { Fragment, Slice, type Node } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; +import type { Schema } from "prosemirror-model"; + +import { + BLOCK_GROUP_CHILD_GROUP, + getChildrenConfig, + isContainerNode, + resolveChildren, +} from "../../../schema/blocks/children.js"; +import type { ResolvedChildren } from "../../../schema/blocks/children.js"; +import { seedRefillChildren } from "../../nodeConversions/blockToNode.js"; +import { getNodeById } from "../../nodeUtil.js"; + +// Defined in `children.ts` (it answers a schema-level question); re-exported +// here because the public root export (`index.ts`) imports it from this +// module. +export { isContainerNode }; + +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(BLOCK_GROUP_CHILD_GROUP) + ); +} + +type ContainerRepairTarget = { + blockPos: number; + blockNode: Node; +}; + +function getContainerRepairTarget( + doc: Node, + containerPos: number, +): ContainerRepairTarget | undefined { + const node = doc.resolve(containerPos).nodeAfter; + if (!node || !isContainerNode(node.type)) { + return undefined; + } + + return { blockPos: containerPos, blockNode: node }; +} + +/** + * The (possibly rebuilt) block at the repair target, with where its children + * now start. Recomputed after each mutation of `tr`. + */ +function refreshRepairTarget( + tr: Transaction, + target: ContainerRepairTarget, +): { children: Node; childrenStart: number } | undefined { + const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter; + if (!refreshedBlock || refreshedBlock.type !== target.blockNode.type) { + return undefined; + } + + return { children: refreshedBlock, childrenStart: target.blockPos + 1 }; +} + +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 = target.blockNode.type.spec.blockConfig; + const childrenConfig = blockConfig + ? getChildrenConfig(blockConfig) + : undefined; + const config = childrenConfig ? resolveChildren(childrenConfig) : undefined; + + if (!config) { + return; + } + + if (config.whenEmptied === "unwrap") { + unwrapContainer(tr, target, config); + } else { + // `blockConfig` is set whenever `config` is. + refillContainer(tr, target, config, blockConfig!.type); + } +} + +function unwrapContainer( + tr: Transaction, + target: ContainerRepairTarget, + config: ResolvedChildren, +) { + removeEmptyChildren(tr, target.blockPos); + + const refreshed = refreshRepairTarget(tr, target); + if (!refreshed) { + return; + } + const { children: refreshedChildren, childrenStart } = refreshed; + + const nonEmptyChildren: { child: Node; offset: number }[] = []; + refreshedChildren.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + nonEmptyChildren.push({ child, offset }); + } + }); + + if (nonEmptyChildren.length >= config.min) { + return; + } + + const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter!; + 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)); +} + +/** + * The `whenEmptied: "refill"` repair: when fewer than `min` non-empty + * children remain, drop the emptied ones and top the container back up. + * Position `k..min-1` (k = surviving count) is seeded from the container's + * `default`, falling back to `fillBefore`-style empty fill when `default` is + * absent. Deterministic, appended at the end. + * + * Rebuilt in a single replace: removing an empty child first would make + * ProseMirror's schema fitting instantly pad the container back to `min` with + * a fresh empty child, hiding the deficit from the seeding step. + */ +function refillContainer( + tr: Transaction, + target: ContainerRepairTarget, + config: ResolvedChildren, + blockType: string, +) { + const current = refreshRepairTarget(tr, target); + if (!current) { + return; + } + const { children, childrenStart } = current; + + const survivors: Node[] = []; + children.forEach((child) => { + if (!isEmptyContainerChild(child)) { + survivors.push(child); + } + }); + // At or above the minimum, empty children are left alone: they may be + // intentional. + if (survivors.length >= config.min) { + return; + } + + const seeds = seedRefillChildren( + blockType, + tr.doc.type.schema, + survivors.length, + config.min, + ); + + if (seeds.length === 0) { + // No `default` to seed from, so empty children are the right fill, and + // ProseMirror's schema fitting has usually already padded the container + // back to `min` with them. Complete the fill only when it hasn't. + const match = children.type.contentMatch.matchFragment(children.content); + const fill = match?.fillBefore(Fragment.empty, true); + if (fill && fill.size > 0) { + tr.insert(childrenStart + children.content.size, fill); + } + return; + } + + // Survivors keep their place; the seeds land at the end, replacing the + // emptied (or schema-padded) children. + let content = Fragment.from([...survivors, ...seeds]); + const match = children.type.contentMatch.matchFragment(content); + const fill = match?.fillBefore(Fragment.empty, true); + if (fill) { + content = content.append(fill); + } + + tr.replaceWith(childrenStart, childrenStart + children.content.size, content); +} + +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(BLOCK_GROUP_CHILD_GROUP) + ) { + 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/getBlock/getBlock.ts b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts index 1d87f58b49..9982402a4e 100644 --- a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts +++ b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts @@ -97,6 +97,9 @@ export function getParentBlock< const $posBeforeNode = doc.resolve(posInfo.posBeforeNode); const parentNode = $posBeforeNode.node(); const grandparentNode = $posBeforeNode.node(-1); + // A block's children live in its parent's `blockGroup` (regular nesting), + // in which case the actual parent block is the grandparent. A container + // holds its children directly, so its own node is the parent. const nodeToConvert = grandparentNode.type.name !== "doc" ? parentNode.type.name === "blockGroup" 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..e9942518b5 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -6,8 +6,11 @@ import { BlockImplementation, BlockSchema, InlineContentSchema, + isContainerType, StyleSchema, } from "../../../../schema/index.js"; +import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; +import { containerRootDOM } from "../../../../schema/blocks/createSpec.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -241,10 +244,15 @@ function serializeBlock< const elementFragment = doc.createDocumentFragment(); - if ((ret.dom as HTMLElement).classList.contains("bn-block-content")) { + // A fragment `dom` (the shape a React render produces) can't hold classes + // or attributes itself; its resolved root element (the single element it + // wraps, if any) stands in for it everywhere below. + const rootElement = containerRootDOM(ret); + + if (rootElement?.classList.contains("bn-block-content")) { const blockContentDataAttributes = [ ...attrs, - ...Array.from((ret.dom as HTMLElement).attributes), + ...Array.from(rootElement.attributes), ].filter( (attr) => attr.name.startsWith("data") && @@ -256,26 +264,42 @@ function serializeBlock< attr.name !== "data-editable", ); - // ret.dom = ret.dom.firstChild! as any; for (const attr of blockContentDataAttributes) { - (ret.dom.firstChild! as HTMLElement).setAttribute(attr.name, attr.value); + (rootElement.firstChild! as HTMLElement).setAttribute( + attr.name, + attr.value, + ); } - addAttributesAndRemoveClasses(ret.dom.firstChild! as HTMLElement); + addAttributesAndRemoveClasses(rootElement.firstChild! as HTMLElement); if (nestingLevel > 0) { - (ret.dom.firstChild! as HTMLElement).setAttribute( + (rootElement.firstChild! as HTMLElement).setAttribute( "data-nesting-level", nestingLevel.toString(), ); } - elementFragment.append(...Array.from(ret.dom.childNodes)); + // Discard the `bn-block-content` wrapper (and, for a fragment `dom`, the + // fragment around it) and keep only its children. + elementFragment.append(...Array.from(rootElement.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( + rootElement, + block.type!, + props, + editor.schema.blockSchema[block.type as any].propSchema, + ); + } elementFragment.append(ret.dom); if (nestingLevel > 0) { - (ret.dom as HTMLElement).setAttribute( - "data-nesting-level", - nestingLevel.toString(), - ); + rootElement?.setAttribute("data-nesting-level", nestingLevel.toString()); } } @@ -301,11 +325,9 @@ function serializeBlock< // tables) fill their `contentDOM` with child blocks later on, and code // blocks would turn the placeholder into literal content. const blockNodeType = editor.pmSchema.nodes[block.type as any]; - if ( - blockNodeType?.inlineContent && - !blockNodeType.spec.code && - ret.contentDOM.childNodes.length === 0 - ) { + const needsPlaceholder = + !!blockNodeType?.inlineContent && !blockNodeType.spec.code; + 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..9bc719c41c 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -5,8 +5,11 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { BlockSchema, InlineContentSchema, + isContainerType, StyleSchema, } from "../../../../schema/index.js"; +import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; +import { containerRootDOM } from "../../../../schema/blocks/createSpec.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -159,6 +162,9 @@ function serializeBlock< editor as any, ); + const blockConfig = editor.schema.blockSchema[block.type as any]; + const isContainer = isContainerType(blockConfig); + if (ret.contentDOM && block.content) { const ic = serializeInlineContentInternalHTML( editor, @@ -170,9 +176,26 @@ function serializeBlock< ret.contentDOM.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( + containerRootDOM(ret), + block.type!, + props, + blockConfig.propSchema, + ); - if (pmType.isInGroup("bnBlock")) { + // Mark where the children live so the container's round-trip parse rule + // can scope itself to this element (`contentElement` in `getParseRules`). + // A render is free to put non-content UI text elsewhere in its DOM + // (button labels, captions, ...), and without the marker that text would + // parse back as document content. + if (ret.contentDOM) { + ret.contentDOM.setAttribute("data-children-of", block.type!); + } if (block.children && block.children.length > 0) { const fragment = serializeBlocks( editor, diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 04ed789c98..f09627a1d0 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,6 +1,8 @@ import { Node, ResolvedPos } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; +import { CHILD_CONTAINER_GROUP } from "../schema/blocks/children.js"; + type SingleBlockInfo = { node: Node; beforePos: number; @@ -20,13 +22,17 @@ 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 +44,14 @@ export type BlockInfo = { */ blockContent: SingleBlockInfo; /** - * Whether bnBlock is a blockContainer node + * Whether `bnBlock` wraps the block's content in a node of its own: a + * `blockContainer` (an ordinary block wrapped for nesting), shaped as a + * content node followed by an optional child container. + * + * Note this is the opposite of "is a container block": a column has + * `isWrappedBlock: false`. */ - isBlockContainer: true; + isWrappedBlock: true; } ); @@ -185,45 +196,33 @@ export function getBlockInfoWithManualOffset( if (bnBlockNode.type.name === "blockContainer") { 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; + const beforePos = bnBlockBeforePos + offset + 1; + const afterPos = beforePos + 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; - - blockGroup = { - node: blockGroupNode, - beforePos: blockGroupBeforePos, - afterPos: blockGroupAfterPos, - }; + if (node.type.spec.group === "blockContent") { + 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, + childContainer, + // A `blockContainer` is a generic wrapper, so its type comes from the + // content node inside it. blockNoteType: blockContent.node.type.name, }; } else { @@ -235,7 +234,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/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts index 828894cf1d..2186fefe7d 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -652,8 +652,8 @@ describe("getBlocksChangedByTransaction - ranged optimization", () => { throw new Error("block not found"); } const info = getBlockInfo(posInfo); - if (!info.isBlockContainer) { - throw new Error("expected a block container"); + if (!info.isWrappedBlock) { + throw new Error("expected a wrapped block"); } // Adding a mark produces an AddMarkStep, whose StepMap is empty — the case // getChangedRange has to recover from the step's own from/to. diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index af5c0ba1b7..81f6937040 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,22 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +// `isContainerNode` comes from `children.js` directly (rather than via its +// `fixContainer.js` re-export) because `fixContainer.js` imports the seeding +// machinery below; going through it would create an import cycle. +import { + getChildrenConfig, + isContainerNode, + resolveChildren, +} from "../../schema/blocks/children.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 +353,163 @@ 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).whenEmptied === "unwrap"; +} + +// `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, + ), + ); +} + +/** + * The nodes `whenEmptied: "refill"` appends when a container's non-empty + * children drop below `min`: the unconsumed tail of its `default` + * (`default[from..min-1]`), each converted exactly like an inserted block. + * Empty when the container has no `default`; the caller pads any remainder + * with empty fill. + */ +export function seedRefillChildren( + blockType: string, + schema: Schema, + from: number, + min: number, +): Node[] { + const blockConfig = getBlockSchema(schema)[blockType]; + const children = blockConfig ? getChildrenConfig(blockConfig) : undefined; + const defaultChildren = children + ? resolveChildren(children).default + : undefined; + if (!defaultChildren) { + return []; + } + + return defaultChildren + .slice(from, min) + .map((child) => blockToNode(child as PartialBlock, schema)); +} + +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)) { + // Fill so the node satisfies its own content expression for the + // `node.check()` that runs before the repair pass (e.g. in + // `removeAndInsertBlocks`); that pass then unwraps the still-empty + // container. Without the fill, a `min >= 1` unwrap container with no + // `default` produces a schema-invalid node and `check()` throws. + return type.createAndFill(attrs) ?? 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; +} + +// Passes explicit children straight through for unwrap-on-empty containers +// (fill would be undone by the next repair pass) and for unfittable content +// (let `node.check()` report it). An empty child list is the exception: it +// still needs filling to survive the pre-repair `node.check()`. +function createExplicitChildrenNode( + blockType: string, + type: NodeType, + schema: Schema, + children: Node[], + attrs: Attrs | null = null, +): Node { + if (unwrapsWhenEmptied(blockType, schema)) { + // An empty explicit `children: []` would leave a `min >= 1` container + // schema-invalid and fail the pre-repair `node.check()`, so fill it (the + // repair pass unwraps it). Non-empty explicit children are left as given. + return children.length === 0 + ? (type.createAndFill(attrs) ?? type.create(attrs)) + : type.create(attrs, children); + } + + return type.createAndFill(attrs, children) ?? type.create(attrs, children); +} + /** * Converts a BlockNote block to a Prosemirror node. */ @@ -341,6 +517,7 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + seedingTypes: ReadonlySet = EMPTY_SEEDING, ) { let id = block.id; @@ -352,7 +529,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)); } } @@ -361,8 +538,6 @@ export function blockToNode( 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 contentNode = blockOrInlineContentToContentNode( block, schema, @@ -381,15 +556,25 @@ 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 (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/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..ddd3de46f2 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,60 +1,58 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + getChildrenConfig, + isContainerNode, + isPlaceableAnywhere, + resolveChildren, +} from "../../schema/blocks/children.js"; +import { getBlockSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; -/** - * Converts all Blocks within a fragment to BlockNote blocks. - */ +function isSelfContainedContainer(node: Node): boolean { + if (!isContainerNode(node.type)) { + return false; + } + const blockConfig = getBlockSchema(node.type.schema)[node.type.name] ?? {}; + const childrenConfig = getChildrenConfig(blockConfig); + if (!childrenConfig) { + return false; + } + return ( + isPlaceableAnywhere(blockConfig) && + node.childCount >= resolveChildren(childrenConfig).min + ); +} + 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) => { + if (isContainerNode(node.type) && !isSelfContainedContainer(node)) { + node.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..0037759daa 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,5 +1,6 @@ import { Mark, Node, Slice } from "@tiptap/pm/model"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { isContainerNode } from "../../schema/blocks/children.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { BlockSchema, @@ -430,7 +431,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 +453,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 +462,7 @@ export function nodeToBlock< styleSchema, ); } else if (blockConfig.content === "table") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { throw new Error("impossible"); } content = contentNodeToTableContent( @@ -470,7 +471,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 @@ -563,7 +564,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 +574,44 @@ export function prosemirrorSliceToSlicedBlocks< let blockCutAtEnd: string | undefined; node.forEach((blockContainer, _offset, index) => { + const isFirstBlock = index === 0; + const isLastBlock = index === node.childCount - 1; + + if (isContainerNode(blockContainer.type)) { + // A container child. When the slice boundary is open inside it, the + // selection covers part of its children, so 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; + + if (openAtStart || openAtEnd) { + const ret = processNode( + blockContainer, + openAtStart ? Math.max(0, openStart - 1) : 0, + openAtEnd ? Math.max(0, openEnd - 1) : 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 (blockContainer.type.name !== "blockContainer") { throw new Error("unexpected"); } @@ -583,9 +624,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..51317b699d 100644 --- a/packages/core/src/api/pmUtil.ts +++ b/packages/core/src/api/pmUtil.ts @@ -67,7 +67,9 @@ export function isPlainContentNodeType( schema: Schema, nodeType: NodeType, ): boolean { - if (getBlockSchema(schema)[nodeType.name]?.content === "plain") { + const blockSchema = getBlockSchema(schema); + + if (blockSchema[nodeType.name]?.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..5f0f34a746 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 check the initial document is + // never validated. A container below its `children.min` would reach + // the editor and stay there, while 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..90cb91e432 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.). The bnBlock node is the + // block itself, 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, }), 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..033df48484 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -118,7 +118,11 @@ export function transformPasted(slice: Slice, view: EditorView) { return retyped; } - if (isInTableCell(view)) { + // `tableParagraph` only exists in schemas with the default table blocks. A + // schema with a custom table implementation (e.g. container-block cells, + // which hold real blocks and need no inline conversion) skips this branch. + const tableParagraph = view.state.schema.nodes.tableParagraph; + if (tableParagraph && isInTableCell(view)) { let hasTableContent = false; f.descendants((node) => { if (node.type.isInGroup("tableContent")) { @@ -128,7 +132,7 @@ export function transformPasted(slice: Slice, view: EditorView) { if ( !hasTableContent && // is the content valid for a table paragraph? - !view.state.schema.nodes.tableParagraph.validContent(f) + !tableParagraph.validContent(f) ) { // if not, convert the content to inline content return new Slice( @@ -213,9 +217,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 +277,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..4430c5f399 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, so 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..94c8dfd1c4 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,12 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const containerUIInfo = getContainerUIInfo(this.editor); + const block = getBlockFromMousePos( + this.mousePos, + this.pmView, + containerUIInfo, + ); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -240,7 +273,14 @@ 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 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..afcbb8be59 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts @@ -0,0 +1,285 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; +import { + getContainerChildAtCursor, + getDirectChildBlocks, + hasHorizontalContainerAncestor, + isHorizontalContainer, + rectIndexAtCursor, + rectsAreSideBySide, + type BlockRect, +} from "./sideMenuContainerGeometry.js"; + +// The side-menu container geometry: the pure rect arithmetic, and the +// `querySelectorAll`/`closest` walks against live layout. A container whose +// children happen to sit side-by-side must be recognised as horizontal +// without declaring anything. +// +// The DOM trees are attached to the real document and laid out by the real +// engine; nothing stubs `getBoundingClientRect`. A column list inside a real +// editor is covered end-to-end by +// `tests/src/end-to-end/multicolumn/multicolumn.test.tsx`. + +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. +const STACKED = [rect(0, 40, 0, 200), rect(50, 90, 0, 200)]; + +describe("rectsAreSideBySide", () => { + it("is true when two rects overlap vertically, false when stacked", () => { + expect(rectsAreSideBySide(SIDE_BY_SIDE)).toBe(true); + expect(rectsAreSideBySide(STACKED)).toBe(false); + // Degenerate inputs are never a row. + expect(rectsAreSideBySide([rect(0, 100, 0, 100)])).toBe(false); + expect(rectsAreSideBySide([])).toBe(false); + }); + + it("treats abutting rects as stacked, but counts a one-pixel overlap", () => { + // 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); + expect( + rectsAreSideBySide([rect(0, 41, 0, 100), rect(40, 80, 0, 100)]), + ).toBe(true); + }); + + 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); + }); +}); + +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. The + // vertical-only fallback recorded for the first must not win over an x + // match found later in the list; otherwise hovering the second column of + // a row would resolve to its neighbour. + expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 150, y: 50 })).toBe(1); + 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 misses every rect vertically", () => { + expect(rectIndexAtCursor(STACKED, { x: 10, y: 999 })).toBeUndefined(); + expect(rectIndexAtCursor(STACKED, { x: 10, y: -999 })).toBeUndefined(); + 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); + }); +}); + +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 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, + ]); + }); +}); + +describe("isHorizontalContainer", () => { + it("recognises a real flex row as horizontal", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + + // Nothing declares the column list horizontal and no rect is stubbed; + // the detection runs against real layout. + expect(isHorizontalContainer(columnList, info)).toBe(true); + + // Also asserted as raw geometry, so a failure shows whether the layout + // or the detection 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 } = buildVerticalContainer(); + + expect(isHorizontalContainer(callout, info)).toBe(false); + }); + + 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, so 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, + ); + }); +}); + +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); + }); +}); 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..3c4cac4442 --- /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 itself, 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..7423faf9fa 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, as 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..31734d0096 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,6 +1,6 @@ -import { Extension } from "@tiptap/core"; +import { CommandProps, Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { NodeSelection, TextSelection, Transaction } from "prosemirror-state"; import { getBottomNestedBlockInfo, @@ -14,7 +14,17 @@ import { 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 { isSealed } from "../../../schema/blocks/children.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { @@ -25,6 +35,70 @@ import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; +// Moves `node` out of its container to `insertAt` (a position in the pre-delete +// doc): deletes it from `[from, to]`, re-inserts it, repairs the containers it +// left behind (their `whenEmptied` min/unwrap/refill), and places the caret +// inside the moved block. Every position is mapped through the deletion and the +// repair, so the caret lands correctly even when the repair rewrites the source +// container. Shared by the Backspace/Delete/Enter container-boundary branches. +function moveBlockOutAndPlaceCaret( + tr: Transaction, + { + from, + to, + node, + insertAt, + }: { from: number; to: number; node: Node; insertAt: number }, +) { + const containersToFix = getAncestorContainers(tr.doc, from); + tr.delete(from, to); + const insertionPos = tr.mapping.map(insertAt); + tr.insert(insertionPos, node); + const stepsBeforeFix = tr.steps.length; + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near( + tr.doc.resolve(tr.mapping.slice(stepsBeforeFix).map(insertionPos) + 1), + ), + ); +} + +// If the sibling in `direction` of the current block is a sealed container, +// selects it (a NodeSelection) instead of merging across its sealed boundary, +// so a second Backspace/Delete can delete the container explicitly. Returns a +// command: `false` when nothing to select, `true` once handled. Shared by the +// Backspace (prev) and Delete (next) boundary branches. +function selectSealedSiblingCommand(direction: "prev" | "next") { + return ({ state, tr, dispatch }: CommandProps) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isWrappedBlock) { + return false; + } + + const atEdge = + direction === "prev" + ? state.selection.from === blockInfo.blockContent.beforePos + 1 + : state.selection.from === blockInfo.blockContent.afterPos - 1; + if (!atEdge || !state.selection.empty) { + return false; + } + + const sibling = ( + direction === "prev" ? getPrevBlockInfo : getNextBlockInfo + )(state.doc, blockInfo.bnBlock.beforePos); + if (!sibling || !isSealed(sibling.bnBlock.node)) { + return false; + } + + if (dispatch && NodeSelection.isSelectable(sibling.bnBlock.node)) { + tr.setSelection( + NodeSelection.create(tr.doc, sibling.bnBlock.beforePos), + ).scrollIntoView(); + } + return true; + }; +} + export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; tabBehavior: "prefer-navigate-ui" | "prefer-indent"; @@ -45,7 +119,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -69,7 +143,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; @@ -87,12 +161,15 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the previous sibling is a sealed container, selects it instead + // of merging into it. + () => commands.command(selectSealedSiblingCommand("prev")), // Merges block with the previous one if it isn't indented, and the selection is at the start of the // block. The target block for merging must contain inline content. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -106,7 +183,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 +204,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 +225,54 @@ 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"], + { respectSealed: true }, + ); + if (insertionPos === null) { + // When only a sealed boundary blocked the descent, the + // container can't be entered, so it's selected instead, and a + // second Backspace deletes it explicitly. A container with + // nowhere a `blockContainer` can land falls through as before. + // (The probe descends without `respectSealed`, i.e. through + // seals.) + const blockedBySeal = + descendToLastInsertionPos( + prevBlockInfo.bnBlock.node, + prevBlockInfo.bnBlock.beforePos, + state.schema.nodes["blockContainer"], + ) !== null; + if ( + blockedBySeal && + NodeSelection.isSelectable(prevBlockInfo.bnBlock.node) + ) { + if (dispatch) { + tr.setSelection( + NodeSelection.create( + tr.doc, + prevBlockInfo.bnBlock.beforePos, + ), + ).scrollIntoView(); + } + return true; + } + 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 +280,15 @@ 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 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 +306,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(); - - if (dispatch) { - tr.delete( - blockInfo.bnBlock.beforePos, - blockInfo.bnBlock.afterPos, - ); - fixColumnList(tr, columnListPos); + // A sealed container swallows Backspace at its first block: + // moving the block out would cross the boundary. + if (isSealed(parentBlock)) { + return true; + } - 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)), + 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, + { respectSealed: true }, + ) + : ascendToInsertablePos( + tr.doc, + containerBeforePos, + blockContainerType, + { respectSealed: true }, ); - } + if (insertionPos === null) { + return false; + } + + if (dispatch) { + moveBlockOutAndPlaceCaret(tr, { + from: blockInfo.bnBlock.beforePos, + to: blockInfo.bnBlock.afterPos, + node: blockInfo.bnBlock.node, + insertAt: insertionPos, + }); } return true; @@ -227,7 +364,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -247,13 +384,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, prevBlockInfo, ); - if (!bottomNestedPrevBlockInfo.isBlockContainer) { - return false; - } - if ( - !bottomNestedPrevBlockInfo || - !bottomNestedPrevBlockInfo.isBlockContainer - ) { + if (!bottomNestedPrevBlockInfo.isWrappedBlock) { return false; } @@ -313,7 +444,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -327,12 +458,21 @@ export const KeyboardShortcutsExtension = Extension.create<{ ); if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) { + // The sealed-aware descent stops at a sealed container instead + // of finding an (empty) block inside it, so the current block + // is never cut in across the boundary. const bottomBlock = getBottomNestedBlockInfo( state.doc, prevBlockInfo, + { stopAtSealed: true }, ); - if (!bottomBlock.isBlockContainer) { + if (!bottomBlock.isWrappedBlock) { + return false; + } + // A sealed content container also stops the descent; deleting + // it here would take its children with it. + if (isSealed(bottomBlock.bnBlock.node)) { return false; } @@ -375,11 +515,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 +533,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ const firstChildBlockInfo = getBlockInfoFromResolvedPos( state.doc.resolve(childContainer.beforePos + 1), ); - if (!firstChildBlockInfo.isBlockContainer) { + if (!firstChildBlockInfo.isWrappedBlock) { return false; } @@ -408,7 +554,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ Fragment.empty, ) .deleteRange( - // Deletes whole child container if there's only one child. + // Deletes whole child container if there's only one + // child. childContainer.node.childCount === 1 ? { from: childContainer.beforePos, @@ -434,13 +581,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the next sibling is a sealed container, selects it instead of + // merging it in. Delete counterpart of the sealed-previous-sibling + // Backspace case. + () => commands.command(selectSealedSiblingCommand("next")), // Merges block with the next one (at the same nesting level or lower), // if one exists, the block has no children, and the selection is at the // end of the block. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; @@ -449,7 +600,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -468,12 +619,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,36 +638,40 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || nextBlockInfo.isWrappedBlock) { return false; } - if (dispatch) { - const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1; - const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); + const firstLeaf = getFirstLeafBlock( + nextBlockInfo.bnBlock.node, + nextBlockInfo.bnBlock.beforePos, + { respectSealed: true }, + ); + if (!firstLeaf) { + return false; + } - tr.delete( - $blockBeforePos.pos, - $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, - ); - fixColumnList(tr, nextBlockInfo.bnBlock.beforePos); - tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!); - tr.setSelection( - TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), - ); + if (dispatch) { + moveBlockOutAndPlaceCaret(tr, { + from: firstLeaf.beforePos, + to: firstLeaf.beforePos + firstLeaf.node.nodeSize, + node: firstLeaf.node, + insertAt: blockInfo.bnBlock.afterPos, + }); return true; } 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 to after it. The next block is the + // first leaf of the next sibling container, or the block following + // the enclosing containers. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -534,37 +689,49 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // 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) + ) { + // Pulling a block in from past a sealed boundary would cross + // it, so the keystroke is swallowed instead. + if (isSealed($boundary.node())) { + return true; + } + $boundary = tr.doc.resolve($boundary.after()); + } + + const nextNode = $boundary.nodeAfter; + if (!nextNode) { return false; } - const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos); - const $columnEndPos = tr.doc.resolve($blockEndPos.after()); - const columnListEndPos = $columnEndPos.after(); + // The block to pull in: the next node itself, or its first leaf + // block when it's a container. + const target = isContainerNode(nextNode.type) + ? getFirstLeafBlock(nextNode, $boundary.pos, { + respectSealed: true, + }) + : { 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), - ); - - tr.delete( - nextBlockInfo.bnBlock.beforePos, - nextBlockInfo.bnBlock.afterPos, - ); - fixColumnList( - tr, - columnListEndPos - $columnEndPos.node().nodeSize, - ); - tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), - ); + moveBlockOutAndPlaceCaret(tr, { + from: target.beforePos, + to: target.beforePos + target.node.nodeSize, + node: target.node, + insertAt: blockInfo.bnBlock.afterPos, + }); } return true; @@ -577,7 +744,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } const { blockContent } = blockInfo; @@ -597,7 +764,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlockInfo = getParentBlockInfo(doc, beforePos); - if (!parentBlockInfo) { + if ( + !parentBlockInfo || + // Never climbs past a sealed boundary. A block found + // there would be pulled in across it. + isSealed(parentBlockInfo.bnBlock.node) + ) { return undefined; } @@ -611,7 +783,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -653,7 +825,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -666,7 +838,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.doc, blockInfo.bnBlock.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { return false; } @@ -715,7 +887,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.isWrappedBlock) { return false; } @@ -730,7 +902,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!nextBlockInfo) { return false; } - if (!nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo.isWrappedBlock) { return false; } @@ -770,7 +942,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 +1031,72 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the block is empty and the last child of a non-sealed container, + // moves the block out (double Enter exits the container). The block + // lands at the nearest enclosing position that accepts it. E.g. out + // of a column it skips the columnList, which holds only columns, and + // lands below it. Without this, Enter only ever creates new blocks + // within the container, so the cursor could never leave a trailing + // container. Shift+Enter still adds spacing inside a container. + () => + 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; + } + + // A sealed boundary means Enter never moves content out. + if (isSealed(parentBlock)) { + return false; + } + + const containerAfterPos = ascendToInsertablePos( + tr.doc, + $pos.after(), + state.schema.nodes["blockContainer"], + { respectSealed: true }, + "after", + ); + if (containerAfterPos === null) { + return false; + } + + if (dispatch) { + moveBlockOutAndPlaceCaret(tr, { + from: blockInfo.bnBlock.beforePos, + to: blockInfo.bnBlock.afterPos, + node: blockInfo.bnBlock.node, + insertAt: containerAfterPos, + }); + 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 +1152,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..240b0c762a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,11 @@ 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 is on `@blocknote/core/internal`: +// repair, navigation, UI info, the node groups and the generated node names. +// `isContainerNode` stays here: it answers a schema-level question ("is this +// node type a container?") that integrations legitimately ask. It is defined +// in `children.ts` and re-exported via `fixContainer.ts`. +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..8a50e9f2f3 --- /dev/null +++ b/packages/core/src/internal.ts @@ -0,0 +1,65 @@ +/** + * `@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 use 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`). + */ + +// How a `children` config compiles to a ProseMirror content expression, and +// the node groups derived from it. +export { + ANY_CONTAINER_GROUP, + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + CONTAINER_NODE_PRIORITY, + childrenContentExpression, + containerNodePriority, + getChildrenConfig, + isPlaceableAnywhere, + resolveChildren, +} from "./schema/blocks/children.js"; + +// Validation of `children` configs, run when a schema is built. +export { + 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, +} 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..3fc2263159 --- /dev/null +++ b/packages/core/src/schema/blocks/assertSchemaInvariants.ts @@ -0,0 +1,98 @@ +import { Fragment, type Schema } from "prosemirror-model"; + +import { + ANY_CONTAINER_GROUP, + getChildrenConfig, + isContainerNode, + isPlaceableAnywhere, +} from "./children.js"; + +/** + * Checks the structural properties the rest of the container machinery + * assumes, once, when the ProseMirror schema is built. + * + * Each property is otherwise guaranteed only by a chain of implicit reasoning + * spread across several files. Asserting them here turns silent breakage into + * a startup error naming the cause. + */ +export function assertContainerSchemaInvariants(pmSchema: Schema) { + assertBlockGroupFillsWithBlockContainer(pmSchema); + assertContainersAreFillable(pmSchema); + assertAnyContainerGroupMatchesConfigs(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. Every link in that chain is implicit, 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).", + ); + } +} + +/** + * The `anyContainer` group must contain exactly the container blocks + * placeable anywhere. It is what the `allow` container wildcards (`"any"`, + * `"containers"`) compile to. Generated nodes always get this right; a + * hand-written container node that forgets the group would silently drop out + * of every wildcard `allow`, so the mismatch is reported here instead. + */ +function assertAnyContainerGroupMatchesConfigs(pmSchema: Schema) { + for (const type of Object.values(pmSchema.nodes)) { + const blockConfig = type.spec.blockConfig; + if (!blockConfig || blockConfig.type !== type.name) { + continue; + } + + const shouldBeInGroup = + getChildrenConfig(blockConfig) !== undefined && + isPlaceableAnywhere(blockConfig); + if (shouldBeInGroup !== type.isInGroup(ANY_CONTAINER_GROUP)) { + throw new Error( + shouldBeInGroup + ? `BlockNote schema invariant broken: container block "${type.name}" is placeable anywhere but its node is not in the "${ANY_CONTAINER_GROUP}" group, ` + + `so wildcard \`allow\` containers would not accept it. A hand-written container node must include the group itself.` + : `BlockNote schema invariant broken: node "${type.name}" is in the "${ANY_CONTAINER_GROUP}" group but its block config does not make it a container placeable anywhere.`, + ); + } + } +} + +/** + * Every container must be creatable empty, or inserting one throws a raw + * ProseMirror error at the call site instead of here. + * + * This asks ProseMirror directly rather than re-deriving the answer from the + * config, so it catches combinations a hand-written check would miss. + * `whenEmptied: "refill"`'s empty-fill fallback uses the same `fillBefore`, + * so this also guarantees that a refill repair can always complete. + */ +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.", + ); + } + } +} 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..321283d966 --- /dev/null +++ b/packages/core/src/schema/blocks/children.test.ts @@ -0,0 +1,271 @@ +// @vitest-environment node +import { describe, expect, it } from "vite-plus/test"; + +import { childrenContentExpression, resolveChildren } from "./children.js"; +import type { ChildrenConfig } from "./types.js"; +import { validateChildrenConfigs } from "./validateChildren.js"; + +// All enforcement happens through the content expression. If this table is +// right, `allow`/`min`/`max` are enforced by ProseMirror itself. +const CASES: [string, ChildrenConfig, string][] = [ + [ + "any block, at least one (the minimal config)", + { allow: "any" }, + "blockGroupChild+", + ], + ["any block, possibly none", { allow: "any", min: 0 }, "blockGroupChild*"], + [ + "any block, exactly one", + { allow: "any", min: 1, max: 1 }, + "blockGroupChild", + ], + ["any block, two or more", { allow: "any", min: 2 }, "blockGroupChild{2,}"], + [ + "any block, two to four", + { allow: "any", min: 2, max: 4 }, + "blockGroupChild{2,4}", + ], + [ + "any block, at most one", + { allow: "any", min: 0, max: 1 }, + "blockGroupChild?", + ], + [ + "any block, exactly three", + { allow: "any", min: 3, max: 3 }, + "blockGroupChild{3}", + ], + ["regular blocks only", { allow: "blocks" }, "blockContainer+"], + ["one container type only", { allow: ["column"], min: 2 }, "column{2,}"], + [ + "several container types", + { allow: ["column", "card"] }, + "(column | card)+", + ], + [ + "any container but no regular blocks", + { allow: "containers" }, + "anyContainer+", + ], +]; + +describe("childrenContentExpression", () => { + it.each(CASES)("%s", (_name, config, expected) => { + expect(childrenContentExpression(config)).toBe(expected); + }); +}); + +describe("resolveChildren", () => { + // The four `allow` forms and what they desugar to. The compiled expressions + // above are a direct function of this table. + it.each([ + ["any", { blocks: true, containers: true }], + ["blocks", { blocks: true, containers: [] }], + ["containers", { blocks: false, containers: true }], + [["column"], { blocks: false, containers: ["column"] }], + ] as const)("resolves allow %j", (allow, expected) => { + expect(resolveChildren({ allow })).toMatchObject(expected); + }); + + it("applies the defaults: min 1, unbounded, refill, isolated", () => { + const resolved = resolveChildren({ allow: "any" }); + expect(resolved.min).toBe(1); + expect(resolved.max).toBeUndefined(); + expect(resolved.whenEmptied).toBe("refill"); + expect(resolved.boundary).toBe("isolated"); + }); + + it("returns the same object for the same config, without mutating it", () => { + // 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 = { allow: "any", min: 1 }; + expect(resolveChildren(config)).toBe(resolveChildren(config)); + expect(config).toEqual({ allow: "any", min: 1 }); + }); +}); + +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 valid shapes: minimal and columnList-style", () => { + expect(validate({ callout: { children: { allow: "any" } } })).not.toThrow(); + expect( + validate({ + grid: { children: { allow: ["gridCell"], min: 2 } }, + gridCell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).not.toThrow(); + }); + + // Malformed configs, each rejected with a specific message (JS consumers + // don't get the type errors TS consumers do). `allow: ["heading"]` used to + // silently compile to "any regular block", so naming a regular block is a + // hard error until per-type filtering is supported. + it.each<[string, ContainerFixture["children"], RegExp]>([ + ["missing `allow`", {} as unknown as ChildrenConfig, /`allow` is required/], + [ + "unknown `allow` form", + { allow: "everything" } as unknown as ChildrenConfig, + /`allow` must be/, + ], + ["unknown type in allow array", { allow: ["nope"] }, /nope/], + [ + "regular block type in allow array", + { allow: ["heading"] }, + /not yet supported/, + ], + ["allow that permits nothing", { allow: [] }, /permits nothing/], + [ + "containers wildcard with no other containers", + { allow: "containers" }, + /no other container block types/, + ], + ["negative minimum", { allow: "any", min: -1 }, /non-negative integer/], + [ + "maximum smaller than minimum", + { allow: "any", min: 3, max: 2 }, + /greater than or equal/, + ], + [ + "unknown boundary value", + { allow: "any", boundary: "shut" } as unknown as ChildrenConfig, + /`boundary` must be "open", "isolated" or "sealed"/, + ], + [ + "`default` violating the child count", + { allow: "any", min: 2, default: [{ type: "paragraph" }] }, + /fewer than the 2 required/, + ], + ])("rejects %s", (_name, children, message) => { + expect(validate({ box: { children } })).toThrow(message); + }); + + it("rejects `default` containing a block that isn't permitted", () => { + expect( + validate({ + grid: { + children: { + allow: ["gridCell"], + min: 2, + default: [{ type: "paragraph" }, { type: "paragraph" }], + }, + }, + gridCell: { + children: { allow: "any" }, + placement: "containerOnly", + }, + }), + ).toThrow(/not permitted/); + }); + + // The wildcards compile to the containers placeable anywhere, so a + // containerOnly block only fits where a parent names it explicitly. Every + // configuration that would leave one unreachable, or in an unsatisfiable + // `default`, is rejected up front. + it("rejects containerOnly blocks that nothing can hold", () => { + // In a wildcard `default`, which would build an unsatisfiable node: + expect( + validate({ + box: { children: { allow: "any", default: [{ type: "cell" }] } }, + cell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).toThrow(/not permitted/); + // Unreachable, even though a wildcard container exists: + expect( + validate({ + box: { children: { allow: "any" } }, + cell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).toThrow(/could never be inserted/); + // Unreachable, because no container's allow list names it: + expect( + validate({ + grid: { children: { allow: ["gridCell"], min: 2 } }, + gridCell: { + children: { allow: "blocks" }, + placement: "containerOnly", + }, + orphan: { + children: { allow: "blocks" }, + placement: "containerOnly", + }, + }), + ).toThrow(/could never be inserted/); + // A `containers` wildcard needs at least one placeable-anywhere one: + expect( + validate({ + box: { children: { allow: "containers" } }, + cell: { children: { allow: "any" }, placement: "containerOnly" }, + }), + ).toThrow(/placeable anywhere/); + }); + + 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/); + }); + + // A container block's body is its children; combining `children` with any + // content of the block's own is not supported. + it.each(["inline", "plain", "table"] as const)( + 'rejects `children` combined with `content: "%s"`', + (content) => { + expect(() => + validateChildrenConfigs({ + bad: { type: "bad", content, children: { allow: "any" } }, + }), + ).toThrow(/`children` can only be combined with `content: "none"`/); + }, + ); + + // `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. A mutual reference is fine as soon as one side can be filled with + // a paragraph instead. + it("rejects a container cycle but accepts a breakable mutual reference", () => { + expect( + validate({ + card: { children: { allow: ["cardBody"] } }, + cardBody: { + children: { allow: ["card"] }, + placement: "containerOnly", + }, + }), + ).toThrow(/requires it back/); + expect( + validate({ + card: { children: { allow: ["cardBody"] } }, + cardBody: { + children: { allow: "any" }, + placement: "containerOnly", + }, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts new file mode 100644 index 0000000000..3f67820b15 --- /dev/null +++ b/packages/core/src/schema/blocks/children.ts @@ -0,0 +1,183 @@ +import type { Node, NodeType } from "prosemirror-model"; + +import type { + BlockConfig, + ChildrenAllow, + ChildrenConfig, + PartialBlockNoDefaults, +} from "./types.js"; + +/** A {@link ChildrenConfig} with every default filled in. */ +export type ResolvedChildren = { + blocks: boolean; + /** `true` for any container type; a (possibly empty) list otherwise. */ + containers: true | readonly string[]; + /** What `whenEmptied` compares against. */ + min: number; + max: number | undefined; + default: readonly PartialBlockNoDefaults[] | undefined; + whenEmptied: "refill" | "unwrap"; + boundary: "open" | "isolated" | "sealed"; +}; + +export const CHILD_CONTAINER_GROUP = "childContainer"; + +export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; + +// Joined by every container block placeable anywhere (`placement` other than +// `"containerOnly"`). It's what the `allow` container wildcards (`"any"`, +// `"containers"`) compile to: a containerOnly type only ever lives where a +// container names it explicitly, so it stays out of the group. +export const ANY_CONTAINER_GROUP = "anyContainer"; + +// Whether `type` is a node that holds child blocks directly: a container +// block's own node. (`blockGroup` is in the group too but is regular-block +// nesting machinery, not a container.) +export function isContainerNode(type: NodeType): boolean { + return type.isInGroup(CHILD_CONTAINER_GROUP) && type.name !== "blockGroup"; +} + +// 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 resolved: ResolvedChildren = { + ...resolveAllow(children.allow), + min: children.min ?? 1, + max: children.max, + default: children.default, + whenEmptied: children.whenEmptied ?? "refill", + boundary: children.boundary ?? "isolated", + }; + + resolvedCache.set(children, resolved); + return resolved; +} + +function resolveAllow( + allow: ChildrenAllow, +): Pick { + if (allow === "any") { + return { blocks: true, containers: true }; + } + if (allow === "blocks") { + return { blocks: true, containers: [] }; + } + if (allow === "containers") { + return { blocks: false, containers: true }; + } + return { blocks: false, containers: allow }; +} + +/** + * Whether `node` belongs to a container with a `"sealed"` boundary, one whose + * edge content may never implicitly cross (a table cell rather than a column). + * Reads the block config off the node's spec. + */ +export function isSealed(node: Node): boolean { + const children = getChildrenConfig(node.type.spec.blockConfig ?? {}); + return ( + children !== undefined && resolveChildren(children).boundary === "sealed" + ); +} + +export function childrenContentExpression(children: ChildrenConfig): string { + const resolved = resolveChildren(children); + return allowTerm(resolved) + quantifier(resolved.min, resolved.max); +} + +function allowTerm(resolved: ResolvedChildren): 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 (resolved.blocks && resolved.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 (resolved.blocks) { + terms.push("blockContainer"); + } + // The wildcard is the `anyContainer` group, not `childContainer`. The + // latter also contains `blockGroup`, which is not a block. + if (resolved.containers === true) { + terms.push(ANY_CONTAINER_GROUP); + } else { + terms.push(...resolved.containers); + } + + if (terms.length === 0) { + // Validation rejects this first; this is a bug-guard, not a user-facing + // error path. + throw new Error( + "Container `allow` permits 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..ee01f9cc09 --- /dev/null +++ b/packages/core/src/schema/blocks/containerAttributes.ts @@ -0,0 +1,81 @@ +import { camelToDataKebab } from "../../util/string.js"; +import { PropSchema, Props } from "../propTypes.js"; + +function getContainerAttributes( + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id: string | undefined, +): Record { + const attributes: Record = {}; + + for (const [prop, value] of Object.entries(blockProps)) { + if (value === undefined || value === propSchema[prop]?.default) { + continue; + } + attributes[camelToDataKebab(prop)] = `${value}`; + } + + // Emit the reserved markers after the prop loop so a prop whose name + // kebab-cases to `data-node-type` or `data-id` can't overwrite them. + attributes["data-node-type"] = blockType; + if (id) { + attributes["data-id"] = id; + } + + return attributes; +} + +export function applyContainerAttributes( + element: HTMLElement | undefined | null, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id: string | undefined, +) { + if (!element) { + 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( + element: HTMLElement | undefined | null, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, +) { + if (!element) { + return; + } + + const attributes = getContainerAttributes( + blockType, + blockProps, + propSchema, + undefined, + ); + + for (const [attr, value] of Object.entries(attributes)) { + if (!element.hasAttribute(attr)) { + element.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..74b24d1616 --- /dev/null +++ b/packages/core/src/schema/blocks/containerParse.browser.test.ts @@ -0,0 +1,327 @@ +import { Fragment } from "prosemirror-model"; +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. Parsing HTML is the capability under test, so the 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: { allow: "any" }, + }, + { + 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: { allow: "any" }, + }, + { + 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 pure container whose render puts non-content UI text next to the children +// host, the table-with-controls shape. That text must never round-trip into +// document content. +const Widget = createBlockSpec( + { + type: "widget" as const, + propSchema: {}, + content: "none", + children: { allow: "any" }, + }, + { + render: () => { + const dom = document.createElement("div"); + const contentDOM = document.createElement("div"); + const controls = document.createElement("div"); + controls.contentEditable = "false"; + controls.textContent = "UI LABEL"; + dom.append(contentDOM, controls); + return { dom, contentDOM }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + card: Card, + quote: Quote, + widget: Widget, + } 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: {} }, + ]); + }); +}); + +describe("container HTML round-trip", () => { + // Regression: internal HTML renders the block's full DOM, so a render with + // non-content UI text next to the children host (control buttons, labels) + // used to leak that text into the document as extra blocks on re-parse. + // The serializer marks the children host with `data-children-of` and the + // round-trip rule scopes itself to it. + it("excludes a render's non-content UI text from a pure container's round-trip", () => { + editor.replaceBlocks(editor.document, [ + { + id: "w-0", + type: "widget" as const, + children: [ + { id: "w-p-0", type: "paragraph" as const, content: "Inside" }, + ], + }, + ]); + + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain('data-children-of="widget"'); + expect(html).toContain("UI LABEL"); + + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("widget"); + expect( + (parsed[0] as any).children.map((child: any) => [ + child.type, + child.content?.[0]?.text, + ]), + ).toEqual([["paragraph", "Inside"]]); + expect(JSON.stringify(parsed)).not.toContain("UI LABEL"); + }); +}); + +describe("container fragment root", () => { + // A container whose render returns a `DocumentFragment` wrapping a single + // element and no `rootDOM`, the shape a React render produces. The fragment + // itself can't hold attributes, so `containerRootDOM` must resolve to the + // wrapped element for the round-trip markers (`data-node-type`, prop + // `data-*`) to survive serialization. + const Panel = createBlockSpec( + { + type: "panel" as const, + propSchema: { tone: { default: "neutral" } }, + content: "none", + children: { allow: "any" }, + }, + { + render: () => { + const root = document.createElement("div"); + const fragment = document.createDocumentFragment(); + fragment.append(root); + return { dom: fragment, contentDOM: root }; + }, + }, + )(); + + const panelBlocks = [ + { + id: "pn-0", + type: "panel" as const, + props: { tone: "warning" }, + children: [{ id: "pn-p-0", type: "paragraph" as const, content: "Body" }], + }, + ]; + + const expectRoundTripped = (parsed: any[]) => { + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("panel"); + expect(parsed[0].props.tone).toBe("warning"); + expect( + parsed[0].children.map((child: any) => [ + child.type, + child.content?.[0]?.text, + ]), + ).toEqual([["paragraph", "Body"]]); + }; + + // Headless: a fragment `dom` is only valid for serialization renders, not + // as a mounted node view's root, which ProseMirror requires to be an + // element. + const makeEditor = () => + BlockNoteEditor.create({ + schema: BlockNoteSchema.create().extend({ + blockSpecs: { ...defaultBlockSpecs, panel: Panel } as const, + }), + }) as BlockNoteEditor; + + it("round-trips a fragment-rendered container through full HTML", () => { + const other = makeEditor(); + try { + other.replaceBlocks(other.document, panelBlocks); + + const html = other.blocksToFullHTML(other.document); + expect(html).toContain('data-node-type="panel"'); + expect(html).toContain('data-tone="warning"'); + + expectRoundTripped(other.tryParseHTMLToBlocks(html)); + } finally { + other._tiptapEditor.destroy(); + } + }); + + it("round-trips a fragment-rendered container through external HTML", () => { + const other = makeEditor(); + try { + other.replaceBlocks(other.document, panelBlocks); + + const html = other.blocksToHTMLLossy(other.document); + expect(html).toContain('data-node-type="panel"'); + expect(html).toContain('data-tone="warning"'); + + expectRoundTripped(other.tryParseHTMLToBlocks(html)); + } finally { + other._tiptapEditor.destroy(); + } + }); +}); + +describe("container `runsBefore`", () => { + const ambiguous = (type: string) => + createBlockSpec( + { + type, + propSchema: {}, + content: "none", + children: { allow: "any" }, + } 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("orders a container's parse rules before another container's", () => { + // Declaration order wins by default; `runsBefore` overrides it. + for (const [runsBefore, winner] of [ + [undefined, "alpha"], + [["alpha"], "beta"], + ] as const) { + const other = makeEditor(runsBefore ? [...runsBefore] : undefined); + try { + expect( + other.tryParseHTMLToBlocks('

x

')[0] + .type, + ).toBe(winner); + } 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..a2784278e7 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,21 @@ 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 { + ANY_CONTAINER_GROUP, + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + childrenContentExpression, + containerNodePriority, + getChildrenConfig, + isPlaceableAnywhere, + resolveChildren, +} from "./children.js"; +import { applyContainerAttributes } from "./containerAttributes.js"; +import { + applyDOMAttributes, getBlockFromNodeView, propsToAttributes, wrapInBlockStructure, @@ -45,9 +60,61 @@ 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. +function toContainerChildren(fragment: Fragment, schema: PMSchema): Fragment { + const out: PMNode[] = []; + let inlineRun: PMNode[] = []; + + const flush = () => { + if (inlineRun.length === 0) { + return; + } + out.push(schema.nodes["paragraph"].create(null, inlineRun)); + inlineRun = []; + }; + + fragment.forEach((child) => { + if (child.isInline) { + inlineRun.push(child); + return; + } + flush(); + out.push(child); + }); + flush(); + + return Fragment.fromArray(out); +} + +// Finds the element holding a serialized container block's children, marked +// `data-children-of` by the internal HTML serializer. Returns undefined when +// no marker belonging to *this* block (rather than a same-typed nested +// container) is present. +function findContainerContentElement( + el: HTMLElement, + config: { type: string }, +): HTMLElement | undefined { + const selector = `[data-children-of="${config.type}"]`; + + // The block's root may itself be the children host (a render that passes + // its own root to `contentRef`). `querySelectorAll` only sees descendants. + if (el.matches(selector)) { + return el; + } + + for (const host of el.querySelectorAll(selector)) { + // Skip hosts of same-typed *nested* containers: this block's own host is + // the one with no other container root between it and `el`. + if (host.parentElement?.closest("[data-node-type]") === el) { + return host; + } + } + + return undefined; +} + +// Creates `parseHTML` rules for clipboard parsing. export function getParseRules< TName extends string, TProps extends PropSchema, @@ -55,12 +122,28 @@ 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}]`, + // Scope the round-trip parse to the block's content region, so text + // the render puts elsewhere in its DOM (button labels, captions, + // ...) doesn't parse back as document content. The internal HTML + // serializer marks the region with `data-children-of`; HTML without + // the marker (older or hand-written) falls back to the whole + // element, the previous behavior. + contentElement: (el) => + findContainerContentElement(el as HTMLElement, config) ?? + (el as HTMLElement), + } + : { + tag: "[data-content-type=" + config.type + "]", + contentElement: ".bn-inline-content", + }, ]; if (implementation.parse) { @@ -81,10 +164,24 @@ 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, + ) + : undefined + : config.content === "inline" || + config.content === "none" || + config.content === "plain" ? (node, schema) => { if (implementation.parseContent) { const result = implementation.parseContent({ @@ -167,137 +264,324 @@ 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, + priority?: number, +) { + const children = getChildrenConfig(blockConfig)!; + + const groups = ["bnBlock", CHILD_CONTAINER_GROUP]; + if (isPlaceableAnywhere(blockConfig)) { + groups.push(BLOCK_GROUP_CHILD_GROUP, ANY_CONTAINER_GROUP); + } + + return Node.create({ + name: blockConfig.type, + content: childrenContentExpression(children), + group: groups.join(" "), + marks() { + return suggestionMarks(this.editor); + }, + selectable: blockImplementation.meta?.selectable ?? true, + // Derived from `boundary`: an "open" container lets everything cross its + // edge; "isolated" and "sealed" both map to PM `isolating: true`. + isolating: resolveChildren(children).boundary !== "open", + 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 || {}, + }); + }, + }); +} + +export function containerRootDOM(output: { + dom: HTMLElement | DocumentFragment; + rootDOM?: HTMLElement | null; +}): HTMLElement | null { + if (output.rootDOM !== undefined) { + return output.rootDOM; + } + if (output.dom instanceof DocumentFragment) { + // A fragment can't hold attributes, so the round-trip markers + // (`data-node-type`, prop `data-*`) would be lost with it as the root. + // When it wraps a single element (the shape a React render produces), + // that element is the block's real root. A multi-element fragment has no + // root to mark, so its container HTML can't parse back. + return output.dom.children.length === 1 + ? (output.dom.children[0] as HTMLElement) + : null; + } + return output.dom; +} + +function containerNodeView( + 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; + + // Mark the children host in the live DOM, mirroring what the internal HTML + // serializer emits, so the container's round-trip parse rule can scope + // itself to it (`contentElement` in `getParseRules`) when ProseMirror + // re-reads editor DOM. + if (typedNodeView.contentDOM) { + (typedNodeView.contentDOM as HTMLElement).setAttribute( + "data-children-of", + blockConfig.type, + ); + } + + 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 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), which 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, - ); - - // Cast needed because render returns `dom: HTMLElement | DocumentFragment` - // but tiptap's NodeView expects `dom: HTMLElement` - const typedNodeView = nodeView as unknown as NodeView; - - if (blockImplementation.meta?.selectable === false) { - applyNonSelectableBlockFix(typedNodeView, this.editor); - } - - // 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 (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, this.editor); + } - // 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; - }; - }, - }); + // 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 (node.name !== blockConfig.type) { +// 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, +): LooseBlockSpec { + // A `children` config combined with any `content` other than `"none"` is + // rejected by `validateChildrenConfigs` when the schema is built. + const childrenConfig = getChildrenConfig(blockConfig); + + const isContainer = childrenConfig !== undefined; + + const builtNode: Node = (blockImplementation as any).node + ? ((blockImplementation as any).node as Node) + : childrenConfig + ? buildContainerNode( + blockConfig as unknown as BlockConfig, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "none" + >, + priority, + ) + : buildRegularNode(blockConfig, blockImplementation, priority); + + if (builtNode.name !== blockConfig.type) { throw new Error( "Node name does not match block type. This is a bug in BlockNote.", ); } + // The block's config is stored on its node's PM spec + // (`NodeSpec.blockConfig`), so code holding a bare `Node` can consult it + // without an editor or schema reference. (`extendNodeSchema` hooks run + // for every node in the schema, hence the name gate.) + const node = builtNode.extend({ + extendNodeSchema(extension) { + return extension.name === builtNode.name ? { blockConfig } : {}; + }, + }); + return { config: blockConfig, implementation: { @@ -307,7 +591,7 @@ export function addNodeAndExtensionsToSpec< const blockContentDOMAttributes = node.options.domAttributes?.blockContent || {}; - return blockImplementation.render.call( + const output = blockImplementation.render.call( { blockContentDOMAttributes, props: undefined, @@ -317,6 +601,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 +620,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 +636,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 +759,8 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + const isContainer = getChildrenConfig(blockConfig) !== undefined; + return { config: blockConfig, implementation: { @@ -470,6 +779,11 @@ export function createBlockSpec< return undefined; } + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -489,6 +803,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..49135f9f7f 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..813ea343fd 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, container or not: 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,98 @@ export interface BlockConfigMeta< hasPreview?: boolean; } +/** + * What may appear as a child of a container block. + * + * - `"any"`: any regular block, or any container block placeable anywhere. + * - `"blocks"`: regular (non-container) blocks only. This cannot be narrowed + * to specific block types: every regular block is the *same* ProseMirror + * node (`blockContainer`), so paragraphs, headings and code blocks are + * indistinguishable at the node level. + * - `"containers"`: any container block placeable anywhere, no regular blocks. + * - `readonly string[]`: only these types, enforced exactly by the schema. + * Today the array may only name *container* block types (naming a regular + * block type is a startup error); per-type regular-block filtering can be + * added to this same form later, with no API change. + * + * The wildcards (`"any"`, `"containers"`) never include + * `placement: "containerOnly"` types. Those appear only where a parent names + * them explicitly in an array. + */ +export type ChildrenAllow = "any" | "blocks" | "containers" | readonly string[]; + +/** + * Marks a block as a *container*: a block whose body is other blocks, exposed + * as `block.children` at runtime. + * + * The config describes one uniform body, semantically a single implicit + * slot. Ordered multi-slot bodies (a `sequence` of slots) can be added later + * as a sibling form. + */ +export type ChildrenConfig = { + /** What may appear as a child. See {@link ChildrenAllow}. */ + allow: ChildrenAllow; + /** @default 1 */ + min?: number; + /** @default unbounded */ + max?: number; + /** + * 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. + * + * Also the seed that `whenEmptied: "refill"` tops up from, when children + * drop below `min`. + */ + default?: readonly PartialBlockNoDefaults[]; + /** + * What happens as children are emptied out (Backspace merges the last child + * away, `removeBlocks` deletes children, ...) and fewer than `min` non-empty + * children remain: + * + * - `"refill"` (the default): drop the emptied children and top the + * container back up to `min`, seeding the missing positions from the + * unconsumed tail of `default` (falling back to empty blocks when + * `default` is absent or too short). + * - `"unwrap"`: drop the emptied children and 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`: + * 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 "refill" + */ + whenEmptied?: "refill" | "unwrap"; + /** + * What may cross the container's edge. + * + * - `"open"`: the caret, editing gestures and text selections all cross + * the edge (ProseMirror `isolating: false`). Right for flow regions like + * column lists, where a selection may span columns. + * - `"isolated"` (the default): the caret and editing gestures cross + * exactly as with `"open"`; only a text selection cannot span the edge + * (`isolating: true`). + * - `"sealed"`: atomic to gestures, like a table cell. The caret doesn't + * enter via arrows/Backspace, and the block selects as a unit + * (`isolating: true`). Key-agnostic, so compartments need no hand-written + * keyboard handlers. + * + * Seals bind editing gestures only: the block manipulation API + * (`insertBlocks` etc.) ignores them. + * @default "isolated" + */ + boundary?: "open" | "isolated" | "sealed"; +}; + +// `ResolvedChildren`, the fully-defaulted, desugared shape a `ChildrenConfig` +// compiles to, is internal machinery, not part of the consumer-facing config +// surface. So it lives in `./children.ts` (which is not re-exported wholesale) +// rather than here, where `export *` would leak it onto `@blocknote/core`'s +// public types. + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -106,8 +204,44 @@ 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. + * + * Only `content: "none"` may be combined with `children`; a container + * block has no content of its own. Combining `children` with any other + * `content` is a schema-creation error. (Content-bearing containers may be + * supported in a future version, at which point this restriction lifts.) + * + * `children: { allow: "any" }` 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` array (e.g. a `column` inside a `columnList`). + * + * Only meaningful for container blocks; regular blocks are always placeable + * anywhere. + */ + placement?: "anywhere" | "containerOnly"; +} + +declare module "prosemirror-model" { + interface NodeSpec { + /** + * The config of the BlockNote block this node was built from, so code + * holding a bare `Node` can read block-level facts (children config, + * placement, ...) without an editor or schema reference. Set on every + * node built from a block spec. + */ + blockConfig?: BlockConfig; + } } /** @@ -227,9 +361,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, @@ -286,9 +422,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 +728,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 author's element, which container attributes (`data-node-type`, + * `data-id`, prop `data-*`) are applied to. + * @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.ts b/packages/core/src/schema/blocks/validateChildren.ts new file mode 100644 index 0000000000..942f4eddfe --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -0,0 +1,392 @@ +import { + getChildrenConfig, + isContainerType, + isPlaceableAnywhere, + resolveChildren, +} from "./children.js"; +import type { ResolvedChildren } from "./children.js"; +import type { BlockConfig, ChildrenConfig } from "./types.js"; + +type ValidatableConfig = Pick & { + children?: ChildrenConfig; + placement?: BlockConfig["placement"]; +}; + +/** + * Validates the `children` config of every block in a schema, so that + * misconfigurations are reported 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]); + const acceptCtx = { + isContainerBlockType, + isPlaceableAnywhereType: (blockType: string) => + !!blockConfigs[blockType] && isPlaceableAnywhere(blockConfigs[blockType]), + }; + + for (const [type, config] of Object.entries(blockConfigs)) { + const children = getChildrenConfig(config); + + if (!children) { + // `placement: "anywhere"` is the documented default for every block, so + // writing it on a regular block is a harmless restatement. Only + // `"containerOnly"` is meaningless without `children`. + if (config.placement === "containerOnly") { + fail( + type, + '`placement: "containerOnly"` 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, acceptCtx); + } + + validateContainerOnlyIsReachable(blockConfigs); + validateNoCycles(blockConfigs, isContainerBlockType); +} + +function fail(type: string, message: string): never { + throw new Error( + `Invalid \`children\` config for block "${type}": ${message}`, + ); +} + +type AllowAcceptContext = { + isContainerBlockType: (blockType: string) => boolean; + isPlaceableAnywhereType: (blockType: string) => boolean; +}; + +function validateOne( + type: string, + config: ValidatableConfig, + children: ChildrenConfig, + blockConfigs: Record, + acceptCtx: AllowAcceptContext, +) { + // A container block's body is its children; it has no content of its own. + // Combining the two (a "content container") is not supported — only + // `content: "none"` may be combined with `children`. Blocks wanting an + // editable title alongside their children can use a string prop instead. + if (config.content !== "none") { + fail( + type, + `\`children\` can only be combined with \`content: "none"\`, but this block declares \`content: "${config.content}"\`. ` + + "A container block holds child blocks instead of its own content. " + + "For an editable title or caption, use a string prop rendered as an input.", + ); + } + + // Mirror the type-level contract for JS consumers: `allow` is required, and + // takes exactly the four forms. Widened to `unknown` because the type + // narrowing would otherwise leave `never` for the message. + const allow: unknown = children.allow; + if (allow === undefined) { + fail( + type, + '`allow` is required. Use `children: { allow: "any" }` for a container that accepts any block.', + ); + } + if ( + !Array.isArray(allow) && + allow !== "any" && + allow !== "blocks" && + allow !== "containers" + ) { + fail( + type, + `\`allow\` must be "any", "blocks", "containers" or an array of container block types, but is ${JSON.stringify(allow)}.`, + ); + } + + const boundary: string | undefined = children.boundary; + if ( + boundary !== undefined && + boundary !== "open" && + boundary !== "isolated" && + boundary !== "sealed" + ) { + fail( + type, + `\`boundary\` must be "open", "isolated" or "sealed", but is "${boundary}".`, + ); + } + + const resolved = resolveChildren(children); + + if (!Number.isInteger(resolved.min) || resolved.min < 0) { + fail( + type, + `minimum child count must be a non-negative integer, but is ${resolved.min}.`, + ); + } + if (resolved.max !== undefined) { + if (!Number.isInteger(resolved.max) || resolved.max < 1) { + fail( + type, + `maximum child count must be a positive integer, but is ${resolved.max}.`, + ); + } + if (resolved.max < resolved.min) { + fail( + type, + `maximum child count (${resolved.max}) must be greater than or equal to the minimum (${resolved.min}).`, + ); + } + } + + validateAllow(type, resolved, blockConfigs, acceptCtx); + validateDefault(type, resolved, blockConfigs, acceptCtx); +} + +function validateAllow( + type: string, + resolved: ResolvedChildren, + blockConfigs: Record, + { isContainerBlockType, isPlaceableAnywhereType }: AllowAcceptContext, +) { + if (resolved.containers !== true) { + for (const allowed of resolved.containers) { + if (!(allowed in blockConfigs)) { + fail( + type, + `\`allow\` contains "${allowed}", which is not a block type in this schema.`, + ); + } + // An `allow` array is exact by construction: each named type is its own + // ProseMirror node. Every *regular* block, by contrast, is the same node + // (`blockContainer`), so naming one here would promise a restriction the + // schema cannot keep. + if (!isContainerBlockType(allowed)) { + fail( + type, + `\`allow\` contains "${allowed}", which is a regular block, not a container block. ` + + "Restricting which regular block types a container accepts is not yet supported, as every regular block is the same ProseMirror node. " + + 'Use `allow: "blocks"` to accept all regular blocks, or name only container block types.', + ); + } + } + } + + if ( + !resolved.blocks && + resolved.containers !== true && + resolved.containers.length === 0 + ) { + fail( + type, + "`allow` permits nothing. A container must accept at least one block or container type; drop `children` entirely for a block that holds none.", + ); + } + + if (!resolved.blocks && resolved.containers === true) { + // The wildcard compiles to the containers placeable anywhere, so only + // those make the container fillable. `containerOnly` blocks are never + // included. + const hasContainer = Object.keys(blockConfigs).some( + (blockType) => + isContainerBlockType(blockType) && + blockType !== type && + isPlaceableAnywhereType(blockType), + ); + if (!hasContainer) { + fail( + type, + "`allow` permits only container blocks, but this schema has no other container block types placeable anywhere. " + + 'The `"containers"` wildcard never includes `placement: "containerOnly"` blocks. Name those explicitly in an `allow` array.', + ); + } + } +} + +function validateDefault( + type: string, + resolved: ResolvedChildren, + blockConfigs: Record, + acceptCtx: AllowAcceptContext, +) { + const { default: defaultChildren, min, max } = resolved; + if (!defaultChildren) { + return; + } + + if (defaultChildren.length < min) { + fail( + type, + `\`default\` has ${defaultChildren.length} block(s), fewer than the ${min} required.`, + ); + } + if (max !== undefined && defaultChildren.length > max) { + fail( + type, + `\`default\` has ${defaultChildren.length} block(s), more than the ${max} allowed.`, + ); + } + + 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.`, + ); + } + + if (!allowAccepts(resolved, childType, acceptCtx)) { + fail( + type, + `\`default\` contains a block of type "${childType}", which is not permitted.`, + ); + } + } +} + +/** + * Whether a container's `allow` accepts a block type. Matches what the schema + * enforces: the only lever for regular blocks is whether `blockContainer` is + * in the content expression, and the container wildcards compile to the + * containers placeable anywhere, so a `placement: "containerOnly"` block is + * only accepted where it is named explicitly. + */ +function allowAccepts( + resolved: ResolvedChildren, + blockType: string, + ctx: AllowAcceptContext, +): boolean { + if (ctx.isContainerBlockType(blockType)) { + return resolved.containers === true + ? ctx.isPlaceableAnywhereType(blockType) + : resolved.containers.includes(blockType); + } + return resolved.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. A type that isn't in the schema is not this check's + // concern. + 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. + * + * Only explicit `allow` arrays count: the container wildcards compile to the + * containers placeable anywhere, so they never accept a `containerOnly` + * block. Otherwise deliberately conservative. Proving that the block is + * reachable from a block placeable at the root is full graph reachability, + * and this check only exists to catch typos. + */ +function validateContainerOnlyIsReachable( + blockConfigs: Record, +) { + const accepted = new Set(); + for (const config of Object.values(blockConfigs)) { + const children = getChildrenConfig(config); + if (!children) { + continue; + } + const { containers } = resolveChildren(children); + if (containers === true) { + continue; + } + for (const allowed of 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\` array 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 + * overflows 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 container that allows regular blocks can always be filled with a plain + // paragraph, so it never forces recursion. Only container-only lists do. + const requiredContainers = (type: string): string[] => { + const children = getChildrenConfig(blockConfigs[type]); + if (!children) { + return []; + } + const resolved = resolveChildren(children); + return resolved.min >= 1 && !resolved.blocks && resolved.containers !== true + ? resolved.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 containers 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..967a65bb5e 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..b69ba53fbf 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -16,6 +16,10 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "./index.js"; +import { + validateChildrenConfigs, + validateContainerRunsBefore, +} from "./blocks/validateChildren.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -91,6 +95,26 @@ export class CustomBlockNoteSchema< })), ); + // Validation runs before the nodes are built, 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 blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { return [ 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..cc3bdd45f1 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) is therefore +// set on `window` when there is one and on `globalThis` otherwise, matching +// the 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 @@ -19,7 +26,7 @@ class ClipboardEventMock extends Event { }, }; } -(global as any).ClipboardEvent = ClipboardEventMock; +(globalThis as any).ClipboardEvent = ClipboardEventMock; // Mock DragEvent class DragEventMock extends Event { @@ -32,4 +39,4 @@ class DragEventMock extends Event { }, }; } -(global as any).DragEvent = DragEventMock; +(globalThis as any).DragEvent = DragEventMock; diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 2bf0e4fa57..a79a935078 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 itself, 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..588469916b --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx @@ -0,0 +1,156 @@ +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"; + +/** + * Tests for React container blocks in a real browser. + * + * Everything here needs a real DOM: the external-HTML path renders the block + * through a temporary `createRoot` (see `@util/ReactRenderUtil`), and a React + * node view only runs once `contentComponent` is set, which happens when + * `BlockNoteViewRaw` mounts the editor. Document-model behaviour of + * containers in general is covered by the core suites in + * `api/blockManipulation/containers/`. + */ + +// A container: its `contentRef` element holds its child blocks. +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { flavor: { default: "tip" } }, + content: "none", + children: { allow: "any", default: [{ type: "paragraph" }] }, + }, + { + render: (props) => ( +
+
+
+ ), + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + callout: createCallout(), + }, +}); + +describe("React container block external HTML", () => { + it("serializes the author's own root element, unwrapped", () => { + 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). + // The root is the element `render` returned, with no React wrapper in + // between, so `.callout[data-*]` CSS matches it here exactly as in the + // live editor. + expect(html).not.toContain('data-content-type="callout"'); + expect(html).not.toContain("data-node-view-wrapper"); + expect(html).toContain('class="callout"'); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain("Hello"); + + editor._tiptapEditor.destroy(); + }); +}); + +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 checks 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.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 5311d4e37d..0d49889901 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,17 @@ 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" }; + export type ReactCustomBlockRenderProps< B extends BlockConfigOrCreator, Config extends ExtractBlockConfigFromConfigOrCreator = @@ -33,11 +42,15 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; -} & (Config["content"] extends "inline" | "plain" - ? { - contentRef: (node: HTMLElement | null) => void; - } - : object); + // A block gets a `contentRef` for its `render` to mount its editable region: + // its inline content, or, for a container, its child blocks. Only a + // `content: "none"` block without `children` (and the table block, whose + // content is managed separately) has nothing to place. +} & (Config extends { children: ChildrenConfig } + ? { contentRef: (node: HTMLElement | null) => void } + : Config["content"] extends "inline" | "plain" + ? { contentRef: (node: HTMLElement | null) => void } + : object); // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< @@ -131,20 +144,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 +165,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 +249,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, + // with no wrapper. The attributes core stamps afterwards then + // 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 +297,205 @@ 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 uses 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; - 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); + // 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. - const ref = useReactNodeView().nodeViewContentRef; + 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, + // which is the wrong block here. 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 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`, but for containers that element + // has `display: contents`, 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 = ""; + // Mark the children host of a container so the + // round-trip parse rule can scope itself to it (see + // `getParseRules`). + element.setAttribute( + "data-children-of", + blockConfig.type, + ); + } + }} + /> + + ); + }; + + 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. + 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..2577150aec 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. That is correct for blockContent node views, but wrong for + // container blocks, whose node is itself the bnBlock: it would return an + // ancestor block. This guard throws 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..07c283c583 100644 --- a/packages/react/vitestSetup.ts +++ b/packages/react/vitestSetup.ts @@ -1,11 +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. The DOM mocks below are a no-op there. +const hasWindow = typeof window !== "undefined"; + +// Match the core setup: the deterministic-ID options live on `window` when it +// exists and on `globalThis` in the node environment, since `generateID` reads +// them from `(globalThis.window ?? globalThis).__TEST_OPTIONS`. +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 @@ -19,7 +29,7 @@ class ClipboardEventMock extends Event { }, }; } -(global as any).ClipboardEvent = ClipboardEventMock; +(globalThis as any).ClipboardEvent = ClipboardEventMock; // Mock DragEvent class DragEventMock extends Event { @@ -32,28 +42,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; +(globalThis 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..7141fefdfd 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: { allow: "any" }, + }, + { + 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..198de85923 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,28 +1,82 @@ +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: { allow: "any" }, + 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: ["column"], + min: 2, + whenEmptied: "unwrap", + // Everything crosses the column list's edge, e.g. a text selection + // dragged across columns. + boundary: "open", + }, + }, + { + meta: { + 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..1d2da18690 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"; @@ -41,13 +40,71 @@ type ColumnResizeState = { columnList: ColumnData; }; -type ColumnState = +// Exported for tests only - not part of the package's public API. +export type ColumnState = | ColumnDefaultState | ColumnHoverState | ColumnHoverColumnListState | ColumnResizeState; -const columnResizePluginKey = new PluginKey("ColumnResizePlugin"); +// Exported for tests only - not part of the package's public API. +export const columnResizePluginKey = new PluginKey( + "ColumnResizePlugin", +); + +// Re-resolves stored column data against a (possibly changed) doc, since the +// stored node and position may be stale. Returns undefined if the node no +// longer exists in the doc. +function refreshColumnData( + data: T, + doc: Node, +): T | undefined { + const nodeAndPos = getNodeById(data.id, doc); + if (!nodeAndPos) { + return undefined; + } + + return { ...data, ...nodeAndPos }; +} + +// Re-resolves all column data stored in the plugin state against a (possibly +// changed) doc. Falls back to the default state if any of the referenced +// nodes no longer exist - e.g. when a backspace removes a hovered column, or +// unwraps the column list entirely - so decorations are never built from +// positions that are invalid in the new doc. +function refreshColumnState(state: ColumnState, doc: Node): ColumnState { + switch (state.type) { + case "default": + return state; + case "hover-column-list": { + const columnList = refreshColumnData(state.columnList, doc); + + return columnList ? { ...state, columnList } : { type: "default" }; + } + case "hover-column": { + const columnList = refreshColumnData(state.columnList, doc); + const leftColumn = refreshColumnData(state.leftColumn, doc); + const rightColumn = refreshColumnData(state.rightColumn, doc); + + if (!columnList || !leftColumn || !rightColumn) { + return { type: "default" }; + } + + return { ...state, columnList, leftColumn, rightColumn }; + } + case "resize": { + const columnList = refreshColumnData(state.columnList, doc); + const leftColumn = refreshColumnData(state.leftColumn, doc); + const rightColumn = refreshColumnData(state.rightColumn, doc); + + if (!columnList || !leftColumn || !rightColumn) { + return { type: "default" }; + } + + return { ...state, columnList, leftColumn, rightColumn }; + } + } +} class ColumnResizePluginView implements PluginView { editor: BlockNoteEditor; @@ -428,22 +485,26 @@ const createColumnResizePlugin = (editor: BlockNoteEditor) => state: { init: () => ({ type: "default" }) as ColumnState, apply: (tr, oldPluginState) => { - const newPluginState = tr.getMeta(columnResizePluginKey) as + const metaPluginState = tr.getMeta(columnResizePluginKey) as | ColumnState | undefined; - return newPluginState === undefined ? oldPluginState : newPluginState; + const pluginState = + metaPluginState === undefined ? oldPluginState : metaPluginState; + + // The stored column nodes & positions were resolved against an older + // doc, so when the doc changes they must be re-resolved against the + // new one - a backspace may have removed a hovered column or + // unwrapped the column list entirely. + return tr.docChanged + ? refreshColumnState(pluginState, tr.doc) + : pluginState; }, }, 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..6f26bce4e8 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -3,7 +3,8 @@ import { UniqueID, createExtension, fragmentToBlocks, - getBlockInfo, + getBlockInfoWithManualOffset, + isContainerNode, nodeToBlock, } from "@blocknote/core"; import { Plugin } from "prosemirror-state"; @@ -26,7 +27,10 @@ export function createMultiColumnHandleDropPlugin( return false; // Let ProseMirror handle the drop (e.g. outside editor bounds) } - const blockInfo = getBlockInfo(edgePos); + const blockInfo = getBlockInfoWithManualOffset( + edgePos.node, + edgePos.posBeforeNode, + ); // Only handle edge drops (left/right) if (edgePos.position === "regular") { @@ -42,13 +46,24 @@ 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 // keeps the column's ID and width instead of resetting them. let allTargetChildrenDragged = true; - blockInfo.bnBlock.node.forEach((child) => { + // A column is a pure container: its `children` node is the column + // node itself. + const columnChildren = + blockInfo.childContainer?.node ?? blockInfo.bnBlock.node; + columnChildren.forEach((child) => { if (!draggedBlockIds.has(child.attrs.id)) { allTargetChildrenDragged = false; } @@ -57,16 +72,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,28 +95,44 @@ 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; + // The target itself is one of the dragged blocks (only possible + // when the container holds plain blocks directly) - the dragged + // blocks would be re-inserted around their own position, so do + // nothing, same as dropping a typed target's entire contents on + // its own edge. + if (!targetIsChildContainer && draggedBlockIds.has(targetColumnId)) { + return true; + } + // Tracks which of the dragged blocks were already in the column // list - removing those from their old position is handled by // filtering the column list's children instead of `removeBlocks`. @@ -103,20 +140,36 @@ 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); + // removed) and, when the container holds plain blocks directly, + // dragged direct children (which are re-inserted at the drop + // position). + .filter((column) => { + if (targetIsChildContainer) { + return column.children.length > 0; + } + if (!draggedBlockIds.has(column.id)) { + return true; + } + + blocksAlreadyInColumnList.add(column.id); + return false; + }); // The insertion index is computed on the remaining columns, as // removing an emptied column before the drop target shifts the @@ -134,15 +187,25 @@ 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, or spliced in directly otherwise. + const insertedChildren = targetIsChildContainer + ? [ + { + type: blockInfo.blockNoteType, + children: draggedBlocks, + props: {}, + content: undefined, + id: UniqueID.options.generateID(), + }, + ] + : draggedBlocks; + const newChildren = remainingColumns.toSpliced( + insertionIndex, + 0, + ...insertedChildren, + ); 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/__snapshots__/insertBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap index 476357f363..c6019e4dd8 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap @@ -429,6 +429,226 @@ exports[`Test insertBlocks > Insert column with paragraph into column list 1`] = ] `; +exports[`Test insertBlocks > Insert empty column list 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "4", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 1", + "type": "text", + }, + ], + "id": "paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 0", + "type": "text", + }, + ], + "id": "column-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 1", + "type": "text", + }, + ], + "id": "column-paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 2", + "type": "text", + }, + ], + "id": "column-paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 3", + "type": "text", + }, + ], + "id": "column-paragraph-3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-1", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "column-list-0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 2", + "type": "text", + }, + ], + "id": "paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + exports[`Test insertBlocks > Insert paragraph into column 1`] = ` [ { diff --git a/packages/xl-multi-column/src/test/commands/enter.test.ts b/packages/xl-multi-column/src/test/commands/enter.test.ts new file mode 100644 index 0000000000..9c121b97b3 --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/enter.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "@blocknote/core"; + +import { setupTestEnv } from "../setupTestEnv.js"; + +const getEditor = setupTestEnv(); + +function pressEnter(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + keyCode: 13, + bubbles: true, + }); + view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + +// Columns have no special Enter config: like any non-sealed container, an +// empty last block escapes on Enter. The generic mechanics (escape, ascent +// past levels that can't hold the block, mid-container stays) are covered in +// core's `containers.browser.test.ts`; these two tests use the real column +// schema and its interaction with the column-list repair. +describe("Enter exit from columns", () => { + it("typing then double-Enter escapes in two presses", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "cl-0", + children: [ + { + type: "column", + id: "col-1", + children: [{ id: "col1-para", type: "paragraph", content: "col1" }], + }, + { + type: "column", + id: "col-2", + children: [{ id: "col2-para", type: "paragraph", content: "col2" }], + }, + ], + }, + ]); + + editor.setTextCursorPosition("col2-para", "end"); + pressEnter(editor); + + // First press: a new empty block inside the column. + expect(editor.document.map((block) => block.id)).toEqual(["cl-0"]); + const children = editor.getBlock("col-2")!.children; + expect(children).toHaveLength(2); + const created = children[1].id; + expect(editor.getTextCursorPosition().block.id).toBe(created); + + pressEnter(editor); + + // Second press: that block moves below the column list (a block can't sit + // between columns, so the escape lands below the whole list), caret along. + expect(editor.getBlock("col-2")!.children.map((child) => child.id)).toEqual( + ["col2-para"], + ); + expect(editor.document.map((block) => block.id)).toEqual(["cl-0", created]); + expect(editor.getTextCursorPosition().block.id).toBe(created); + }); + + it("escaping a column's only block dissolves it and unwraps the list", () => { + // The exit empties the column, so the column list's `whenEmptied: "unwrap"` + // repair kicks in: the emptied column disappears, and the one-column + // list unwraps to the surviving column's blocks. + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "cl-0", + children: [ + { + type: "column", + id: "col-1", + children: [{ id: "col1-para", type: "paragraph", content: "col1" }], + }, + { + type: "column", + id: "col-2", + children: [{ id: "col2-empty", type: "paragraph", content: "" }], + }, + ], + }, + ]); + + editor.setTextCursorPosition("col2-empty", "end"); + pressEnter(editor); + + expect(editor.document.map((block) => block.id)).toEqual([ + "col1-para", + "col2-empty", + ]); + }); +}); diff --git a/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts index 319ceda379..17372c5668 100644 --- a/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts +++ b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts @@ -6,14 +6,11 @@ const getEditor = setupTestEnv(); describe("Test insertBlocks", () => { it("Insert empty column list", () => { - // should throw an error as we don't allow empty column lists - expect(() => { - getEditor().insertBlocks( - [{ type: "columnList" }], - "paragraph-0", - "after", - ); - }).toThrow(); + // An empty column list is filled to a valid two-column list (each with an + // empty paragraph) instead of throwing. + getEditor().insertBlocks([{ type: "columnList" }], "paragraph-0", "after"); + + expect(getEditor().document).toMatchSnapshot(); }); it("Insert column list with empty column", () => { 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..0d6612056e 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-multi-column/src/test/extensions/columnResize.test.ts b/packages/xl-multi-column/src/test/extensions/columnResize.test.ts new file mode 100644 index 0000000000..964a61de1a --- /dev/null +++ b/packages/xl-multi-column/src/test/extensions/columnResize.test.ts @@ -0,0 +1,101 @@ +import { getNodeById } from "@blocknote/core"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ColumnState, + columnResizePluginKey, +} from "../../extensions/ColumnResize/ColumnResizeExtension.js"; +import { setupTestEnv } from "../setupTestEnv.js"; + +const getEditor = setupTestEnv(); + +// Puts the column resize plugin into the state it would be in when the user +// hovers the boundary between the two columns of "column-list-0" in the test +// document, as the plugin's mouse handlers would. +function hoverColumnBoundary() { + const editor = getEditor(); + const view = editor._tiptapEditor.view; + + const columnList = getNodeById("column-list-0", view.state.doc); + const leftColumn = getNodeById("column-0", view.state.doc); + const rightColumn = getNodeById("column-1", view.state.doc); + + if (!columnList || !leftColumn || !rightColumn) { + throw new Error("Test document is missing expected columns"); + } + + const hoverState: ColumnState = { + type: "hover-column", + columnList: { + element: document.createElement("div"), + id: "column-list-0", + ...columnList, + }, + leftColumn: { + element: document.createElement("div"), + id: "column-0", + ...leftColumn, + }, + rightColumn: { + element: document.createElement("div"), + id: "column-1", + ...rightColumn, + }, + }; + + view.dispatch(view.state.tr.setMeta(columnResizePluginKey, hoverState)); +} + +describe("Column resize plugin state after doc changes", () => { + it("falls back to default when a hovered column's removal unwraps the column list", () => { + const editor = getEditor(); + + hoverColumnBoundary(); + + // Removing one of the two columns brings the column list below its + // minimum of 2 children, so it gets unwrapped entirely. This used to + // throw a RangeError from the plugin's decorations, as they were built + // from positions resolved against the old, larger doc. + editor.removeBlocks(["column-1"]); + + expect( + columnResizePluginKey.getState(editor._tiptapEditor.view.state), + ).toEqual({ type: "default" }); + // The surviving column's two paragraphs are unwrapped to the top level. + expect(editor.document.map((block) => block.type)).toEqual([ + "paragraph", + "paragraph", + "paragraph", + "paragraph", + "paragraph", + ]); + }); + + it("falls back to default when the whole doc is replaced", () => { + const editor = getEditor(); + + hoverColumnBoundary(); + + // Mimics select-all + backspace clearing the document while columns are + // hovered. + editor.replaceBlocks(editor.document, [{ type: "paragraph" }]); + + expect( + columnResizePluginKey.getState(editor._tiptapEditor.view.state), + ).toEqual({ type: "default" }); + expect(editor.document).toHaveLength(1); + }); + + it("keeps the hover state when an unrelated block changes", () => { + const editor = getEditor(); + + hoverColumnBoundary(); + + editor.updateBlock("paragraph-1", { content: "Updated Paragraph 1" }); + + const pluginState = columnResizePluginKey.getState( + editor._tiptapEditor.view.state, + ); + expect(pluginState?.type).toBe("hover-column"); + }); +}); diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index 7c17cad0ad..cc73263000 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -142,11 +142,19 @@ export class ODTExporter< numberedListIndex = 0; } - if (["columnList", "column"].includes(block.type)) { - const children = await this.transformBlocks(block.children, 0); + if (this.isContainerBlock(block.type)) { + // Legacy columns render as an ODT table whose cells reset indentation + // to 0. Schema-defined containers are ordinary nested blocks, so they + // preserve the current nesting level like every other exporter. + const isLegacyColumn = + block.type === "columnList" || block.type === "column"; + const children = await this.transformBlocks( + block.children, + isLegacyColumn ? 0 : nestingLevel + 1, + ); const content = await this.mapBlock( block as any, - 0, + isLegacyColumn ? 0 : nestingLevel, numberedListIndex, children, ); 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 fc95039f22..72cf630477 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1457,6 +1457,33 @@ export const examples = { readme: "In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)", }, + { + projectSlug: "container-block", + fullSlug: "custom-schema/container-block", + pathFromRoot: "examples/06-custom-schema/09-container-block", + config: { + playground: true, + docs: true, + author: "nickthesick", + tags: [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Container Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we create a custom `Callout` block that holds other blocks as its body, like a Notion-style callout wrapping a paragraph followed by a code block.\n\nThe block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime.\n\nThe callout\'s **title** demonstrates the complementary "string prop slot" pattern: a field that doesn\'t need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block\'s own `content: "inline"` instead.\n\nWe also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the callout\'s body and add a code block, heading, or list.\n- Type a title into the title field. It\'s stored on `block.props.title`, not as document content.\n- Watch the JSON panel on the right update as you edit; the callout\'s children appear in `block.children`.\n- Insert a new callout via the Slash Menu (search "callout").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + }, { projectSlug: "math-block", fullSlug: "custom-schema/math-block", @@ -1536,6 +1563,33 @@ export const examples = { readme: 'In this example, we build custom blocks on the source-with-preview pattern — the same building blocks behind BlockNote\'s math and diagram blocks. A custom "CSV table" block renders its comma-separated source as a table, and a custom "color" inline content renders a CSS color as a swatch. Both show the rendered preview in place, while the source is edited in a popup.\n\n**Try it out:** Click the table or a color chip to edit its source!\n\n**Relevant Docs:**\n\n- [Source with Preview Blocks](/docs/features/custom-schemas/source-with-preview)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content)', }, + { + projectSlug: "container-table", + fullSlug: "custom-schema/container-table", + pathFromRoot: "examples/06-custom-schema/12-container-table", + config: { + playground: true, + docs: true, + author: "nickthesick", + tags: [ + "Advanced", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Table Built From Container Blocks", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we rebuild BlockNote\'s table as four container blocks: `table`, `tableRow`, `tableCell`, and `tableHeader`. There is no `prosemirror-tables` and no special `"table"` content type. A table is a container of rows, a row is a container of cells, and a cell is a container of arbitrary blocks, so cells can hold lists, headings, images, or even nested tables. The JSON shape is the same `children` array every other block uses.\n\nCells declare `boundary: "sealed"`, which makes them behave like compartments: Backspace, Delete, and arrow keys never implicitly move content or the caret across a cell\'s edge, and Enter adds another block _inside_ the cell. Header cells are a distinct block type rather than table metadata, so toggling the header row is just `updateBlock` with a new type. All structural operations, from adding and removing rows and columns to Tab-to-next-cell, are plain calls to the public block manipulation API: `insertBlocks`, `removeBlocks`, `updateBlock`, `getParentBlock`, and `setTextCursorPosition`.\n\n**Try it out:**\n\n- Press Tab / Shift-Tab to move between cells. Tab in the last cell adds a new row.\n- Press Enter inside a cell to stack more blocks in it, or "/" to add a list or heading.\n- Hover the table to reveal the row/column controls, and watch the JSON panel update.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Manipulating Blocks](/docs/reference/editor/manipulating-content)\n- [Editor Setup](/docs/getting-started/editor-setup)', + }, { projectSlug: "draggable-inline-content", fullSlug: "custom-schema/draggable-inline-content", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6decb347ed..d18218456e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3459,6 +3459,52 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/09-container-block: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/09-math-block: dependencies: '@blocknote/ariakit': @@ -3609,6 +3655,52 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/12-container-table: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx index 77cd1cd21d..97116684d2 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,53 @@ describe("Check Multi-Column Behaviour", () => { await compareDocToSnapshot("deleteEndOfColumnList"); }); }); + +// Which block the side menu attaches to is resolved from live layout +// (`elementsFromPoint` / `posAtCoords`); the geometry pieces below that are +// unit-tested in `packages/core/src/extensions/SideMenu/ +// sideMenuContainerGeometry.browser.test.ts`. This tests the whole path, +// through a real column list. Hovering a column's left padding hands the +// lookup coordinates that horizontally overlap the previous column, and +// `SideMenu.ts` only resolves the right block by re-probing further right +// once `isHorizontalContainer` recognises the column list. If that +// compensation (or the detection) breaks, the menu attaches to a block in +// the previous column. +describe("Check side menu placement inside a column list", () => { + /** Vertical centre of a rect, which 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. This is a stronger check than a pixel tolerance, since every + // candidate is only a line-height away, and it is 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..397c75019b 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 itself the bnBlock, resolved +// by id instead of by position. +const createBoxBlock = createReactBlockSpec( + { type: "box", propSchema: {}, content: "none", children: { allow: "any" } }, + { 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.find((block) => block.type === "box")!; + 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"/); + }); });