diff --git a/.dockerignore b/.dockerignore index 87239fb0a2..5e9711a8f9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ # these entries just stop the heavy/irrelevant trees from bloating the context. **/node_modules **/dist +**/.next **/types **/.vite **/.vite-plus diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx index ff25cf838c..bcad6e9dac 100644 --- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx @@ -56,6 +56,11 @@ type BlockConfig = { type: string; content: "inline" | "plain" | "none"; readonly propSchema: PropSchema; + layoutOnly?: boolean; + children?: + | { allow?: "blocks"; exclude?: string[] } + | { allow: string[]; min?: number; dissolve?: boolean }; + placeable?: "anywhere" | "namedOnly"; }; ``` @@ -110,6 +115,15 @@ If you do not want the prop to have a default value, you can define it as an obj Properties](/docs/features/blocks#default-block-properties)._ + + _Every block can have child blocks, indented below it. You do not declare + them in the config. To draw a frame around a block and its child blocks, see + [Block Frames](#block-frames). `layoutOnly`, `children` and `placeable` are + only for layout blocks. A layout block only holds and arranges other blocks, + but has no content of its own, for example a panel or a tab set. See [Layout + Blocks](/docs/features/custom-schemas/layout-blocks)._ + + ### Block Implementation (`ReactCustomBlockImplementation`) The Block Implementation defines how the block should be rendered in the editor, and how it should be parsed from and converted to HTML. @@ -121,11 +135,17 @@ type ReactCustomBlockImplementation = { editor: BlockNoteEditor; contentRef?: (node: HTMLElement | null) => void; }>; + renderFrame?: React.FC<{ + block: Block; + editor: BlockNoteEditor; + slotRef: (node: HTMLElement | null) => void; + }>; toExternalHTML?: React.FC<{ block: Block; editor: BlockNoteEditor; contentRef?: (node: HTMLElement | null) => void; - contest: { nestingLevel: number }; + childrenRef: (node: HTMLElement | null) => void; + context: { nestingLevel: number }; }>; parse?: (element: HTMLElement) => PartialBlock["props"] | undefined; parseContent?: (options: { @@ -133,13 +153,26 @@ type ReactCustomBlockImplementation = { schema: Schema; }) => Fragment | undefined; runsBefore?: string[]; + experimental_keyboard?: + | KeyboardSettings + | ((block: Block) => KeyboardSettings); + // KeyboardSettings: { + // enter?: "split" | "into-children" | "line-break"; + // shiftEnter?: "line-break" | "same-as-enter"; + // splitKeepsType?: boolean; + // splitKeepsChildren?: boolean; + // resetsTo?: { type: string; props?: Record }; + // emptyEnterResets?: boolean; + // emptyChildEnter?: "outdent" | "exit-at-end" | "stay"; + // childrenCanOutdent?: boolean; + // } meta?: { - hardBreakShortcut?: "shift+enter" | "enter" | "none"; selectable?: boolean; fileBlockAccept?: string[]; code?: boolean; defining?: boolean; isolating?: boolean; + draggable?: boolean; highlight?: (block: Block) => string | undefined; }; }; @@ -153,9 +186,14 @@ type ReactCustomBlockImplementation = { - `contentRef:` A React `ref` you can use to mark which element in your block is editable. This is only available if your block config contains `content: "inline"` or `content: "plain"`. -`toExternalHTML?:` This component is used whenever the block is being exported to HTML for use outside BlockNote, for example when copying it to the clipboard. If it's not defined, BlockNote will just use `render` for the HTML conversion. Takes the same props as `render` and an additional `context` prop, which is. an object with the following attributes: +`renderFrame?:` A component that draws a frame around the block's content and its child blocks. See [Block Frames](#block-frames). + +`toExternalHTML?:` This component is used whenever the block is being exported to HTML for use outside BlockNote, for example when copying it to the clipboard. If it's not defined, BlockNote will just use `render` (and `renderFrame`, if the block has one) for the HTML conversion. Takes the same props as `render`, and two more: + +- `childrenRef:` A React `ref` that marks where the block's child blocks go, for example inside a `
` element. Without it, the child blocks follow the block. -- `nestingLevel`: How deep the block being exported in nested inside other blocks. 0 means it's at the top level of the document. +- `context:` An object with the following attributes: + - `nestingLevel`: How deep the block being exported is nested inside other blocks. 0 means it's at the top level of the document. _Note that your component passed to `toExternalHTML` is rendered and @@ -171,9 +209,27 @@ type ReactCustomBlockImplementation = { `runsBefore?:` If this block has parsing or extensions that need to be given priority over any other blocks, you can pass their `type`s in an array here. -`meta?:` An object for setting various generic properties of the block. +`experimental_keyboard?:` How the keyboard treats the block and its children. This API is experimental and may change. Give only the settings that differ from the defaults. To make settings depend on the block's props, give a function that gets the block and returns them instead. + +- `enter?:` What Enter does in the block's content. `"split"` (default) splits the block, moving the text after the caret into a new block below. `"into-children"` moves it into a new first child instead. `"line-break"` inserts a line break, and makes Shift-Enter do the same. For `content: "plain"` blocks (which can't hold hard break nodes), a line break is a literal newline (`"\n"`). + +- `shiftEnter?:` What Shift-Enter does in the block's content: `"line-break"` (default) or `"same-as-enter"`. + +- `splitKeepsType?:` Whether the block created by splitting this one with Enter has the same type, as in lists. Defaults to `false`. + +- `splitKeepsChildren?:` Whether the block's children stay with it when Enter splits it. Otherwise they follow the text after the caret into the new block, as for nested blocks, so the new block takes the block's place above them. Defaults to `true` when `enter` is `"into-children"` (the children are the block's body), otherwise `false`; a closed toggle sets it to keep its hidden children. + +- `resetsTo?:` What the block turns into when it's reset, keeping its content and children. Backspace at the start of the block always resets it. A `type`, and `props` to merge into the block's props. Defaults to `{ type: "paragraph" }`. + +- `emptyEnterResets?:` Whether Enter in the empty block resets it too, as when an empty list item turns into a paragraph. Defaults to `false`. + +- `emptyChildEnter?:` What Enter does in an empty child of this block. `"outdent"` (default) outdents any empty child. `"exit-at-end"` (default for layout blocks) moves an empty last child out to after the block, and adds a new child after any other empty child. `"stay"` always adds a new child after it. -- `hardBreakShortcut?:` Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. Defaults to `"shift+enter"`. For `content: "plain"` blocks (which can't hold hard break nodes), the shortcut inserts a literal newline (`"\n"`) instead. +- `childrenCanOutdent?:` Whether the block's children can be outdented out of it with Shift-Tab, or by Enter or Backspace in an empty or nested child. Defaults to `true`, or `false` for layout blocks, whose children can never be outdented. + +When settings meet, Enter at the start of non-empty content always inserts an empty block above it, and resetting an empty block comes before `enter: "into-children"`. + +`meta?:` An object for setting various generic properties of the block. - `selectable?:` Can be set to false in order to make the block non-selectable, both using the mouse and keyboard. This also helps with being able to select non-editable content within the block. Should only be set to false when `content` is `none` and defaults to true. @@ -185,6 +241,8 @@ type ReactCustomBlockImplementation = { - `isolating?:` Whether this block is [isolating](https://prosemirror.net/docs/ref/#model.NodeSpec.isolating). +- `draggable?:` Whether the block gets a side menu drag handle. Defaults to `true`, or `false` for a layout block that can only be placed inside a specific parent (`placeable: "namedOnly"`). + - `highlight?:` A callback that returns the language the block's text should be syntax-highlighted as (or `undefined` for no highlighting). Highlighting only takes effect when the [syntax highlighting extension](/docs/features/blocks/code-blocks#syntax-highlighting) is added to the editor. ### Block Extensions @@ -257,3 +315,86 @@ You can then instantiate your editor with this custom schema, as explained on th Now you know how to create a custom block and add it to your editor. However, users don't have a way of creating instances of it to their documents. To fix this, it's recommended to implement a [command to insert your custom in the Slash Menu](/docs/react/components/suggestion-menus#changing-slash-menu-items), and [an item for it in the Block Type Select](/docs/react/components/formatting-toolbar) + +## Block Frames + +A block can have child blocks. By default, BlockNote shows the child blocks indented below the block. Use `renderFrame` to draw one frame around the block's content and its child blocks, for example one background for both. + +In the demo below, we give the Alert block from the demo above a title and a body. The alert's text is its title, and its child blocks are its body. The frame gives the title and the body the background color of the alert type: + + + +The block config does not change. A block with content (`content: "inline"` or `"plain"`) can always have child blocks, so you do not declare them. We add `renderFrame` and `experimental_keyboard` to the block implementation: + +```tsx +export const createAlert = createReactBlockSpec( + { + type: "alert", + propSchema: { + type: { + default: "warning", + values: ["warning", "error", "info", "success"], + }, + }, + content: "inline", + }, + { + experimental_keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, + // Draws the title row, the same as in the Alert demo. + render: (props) => { + ... + }, + renderFrame: (props) => ( +
+
+
+ ), + }, +); +``` + +`render:` Draws only the block's content, here the alert's title row. It does not change when you add a frame. + +`renderFrame:` A React component that draws the frame. It takes three React props: + +- `block:` The block. Use its props to change the frame, for example to show the color of the alert type. + +- `editor:` The BlockNote editor instance that the block is in. + +- `slotRef:` A React `ref`. Attach it to the element that holds the block's content and its child blocks. You can add other elements around this element, for example icons or buttons. Set `contentEditable={false}` on them. + +To show the block without a frame, return `null`. For example, you can use a prop to turn the frame on and off. + +`experimental_keyboard:` Without these settings, the child blocks behave like all other nested blocks. Here, they keep the body in the alert: + +- `enter: "into-children"`: Enter in the title adds a block at the start of the body. +- `childrenCanOutdent: false`: Shift-Tab does not move blocks out of the alert. +- `emptyChildEnter: "exit-at-end"`: Enter in an empty last block of the body moves that block out of the alert. + +See [`experimental_keyboard`](#block-implementation-reactcustomblockimplementation) above for all settings. + +Use `content` for the title and `children` for the body: + +```typescript +{ + type: "alert", + content: "Before you start", + children: [ + { type: "paragraph", content: "Make sure you have an account." }, + ], +} +``` + +When the user moves the alert, its title and its body move together. + + + _A block with a frame is different from a [layout + block](/docs/features/custom-schemas/layout-blocks). A layout block has no + content of its own. It only holds child blocks, for example a panel or a tab + set. See [Layout Blocks and Block + Frames](/docs/features/custom-schemas/layout-blocks#layout-blocks-and-block-frames)._ + diff --git a/docs/content/docs/features/custom-schemas/layout-blocks.mdx b/docs/content/docs/features/custom-schemas/layout-blocks.mdx new file mode 100644 index 0000000000..12d8ef83a5 --- /dev/null +++ b/docs/content/docs/features/custom-schemas/layout-blocks.mdx @@ -0,0 +1,177 @@ +--- +title: Layout Blocks +description: Learn how to create custom blocks that contain other blocks +--- + +# Layout Blocks + + + Layout blocks are a preview feature. The API can change in a future release. + We would like to hear what you build with layout blocks, and what does not + work for you. Please tell us in [GitHub + Discussions](https://github.com/TypeCellOS/BlockNote/discussions) or open an + [issue](https://github.com/TypeCellOS/BlockNote/issues). + + +A layout block is a custom block that holds and arranges other blocks, but has no content of its own. Panels, tab sets and column layouts are examples. Its child blocks are inside the layout block, not indented below it, and the layout block draws a frame around them. A layout block can also limit which blocks it holds, for example only tabs in a tab set. + +If your block has its own text and also child blocks, for example an alert with a title and a body, use a custom block with a [frame](/docs/features/custom-schemas/custom-blocks#block-frames). See [Layout Blocks and Block Frames](#layout-blocks-and-block-frames) for the differences. + +Take a look at the demo below, in which we add a custom panel to a BlockNote editor, as well as a custom [Slash Menu item](/docs/react/components/suggestion-menus#changing-slash-menu-items) to insert it. Each panel can contain paragraphs, headings, lists, or any other blocks in the editor. + + + +For more advanced layout blocks, see the [Tabs Block](/examples/custom-schema/tabs-block) and [Stepper Block](/examples/custom-schema/stepper-block) examples. + +## Creating a Layout Block + +Use the `createReactBlockSpec` function to create a layout block, just like a [Custom Block](/docs/features/custom-schemas/custom-blocks). For the panel below, we set `content` to `"none"` and `layoutOnly` to `true`, so the panel's child blocks go inside it: + +```tsx +import { createReactBlockSpec } from "@blocknote/react"; + +export const createPanel = createReactBlockSpec( + { + type: "panel", + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { + renderFrame: (props) => ( +
+ ), + }, +); +``` + +### Block Config + +The block config defines the content and child blocks your layout block can hold: + +`content:` Must be `"none"` for a layout block. A layout block holds only its child blocks. For a block with its own text and child blocks, see [Layout Blocks and Block Frames](#layout-blocks-and-block-frames). + +`layoutOnly:` Set to `true` to put the block's child blocks inside it. Without it, a block's child blocks are indented below it. A layout block accepts any block by default. You can also restrict it to specific layout block types, as explained in [Restricting Children](#restricting-children). + +`propSchema:` Defines the layout block's props, just like for other custom blocks. Use these to customize its appearance or behavior. + +### Block Implementation + +`renderFrame:` Your React component draws the layout block's frame. A layout block has no content of its own, so it has no `render`, only this frame. `renderFrame` takes the same props as for [other blocks](/docs/features/custom-schemas/custom-blocks#block-frames), but it must always draw the frame: it must not return `null`. Attach `slotRef` to the element that holds the child blocks. You can add icons, buttons, or other elements around this element: + +```tsx +renderFrame: (props) => ( +
+ 💡 +
+
+), +``` + +You can style the component with CSS, just like any other React component: + +```css +.panel { + display: flex; + gap: 12px; + padding: 16px; + border-left: 4px solid #507aff; + border-radius: 6px; +} +``` + +## Adding Layout Blocks to the Editor + +Add your layout block to a [BlockNote schema](/docs/features/custom-schemas#creating-your-own-schema): + +```typescript +import { BlockNoteSchema } from "@blocknote/core"; +import { createPanel } from "./Panel"; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + panel: createPanel(), + }, +}); +``` + +You can then create an editor with this schema, as explained on the [Custom Schemas](/docs/features/custom-schemas) page. Use `children` to set the blocks inside a panel: + +```typescript +import { useCreateBlockNote } from "@blocknote/react"; + +const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "panel", + children: [ + { type: "heading", content: "Getting started" }, + { type: "paragraph", content: "Follow these steps to get set up." }, + { type: "checkListItem", content: "Create an account" }, + ], + }, + ], +}); +``` + +If you create a panel without specifying its children, it starts with an empty paragraph. To let users insert panels themselves, add a [custom Slash Menu item](/docs/react/components/suggestion-menus#changing-slash-menu-items), as shown in the demo. + +To add blocks to an existing layout block, see [Inserting Blocks](/docs/reference/editor/manipulating-content#inserting-blocks). + +## Layout Blocks and Block Frames + +Every custom block can have a [frame](/docs/features/custom-schemas/custom-blocks#block-frames), drawn by `renderFrame` around the block's content and its child blocks. A layout block is a block that has only a frame. The table shows the differences: + +| | Block with a frame | Layout block | +| --- | --- | --- | +| Example | [Alert with a title and a body](/examples/custom-schema/alert-block-with-title-and-body) | [Panel](/examples/custom-schema/layout-block), [tab set](/examples/custom-schema/tabs-block), [stepper](/examples/custom-schema/stepper-block), [column list](/examples/basic/multi-column) | +| Config | Any `content`, usually `"inline"` or `"plain"` | `content: "none"` and `layoutOnly: true` | +| Content | Its own text, drawn by `render` | None | +| Implementation | `render` (mandatory) draws the content. `renderFrame` (optional) draws the frame. It can return `null` to show the block without a frame | `renderFrame` (mandatory) draws the frame. It must always draw a frame: if it returns `null`, export fails | +| Child blocks | Any blocks | Any blocks, or only the layout block types in [`children.allow`](#restricting-children) | + +Use a layout block when the block only holds and arranges other blocks. Use a block with a frame when the block has its own text, for example a title. + +## Restricting Children + +For structured layouts, you can limit a layout block to specific layout block types, with these options in the block config: + +`children.allow:` Accepts `"blocks"` (the default) or an array of layout block type names. At this moment, you cannot list regular block types such as `"paragraph"` individually. + +`children.min:` The minimum number of children, for an `allow` list only. Defaults to `1`. + +`children.dissolve:` Whether a layout block with too few children dissolves into what they hold, for an `allow` list only. Defaults to `true`, like a column list with one column left. Set `false` to keep the layout block and its empty children, like a tab set. + +`children.exclude:` For a layout block that accepts any blocks, the layout block types it does not hold. Only layout block types can be excluded, not regular block types such as `"paragraph"`. + +`placeable:` Set to `"namedOnly"` to restrict a layout block to parents that name it in `children.allow`. Defaults to `"anywhere"`. + +The [Tabs Block](/examples/custom-schema/tabs-block) and [Stepper Block](/examples/custom-schema/stepper-block) examples use these options. A tab set holds only `tab` blocks, and a stepper holds only `step` blocks. Both set `dissolve: false`, so they stay when the user removes their last tab or step. A `tab` and a `step` are `placeable: "namedOnly"`, so they can only be in a tab set or a stepper. + +These options need `layoutOnly: true`. Other blocks can always have child blocks of any type, so for them `children` can only be the default, `{ allow: "blocks" }`. + +For built-in column blocks, see [Multi-Column Layouts](/docs/foundations/document-structure#column-blocks). + +## Exporting Layout Blocks + +To export a document with your layout block to [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), [PDF](/docs/features/export/pdf), [Typst](/docs/features/export/typst) or [email](/docs/features/export/email), give the exporter a mapping for the block. The mapping for a layout block must be a `{ withChildren }` mapping. A function mapping causes an error, because the exporter does not know where the child blocks go. + +`withChildren` gets the child blocks, which the exporter has already converted. Put them inside the result for the block. For example, for Typst: + +```typescript +new TypstExporter(schema, { + ...typstDefaultSchemaMappings, + blockMapping: { + ...typstDefaultSchemaMappings.blockMapping, + panel: { + withChildren: (block, exporter, nestingLevel, numberedListIndex, children) => + // Draws a box around the child blocks. Each child block is Typst + // markup, and a blank line keeps them as separate blocks. + `#block(stroke: 1pt, inset: 8pt)[${children.join("\n\n")}]`, + }, + }, +}); +``` + +The other exporters work the same way, but give `children` in their own format, for example `docx` elements for DOCX. diff --git a/docs/content/docs/features/custom-schemas/source-with-preview.mdx b/docs/content/docs/features/custom-schemas/source-with-preview.mdx index 03dd18c51e..1acfe19827 100644 --- a/docs/content/docs/features/custom-schemas/source-with-preview.mdx +++ b/docs/content/docs/features/custom-schemas/source-with-preview.mdx @@ -62,7 +62,7 @@ return ( A few more props customize the states: `errorPreview` for the compact error state shown in place of the preview, `emptySourcePlaceholder` for when the source is empty (a string customizes the default placeholder's text, an element — e.g. the exported `PreviewPlaceholder` with your own icon — replaces it entirely), and `sourcePlaceholder` for the popup input's placeholder. See the `SourceWithPreviewProps` type for the full list. -**3. The spec's `meta`**, opting into the popup: +**3. The spec's `meta`**, opting into the popup, and its `experimental_keyboard`: ```tsx const createMyBlockSpec = createReactBlockSpec(createMyBlockConfig, { @@ -70,10 +70,12 @@ const createMyBlockSpec = createReactBlockSpec(createMyBlockConfig, { code: true, // Marks the block as rendering a preview with an editable source popup. hasPreview: true, - // What Enter does while the popup is open: "enter" inserts a newline - // (multiline sources, like diagrams), "shift+enter" closes the popup - // (single-line sources, like math). - hardBreakShortcut: "enter", + }, + // What Enter does while the popup is open: "line-break" inserts a newline + // (multiline sources, like diagrams). Without it, Enter closes the popup + // (single-line sources, like math). + experimental_keyboard: { + enter: "line-break", }, render: MyBlockPreview, }); diff --git a/docs/content/docs/features/export/typst.mdx b/docs/content/docs/features/export/typst.mdx index 4a5e0ddaa3..fecee10e45 100644 --- a/docs/content/docs/features/export/typst.mdx +++ b/docs/content/docs/features/export/typst.mdx @@ -120,6 +120,11 @@ For a block with inline content, render it the way the default mappings do: `exporter.transformInlineContent(block.content).join("")` (inline results are markup strings, so plain concatenation composes them). +A `{ withChildren }` mapping places the block's children itself +([layout blocks](/docs/features/custom-schemas/layout-blocks) must use +one). It gets the children as rendered markup strings: join them with a blank +line, `children.join("\n\n")`, so each stays its own block, since a single +`\n` is only a soft break in Typst markup. ### Math & diagram blocks diff --git a/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx index bc05b7596a..3bb3091225 100644 --- a/docs/content/docs/reference/editor/manipulating-content.mdx +++ b/docs/content/docs/reference/editor/manipulating-content.mdx @@ -173,6 +173,11 @@ editor.insertBlocks( ); ``` +`placement` says where the new blocks go, relative to the reference block: + +- `"before"` and `"after"`: as its siblings, directly before or after it. +- `"first-child"` and `"last-child"`: as its children, at the start or at the end. The reference block does not need to have children yet. + ### Updating Blocks #### Modifying Existing Blocks diff --git a/docs/package.json b/docs/package.json index 2fdeefb332..5de539654c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -113,7 +113,10 @@ "yjs": "^13.6.27", "zod": "^4.3.5", "@blocknote/xl-typst-exporter": "workspace:*", - "@blocknote/xl-typst-compiler": "workspace:*" + "@blocknote/xl-typst-compiler": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2" }, "devDependencies": { "@blocknote/code-block": "workspace:*", diff --git a/examples/01-basic/01-minimal/vite.config.ts b/examples/01-basic/01-minimal/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/01-minimal/vite.config.ts +++ b/examples/01-basic/01-minimal/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/02-block-objects/vite.config.ts b/examples/01-basic/02-block-objects/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/02-block-objects/vite.config.ts +++ b/examples/01-basic/02-block-objects/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/03-multi-column/vite.config.ts b/examples/01-basic/03-multi-column/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/03-multi-column/vite.config.ts +++ b/examples/01-basic/03-multi-column/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/04-default-blocks/vite.config.ts b/examples/01-basic/04-default-blocks/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/04-default-blocks/vite.config.ts +++ b/examples/01-basic/04-default-blocks/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/05-removing-default-blocks/vite.config.ts b/examples/01-basic/05-removing-default-blocks/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/05-removing-default-blocks/vite.config.ts +++ b/examples/01-basic/05-removing-default-blocks/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/06-block-manipulation/vite.config.ts b/examples/01-basic/06-block-manipulation/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/06-block-manipulation/vite.config.ts +++ b/examples/01-basic/06-block-manipulation/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/07-selection-blocks/vite.config.ts b/examples/01-basic/07-selection-blocks/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/07-selection-blocks/vite.config.ts +++ b/examples/01-basic/07-selection-blocks/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/08-ariakit/vite.config.ts b/examples/01-basic/08-ariakit/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/08-ariakit/vite.config.ts +++ b/examples/01-basic/08-ariakit/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/09-shadcn/vite.config.ts b/examples/01-basic/09-shadcn/vite.config.ts index c990876056..6bfee196fb 100644 --- a/examples/01-basic/09-shadcn/vite.config.ts +++ b/examples/01-basic/09-shadcn/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -25,11 +25,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/10-localization/vite.config.ts b/examples/01-basic/10-localization/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/10-localization/vite.config.ts +++ b/examples/01-basic/10-localization/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/11-custom-placeholder/vite.config.ts b/examples/01-basic/11-custom-placeholder/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/11-custom-placeholder/vite.config.ts +++ b/examples/01-basic/11-custom-placeholder/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/12-multi-editor/vite.config.ts b/examples/01-basic/12-multi-editor/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/12-multi-editor/vite.config.ts +++ b/examples/01-basic/12-multi-editor/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/13-custom-paste-handler/vite.config.ts b/examples/01-basic/13-custom-paste-handler/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/13-custom-paste-handler/vite.config.ts +++ b/examples/01-basic/13-custom-paste-handler/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/14-editor-scrollable/vite.config.ts b/examples/01-basic/14-editor-scrollable/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/14-editor-scrollable/vite.config.ts +++ b/examples/01-basic/14-editor-scrollable/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/15-shadowdom/vite.config.ts b/examples/01-basic/15-shadowdom/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/15-shadowdom/vite.config.ts +++ b/examples/01-basic/15-shadowdom/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/16-read-only-editor/vite.config.ts b/examples/01-basic/16-read-only-editor/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/16-read-only-editor/vite.config.ts +++ b/examples/01-basic/16-read-only-editor/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/17-no-trailing-block/vite.config.ts b/examples/01-basic/17-no-trailing-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/17-no-trailing-block/vite.config.ts +++ b/examples/01-basic/17-no-trailing-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/01-basic/testing/vite.config.ts b/examples/01-basic/testing/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/01-basic/testing/vite.config.ts +++ b/examples/01-basic/testing/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/02-backend/01-file-uploading/vite.config.ts b/examples/02-backend/01-file-uploading/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/02-backend/01-file-uploading/vite.config.ts +++ b/examples/02-backend/01-file-uploading/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/02-backend/02-saving-loading/vite.config.ts b/examples/02-backend/02-saving-loading/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/02-backend/02-saving-loading/vite.config.ts +++ b/examples/02-backend/02-saving-loading/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/02-backend/03-s3/vite.config.ts b/examples/02-backend/03-s3/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/02-backend/03-s3/vite.config.ts +++ b/examples/02-backend/03-s3/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/02-backend/04-rendering-static-documents/vite.config.ts b/examples/02-backend/04-rendering-static-documents/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/02-backend/04-rendering-static-documents/vite.config.ts +++ b/examples/02-backend/04-rendering-static-documents/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/01-ui-elements-remove/vite.config.ts b/examples/03-ui-components/01-ui-elements-remove/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/01-ui-elements-remove/vite.config.ts +++ b/examples/03-ui-components/01-ui-elements-remove/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts b/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts +++ b/examples/03-ui-components/02-formatting-toolbar-buttons/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx index eeb8e38887..155318652d 100644 --- a/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx +++ b/examples/03-ui-components/03-formatting-toolbar-block-type-items/src/App.tsx @@ -38,7 +38,7 @@ const CustomFormattingToolbar = () => { // Sets the items in the Block Type Select. blockTypeSelectItems={[ // Gets the default Block Type Select items. - ...blockTypeSelectItems(editor.dictionary), + ...blockTypeSelectItems(editor), // Adds an item for the Alert block. { name: "Alert", diff --git a/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts b/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts +++ b/examples/03-ui-components/03-formatting-toolbar-block-type-items/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/04-side-menu-buttons/vite.config.ts b/examples/03-ui-components/04-side-menu-buttons/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/04-side-menu-buttons/vite.config.ts +++ b/examples/03-ui-components/04-side-menu-buttons/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts b/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts +++ b/examples/03-ui-components/05-side-menu-drag-handle-items/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts b/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts +++ b/examples/03-ui-components/06-suggestion-menus-slash-menu-items/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts b/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts +++ b/examples/03-ui-components/07-suggestion-menus-slash-menu-component/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts +++ b/examples/03-ui-components/08-suggestion-menus-emoji-picker-columns/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts +++ b/examples/03-ui-components/09-suggestion-menus-emoji-picker-component/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts b/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts +++ b/examples/03-ui-components/10-suggestion-menus-grid-mentions/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/11-uppy-file-panel/vite.config.ts b/examples/03-ui-components/11-uppy-file-panel/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/11-uppy-file-panel/vite.config.ts +++ b/examples/03-ui-components/11-uppy-file-panel/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts b/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts +++ b/examples/03-ui-components/12-static-formatting-toolbar/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx index 7a1b7d6b6d..b4833393a2 100644 --- a/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx +++ b/examples/03-ui-components/13-custom-ui/src/MUIFormattingToolbar.tsx @@ -115,8 +115,8 @@ function MUIBlockTypeSelect() { // Gets the default items for the select. const defaultBlockTypeSelectItems = useMemo( - () => blockTypeSelectItems(editor.dictionary), - [editor.dictionary], + () => blockTypeSelectItems(editor), + [editor], ); // Gets the selected item. diff --git a/examples/03-ui-components/13-custom-ui/vite.config.ts b/examples/03-ui-components/13-custom-ui/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/13-custom-ui/vite.config.ts +++ b/examples/03-ui-components/13-custom-ui/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/15-advanced-tables/vite.config.ts b/examples/03-ui-components/15-advanced-tables/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/15-advanced-tables/vite.config.ts +++ b/examples/03-ui-components/15-advanced-tables/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts b/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts +++ b/examples/03-ui-components/16-link-toolbar-buttons/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/17-advanced-tables-2/vite.config.ts b/examples/03-ui-components/17-advanced-tables-2/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/17-advanced-tables-2/vite.config.ts +++ b/examples/03-ui-components/17-advanced-tables-2/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/18-drag-n-drop/vite.config.ts b/examples/03-ui-components/18-drag-n-drop/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/18-drag-n-drop/vite.config.ts +++ b/examples/03-ui-components/18-drag-n-drop/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts b/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts +++ b/examples/03-ui-components/19-suggestion-menus-grouping-ordering/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/03-ui-components/20-portal-elements/vite.config.ts b/examples/03-ui-components/20-portal-elements/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/03-ui-components/20-portal-elements/vite.config.ts +++ b/examples/03-ui-components/20-portal-elements/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/04-theming/01-theming-dom-attributes/vite.config.ts b/examples/04-theming/01-theming-dom-attributes/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/04-theming/01-theming-dom-attributes/vite.config.ts +++ b/examples/04-theming/01-theming-dom-attributes/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/04-theming/02-changing-font/vite.config.ts b/examples/04-theming/02-changing-font/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/04-theming/02-changing-font/vite.config.ts +++ b/examples/04-theming/02-changing-font/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/04-theming/03-theming-css/vite.config.ts b/examples/04-theming/03-theming-css/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/04-theming/03-theming-css/vite.config.ts +++ b/examples/04-theming/03-theming-css/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/04-theming/04-theming-css-variables/vite.config.ts b/examples/04-theming/04-theming-css-variables/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/04-theming/04-theming-css-variables/vite.config.ts +++ b/examples/04-theming/04-theming-css-variables/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/04-theming/05-theming-css-variables-code/vite.config.ts b/examples/04-theming/05-theming-css-variables-code/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/04-theming/05-theming-css-variables-code/vite.config.ts +++ b/examples/04-theming/05-theming-css-variables-code/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/04-theming/06-code-block/vite.config.ts b/examples/04-theming/06-code-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/04-theming/06-code-block/vite.config.ts +++ b/examples/04-theming/06-code-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/04-theming/07-custom-code-block/vite.config.ts b/examples/04-theming/07-custom-code-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/04-theming/07-custom-code-block/vite.config.ts +++ b/examples/04-theming/07-custom-code-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts b/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts +++ b/examples/05-interoperability/01-converting-blocks-to-html/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts b/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts +++ b/examples/05-interoperability/02-converting-blocks-from-html/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts b/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts +++ b/examples/05-interoperability/03-converting-blocks-to-md/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts b/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts +++ b/examples/05-interoperability/04-converting-blocks-from-md/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts b/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts b/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts +++ b/examples/05-interoperability/06-converting-blocks-to-docx/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts b/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts +++ b/examples/05-interoperability/07-converting-blocks-to-odt/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts b/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts +++ b/examples/05-interoperability/08-converting-blocks-to-react-email/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts b/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts +++ b/examples/05-interoperability/09-blocks-to-html-static-render/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/10-static-html-render/vite.config.ts b/examples/05-interoperability/10-static-html-render/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/10-static-html-render/vite.config.ts +++ b/examples/05-interoperability/10-static-html-render/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts b/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts +++ b/examples/05-interoperability/11-converting-blocks-to-pdf-react-pdf-deprecated/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/01-alert-block/.bnexample.json b/examples/06-custom-schema/01-alert-block/.bnexample.json index 1354e61187..3dd6aed529 100644 --- a/examples/06-custom-schema/01-alert-block/.bnexample.json +++ b/examples/06-custom-schema/01-alert-block/.bnexample.json @@ -2,13 +2,7 @@ "playground": true, "docs": true, "author": "matthewlipski", - "tags": [ - "Intermediate", - "Blocks", - "Custom Schemas", - "Suggestion Menus", - "Slash Menu" - ], + "tags": ["Intermediate", "Blocks", "Custom Schemas"], "dependencies": { "@mantine/core": "^9.0.2", "react-icons": "^5.5.0" diff --git a/examples/06-custom-schema/01-alert-block/README.md b/examples/06-custom-schema/01-alert-block/README.md index 831ca1993b..a8e5d6286d 100644 --- a/examples/06-custom-schema/01-alert-block/README.md +++ b/examples/06-custom-schema/01-alert-block/README.md @@ -6,5 +6,7 @@ In this example, we create a custom `Alert` block which is used to emphasize tex **Relevant Docs:** +- [Alert Block with Full UX](/examples/custom-schema/alert-block-full-ux) +- [Alert Block with Title and Body](/examples/custom-schema/alert-block-with-title-and-body) - [Custom Blocks](/docs/features/custom-schemas/custom-blocks) - [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/01-alert-block/vite.config.ts b/examples/06-custom-schema/01-alert-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/01-alert-block/vite.config.ts +++ b/examples/06-custom-schema/01-alert-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts b/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts +++ b/examples/06-custom-schema/02-suggestion-menus-mentions/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/03-font-style/vite.config.ts b/examples/06-custom-schema/03-font-style/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/03-font-style/vite.config.ts +++ b/examples/06-custom-schema/03-font-style/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/04-pdf-file-block/vite.config.ts b/examples/06-custom-schema/04-pdf-file-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/04-pdf-file-block/vite.config.ts +++ b/examples/06-custom-schema/04-pdf-file-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/05-alert-block-full-ux/README.md b/examples/06-custom-schema/05-alert-block-full-ux/README.md index 53368fc687..40dc1d5d73 100644 --- a/examples/06-custom-schema/05-alert-block-full-ux/README.md +++ b/examples/06-custom-schema/05-alert-block-full-ux/README.md @@ -7,6 +7,7 @@ In this example, we create a custom `Alert` block which is used to emphasize tex **Relevant Docs:** - [Minimal Alert Block Example](/examples/custom-schema/alert-block) +- [Alert Block with Title and Body](/examples/custom-schema/alert-block-with-title-and-body) - [Changing Slash Menu Items](/docs/react/components/suggestion-menus) - [Changing Block Type Select Items](/docs/react/components/formatting-toolbar) - [Custom Blocks](/docs/features/custom-schemas/custom-blocks) diff --git a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx index 931c0e1169..409d7a1e97 100644 --- a/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx +++ b/examples/06-custom-schema/05-alert-block-full-ux/src/App.tsx @@ -42,7 +42,7 @@ const CustomFormattingToolbar = () => { // Sets the items in the Block Type Select. blockTypeSelectItems={[ // Gets the default Block Type Select items. - ...blockTypeSelectItems(editor.dictionary), + ...blockTypeSelectItems(editor), // Adds an item for the Alert block. { name: "Alert", diff --git a/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts b/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts +++ b/examples/06-custom-schema/05-alert-block-full-ux/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json b/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json deleted file mode 100644 index 6d4a02dd52..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/.bnexample.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "playground": true, - "docs": true, - "author": "matthewlipski", - "tags": ["Basic"] -} diff --git a/examples/06-custom-schema/06-toggleable-blocks/README.md b/examples/06-custom-schema/06-toggleable-blocks/README.md deleted file mode 100644 index 4bbaaa70e1..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Toggleable Custom Blocks - -This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done using the use the `ToggleWrapper` component from `@blocknote/react`. - -**Relevant Docs:** - -- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) -- [Editor Setup](/docs/getting-started/editor-setup) -- [Default Schema](/docs/features/blocks) diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx b/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx deleted file mode 100644 index 244661f841..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { defaultProps } from "@blocknote/core"; -import { createReactBlockSpec, ToggleWrapper } from "@blocknote/react"; - -// The Toggle block that we want to add to our editor. -export const ToggleBlock = createReactBlockSpec( - { - type: "toggle", - propSchema: { - ...defaultProps, - }, - content: "inline", - }, - { - render: (props) => ( - // The `ToggleWrapper` component renders a button on the left which - // toggles the visibility of the block's children. It also adds a button - // to add child blocks if there are none. By default, it uses local - // storage to remember the toggled state based on the block ID, but you can pass a custom - // `toggledState` prop to use a different storage mechanism. - -

- - ), - }, -); diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts b/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts deleted file mode 100644 index bc2d8a36f3..0000000000 --- a/examples/06-custom-schema/06-toggleable-blocks/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/examples/06-custom-schema/07-configuring-blocks/vite.config.ts b/examples/06-custom-schema/07-configuring-blocks/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/07-configuring-blocks/vite.config.ts +++ b/examples/06-custom-schema/07-configuring-blocks/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/08-non-editable-block/vite.config.ts b/examples/06-custom-schema/08-non-editable-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/08-non-editable-block/vite.config.ts +++ b/examples/06-custom-schema/08-non-editable-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/09-layout-block/.bnexample.json b/examples/06-custom-schema/09-layout-block/.bnexample.json new file mode 100644 index 0000000000..3de7330631 --- /dev/null +++ b/examples/06-custom-schema/09-layout-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-layout-block/README.md b/examples/06-custom-schema/09-layout-block/README.md new file mode 100644 index 0000000000..3ed1f82753 --- /dev/null +++ b/examples/06-custom-schema/09-layout-block/README.md @@ -0,0 +1,18 @@ +# Layout Block + +In this example, we create a custom `Panel` block that holds other blocks as its body, such as a panel containing headings and paragraphs. + +The block sets `layoutOnly: true` on `BlockConfig`, which makes it a layout block: it has no content of its own, only child blocks. It draws its box in `renderFrame`, and its child blocks go where `slotRef` is attached. At runtime, they are in `block.children`. + +We also wire up a Slash Menu item to insert the panel. + +**Try it out:** + +- Press the "/" key inside the panel's body and add a code block, heading, or list. +- Insert a new panel via the Slash Menu (search "panel"). + +**Relevant Docs:** + +- [Layout Blocks](/docs/features/custom-schemas/layout-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/06-toggleable-blocks/index.html b/examples/06-custom-schema/09-layout-block/index.html similarity index 89% rename from examples/06-custom-schema/06-toggleable-blocks/index.html rename to examples/06-custom-schema/09-layout-block/index.html index bdac14de54..29dafcdc28 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/index.html +++ b/examples/06-custom-schema/09-layout-block/index.html @@ -5,7 +5,7 @@ name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" /> - Toggleable Custom Blocks + Layout Block diff --git a/examples/06-custom-schema/06-toggleable-blocks/main.tsx b/examples/06-custom-schema/09-layout-block/main.tsx similarity index 100% rename from examples/06-custom-schema/06-toggleable-blocks/main.tsx rename to examples/06-custom-schema/09-layout-block/main.tsx diff --git a/examples/06-custom-schema/06-toggleable-blocks/package.json b/examples/06-custom-schema/09-layout-block/package.json similarity index 85% rename from examples/06-custom-schema/06-toggleable-blocks/package.json rename to examples/06-custom-schema/09-layout-block/package.json index caed51c711..374c79e5b0 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/package.json +++ b/examples/06-custom-schema/09-layout-block/package.json @@ -1,5 +1,5 @@ { - "name": "@blocknote/example-custom-schema-toggleable-blocks", + "name": "@blocknote/example-custom-schema-layout-block", "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", "type": "module", "private": true, @@ -19,7 +19,8 @@ "@mantine/core": "^9.0.2", "@mantine/hooks": "^9.0.2", "react": "^19.2.3", - "react-dom": "^19.2.3" + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" }, "devDependencies": { "@types/react": "^19.2.3", diff --git a/examples/06-custom-schema/09-layout-block/src/App.tsx b/examples/06-custom-schema/09-layout-block/src/App.tsx new file mode 100644 index 0000000000..9d48fd0064 --- /dev/null +++ b/examples/06-custom-schema/09-layout-block/src/App.tsx @@ -0,0 +1,92 @@ +import { BlockNoteSchema } 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 { RiChatQuoteLine } from "react-icons/ri"; + +import { createPanel } from "./Panel"; +import "./styles.css"; + +// Schema with the default blocks plus our custom Panel layout block. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + panel: createPanel(), + }, +}); + +// Slash menu item to insert a Panel. Inserting one with no children fills +// it with an empty paragraph: a layout block always holds at least one child. +function insertPanel(editor: typeof schema.BlockNoteEditor) { + return { + title: "Panel", + subtext: "Layout block that wraps other blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "panel", + }), + aliases: ["panel", "layout", "container", "box", "group", "section"], + group: "Layout", + icon: , + }; +} + +export default function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "Welcome! This demo shows the new layout block kind.", + }, + { + type: "panel", + children: [ + { + type: "heading", + props: { level: 3 }, + content: "More than paragraphs", + }, + { + type: "paragraph", + content: "Panels can hold any block as their body.", + }, + { + type: "paragraph", + content: + "Try pressing '/' inside this panel to add a heading or code block.", + }, + ], + }, + { + type: "paragraph", + content: "Press '/' anywhere to insert a new Panel.", + }, + { + type: "paragraph", + }, + ], + }); + + return ( + + + filterSuggestionItems( + [...getDefaultReactSlashMenuItems(editor), insertPanel(editor)], + query, + ) + } + /> + + ); +} diff --git a/examples/06-custom-schema/09-layout-block/src/Panel.tsx b/examples/06-custom-schema/09-layout-block/src/Panel.tsx new file mode 100644 index 0000000000..58c093c34f --- /dev/null +++ b/examples/06-custom-schema/09-layout-block/src/Panel.tsx @@ -0,0 +1,17 @@ +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +export const createPanel = createReactBlockSpec( + { + type: "panel", + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { + // With no content of its own, the panel draws only its frame. The slot + // (`slotRef`) receives its child blocks. + renderFrame: (props) =>

, + }, +); diff --git a/examples/06-custom-schema/09-layout-block/src/styles.css b/examples/06-custom-schema/09-layout-block/src/styles.css new file mode 100644 index 0000000000..6f3837c013 --- /dev/null +++ b/examples/06-custom-schema/09-layout-block/src/styles.css @@ -0,0 +1,11 @@ +/* A neutral box: the panel only groups blocks, it has no meaning of its own + (compare the Alert block, whose box shows its type). */ +.panel { + border: 1px solid var(--panel-border, #d4d4d8); + border-radius: 6px; + padding: 12px 16px; +} + +[data-color-scheme="dark"] .panel { + --panel-border: #3f3f46; +} diff --git a/examples/06-custom-schema/06-toggleable-blocks/tsconfig.json b/examples/06-custom-schema/09-layout-block/tsconfig.json similarity index 100% rename from examples/06-custom-schema/06-toggleable-blocks/tsconfig.json rename to examples/06-custom-schema/09-layout-block/tsconfig.json diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts b/examples/06-custom-schema/09-layout-block/vite-env.d.ts similarity index 100% rename from examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts rename to examples/06-custom-schema/09-layout-block/vite-env.d.ts diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts b/examples/06-custom-schema/09-layout-block/vite.config.ts similarity index 86% rename from examples/06-custom-schema/06-toggleable-blocks/vite.config.ts rename to examples/06-custom-schema/09-layout-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts +++ b/examples/06-custom-schema/09-layout-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/09-math-block/src/App.tsx b/examples/06-custom-schema/09-math-block/src/App.tsx index 554b3402bb..def4ecf3b1 100644 --- a/examples/06-custom-schema/09-math-block/src/App.tsx +++ b/examples/06-custom-schema/09-math-block/src/App.tsx @@ -84,7 +84,7 @@ export default function App() { formattingToolbar={() => ( diff --git a/examples/06-custom-schema/09-math-block/vite.config.ts b/examples/06-custom-schema/09-math-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/09-math-block/vite.config.ts +++ b/examples/06-custom-schema/09-math-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/10-diagram-block/src/App.tsx b/examples/06-custom-schema/10-diagram-block/src/App.tsx index c96af84261..57998b3f8f 100644 --- a/examples/06-custom-schema/10-diagram-block/src/App.tsx +++ b/examples/06-custom-schema/10-diagram-block/src/App.tsx @@ -70,7 +70,7 @@ export default function App() { formattingToolbar={() => ( diff --git a/examples/06-custom-schema/10-diagram-block/vite.config.ts b/examples/06-custom-schema/10-diagram-block/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/10-diagram-block/vite.config.ts +++ b/examples/06-custom-schema/10-diagram-block/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/11-source-with-preview/src/App.tsx b/examples/06-custom-schema/11-source-with-preview/src/App.tsx index 3485907844..0088d73a82 100644 --- a/examples/06-custom-schema/11-source-with-preview/src/App.tsx +++ b/examples/06-custom-schema/11-source-with-preview/src/App.tsx @@ -114,10 +114,11 @@ const createCSVTableBlockSpec = createReactBlockSpec( // Marks the block as rendering a preview with an editable source popup // (driven by an editor-wide extension - nothing to register). hasPreview: true, - // Enter inserts a newline while the popup is open (multiline source); - // use "shift+enter" for single-line sources, where Enter closes the - // popup instead. - hardBreakShortcut: "enter", + }, + // Enter inserts a newline while the popup is open (multiline source). + // Without this, Enter closes the popup, as for single-line sources. + experimental_keyboard: { + enter: "line-break", }, render: CSVTablePreview, }, diff --git a/examples/06-custom-schema/11-source-with-preview/vite.config.ts b/examples/06-custom-schema/11-source-with-preview/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/11-source-with-preview/vite.config.ts +++ b/examples/06-custom-schema/11-source-with-preview/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/.bnexample.json b/examples/06-custom-schema/13-alert-block-with-title-and-body/.bnexample.json new file mode 100644 index 0000000000..467ebd02e5 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/.bnexample.json @@ -0,0 +1,10 @@ +{ + "playground": true, + "docs": true, + "author": "nickthesick", + "tags": ["Intermediate", "Blocks", "Custom Schemas"], + "dependencies": { + "@mantine/core": "^9.0.2", + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/README.md b/examples/06-custom-schema/13-alert-block-with-title-and-body/README.md new file mode 100644 index 0000000000..57ea2eb5e9 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/README.md @@ -0,0 +1,22 @@ +# Alert Block with Title and Body + +In this example, we extend the [`Alert` block](/examples/custom-schema/alert-block) with a title and a body: the alert's text is its title, and its child blocks are the body. + +The block has `content: "inline"`, so the title is ordinary inline content: formatting, links, and multiplayer cursors all work. Its child blocks are in `block.children` at runtime. `render` draws the title row, the same as in the Alert example, and `renderFrame` draws the alert's box around the title and the body together. Its `experimental_keyboard` settings keep the body inside the alert: Enter in the title adds a first child block, Shift-Tab doesn't move child blocks out, and Enter in an empty last child block leaves the alert. + +**Try it out:** + +- Click the icon to change the alert type. +- Press Enter at the end of the alert's title to add a block to its body. +- Press Enter in an empty last block of the body to leave the alert. +- Press Backspace at the start of the first block in the body to merge it back into the title. +- Press "/" in the body and add a code block, heading, or list. + +**Relevant Docs:** + +- [Minimal Alert Block Example](/examples/custom-schema/alert-block) +- [Alert Block with Full UX](/examples/custom-schema/alert-block-full-ux) +- [Block Frames](/docs/features/custom-schemas/custom-blocks#block-frames) +- [Layout Blocks and Block Frames](/docs/features/custom-schemas/layout-blocks#layout-blocks-and-block-frames) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/index.html b/examples/06-custom-schema/13-alert-block-with-title-and-body/index.html new file mode 100644 index 0000000000..1b8395fdb7 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/index.html @@ -0,0 +1,17 @@ + + + + + Alert Block with Title and Body + + + +
+ + + diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/main.tsx b/examples/06-custom-schema/13-alert-block-with-title-and-body/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/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/13-alert-block-with-title-and-body/package.json b/examples/06-custom-schema/13-alert-block-with-title-and-body/package.json new file mode 100644 index 0000000000..6a60f32370 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-alert-block-with-title-and-body", + "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/13-alert-block-with-title-and-body/src/Alert.tsx b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/Alert.tsx new file mode 100644 index 0000000000..800b30d45a --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/Alert.tsx @@ -0,0 +1,135 @@ +import { defaultProps } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; +import { Menu } from "@mantine/core"; +import { MdCancel, MdCheckCircle, MdError, MdInfo } from "react-icons/md"; + +import "./styles.css"; + +// The types of alerts that users can choose from. +export const alertTypes = [ + { + title: "Warning", + value: "warning", + icon: MdError, + color: "#e69819", + backgroundColor: { + light: "#fff6e6", + dark: "#805d20", + }, + }, + { + title: "Error", + value: "error", + icon: MdCancel, + color: "#d80d0d", + backgroundColor: { + light: "#ffe6e6", + dark: "#802020", + }, + }, + { + title: "Info", + value: "info", + icon: MdInfo, + color: "#507aff", + backgroundColor: { + light: "#e6ebff", + dark: "#203380", + }, + }, + { + title: "Success", + value: "success", + icon: MdCheckCircle, + color: "#0bc10b", + backgroundColor: { + light: "#e6ffe6", + dark: "#208020", + }, + }, +] as const; + +// The Alert block, with a title (its text) and a body (its child blocks). +export const createAlert = createReactBlockSpec( + { + type: "alert", + propSchema: { + textAlignment: defaultProps.textAlignment, + textColor: defaultProps.textColor, + type: { + default: "warning", + values: ["warning", "error", "info", "success"], + }, + }, + content: "inline", + }, + { + // Keeps the body inside the alert: Enter in the title adds a first child + // block, child blocks can't be outdented out of the alert, and Enter in an + // empty last child block leaves the alert. + experimental_keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, + // The title row. The child blocks (the body) go below it. + render: (props) => { + const alertType = alertTypes.find( + (a) => a.value === props.block.props.type, + )!; + const Icon = alertType.icon; + return ( +
+ {/*Icon which opens a menu to choose the Alert type*/} + + +
+ +
+
+ {/*Dropdown to change the Alert type*/} + + Alert Type + + {alertTypes.map((type) => { + const ItemIcon = type.icon; + + return ( + + } + onClick={() => + props.editor.updateBlock(props.block, { + type: "alert", + props: { type: type.value }, + }) + } + > + {type.title} + + ); + })} + +
+ {/*Rich text field for user to type in*/} +
+
+ ); + }, + // The alert's box, around the title row and the body. + renderFrame: (props) => ( +
+
+
+ ), + }, +); diff --git a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/App.tsx similarity index 53% rename from examples/06-custom-schema/06-toggleable-blocks/src/App.tsx rename to examples/06-custom-schema/13-alert-block-with-title-and-body/src/App.tsx index 81877de7e4..52d4b5f6f3 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/App.tsx @@ -1,19 +1,17 @@ -import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { BlockNoteSchema } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; import { useCreateBlockNote } from "@blocknote/react"; -import { ToggleBlock } from "./Toggle"; +import { createAlert } from "./Alert"; // Our schema with block specs, which contain the configs and implementations for // blocks that we want our editor to use. -const schema = BlockNoteSchema.create({ +const schema = BlockNoteSchema.create().extend({ blockSpecs: { - // Adds all default blocks. - ...defaultBlockSpecs, - // Adds the Toggle block. - toggle: ToggleBlock(), + // Creates an instance of the Alert block with a body and adds it to the schema. + alert: createAlert(), }, }); @@ -27,25 +25,18 @@ export default function App() { content: "Welcome to this demo!", }, { - // We set a persistent ID so that the toggled state is preserved - // on reload. - id: "toggle", - type: "toggle", - content: "This is an example toggle", + type: "alert", + content: "This is an example alert", children: [ { type: "paragraph", - content: "This is the first child of the toggle block.", - }, - { - type: "paragraph", - content: "This is the second child of the toggle block.", + content: "This is the body of the alert", }, ], }, { type: "paragraph", - content: "Click the '>' icon to show/hide its children", + content: "Click the '!' icon to change the alert type", }, ], }); diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/src/styles.css b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/styles.css new file mode 100644 index 0000000000..ed2d871ea2 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/src/styles.css @@ -0,0 +1,89 @@ +.alert-frame { + border-radius: 4px; + padding: 4px; +} + +.alert-title-row { + display: flex; + justify-content: center; + align-items: center; + flex-grow: 1; + min-height: 48px; +} + +.alert-frame[data-alert-type="warning"] { + background-color: #fff6e6; +} + +.alert-frame[data-alert-type="error"] { + background-color: #ffe6e6; +} + +.alert-frame[data-alert-type="info"] { + background-color: #e6ebff; +} + +.alert-frame[data-alert-type="success"] { + background-color: #e6ffe6; +} + +[data-color-scheme="dark"] .alert-frame[data-alert-type="warning"] { + background-color: #805d20; +} + +[data-color-scheme="dark"] .alert-frame[data-alert-type="error"] { + background-color: #802020; +} + +[data-color-scheme="dark"] .alert-frame[data-alert-type="info"] { + background-color: #203380; +} + +[data-color-scheme="dark"] .alert-frame[data-alert-type="success"] { + background-color: #208020; +} + +.alert-icon-wrapper { + border-radius: 16px; + display: flex; + justify-content: center; + align-items: center; + margin-left: 12px; + margin-right: 12px; + height: 18px; + width: 18px; + user-select: none; + cursor: pointer; +} + +.alert-icon[data-alert-icon-type="warning"] { + color: #e69819; +} + +.alert-icon[data-alert-icon-type="error"] { + color: #d80d0d; +} + +.alert-icon[data-alert-icon-type="info"] { + color: #507aff; +} + +.alert-icon[data-alert-icon-type="success"] { + color: #0bc10b; +} + +.inline-content { + flex-grow: 1; +} + +.alert-title-row .inline-content { + font-weight: 600; +} + +/* Hides the indent line next to the blocks of the alert's body. Blocks that + are nested deeper in the body keep their line. */ +.alert-frame + .bn-block-group:not(.alert-frame .bn-block-group .bn-block-group) + > .bn-block-outer:not([data-prev-depth-changed])::before { + border-left: none; +} diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/tsconfig.json b/examples/06-custom-schema/13-alert-block-with-title-and-body/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/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/13-alert-block-with-title-and-body/vite-env.d.ts b/examples/06-custom-schema/13-alert-block-with-title-and-body/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/13-alert-block-with-title-and-body/vite.config.ts b/examples/06-custom-schema/13-alert-block-with-title-and-body/vite.config.ts new file mode 100644 index 0000000000..cbf6ff2ffc --- /dev/null +++ b/examples/06-custom-schema/13-alert-block-with-title-and-body/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/14-tabs-block/.bnexample.json b/examples/06-custom-schema/14-tabs-block/.bnexample.json new file mode 100644 index 0000000000..c8a8e4711b --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/.bnexample.json @@ -0,0 +1,12 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Intermediate", "Blocks", "Custom Schemas"], + "dependencies": { + "react-icons": "^5.5.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2" + } +} diff --git a/examples/06-custom-schema/14-tabs-block/README.md b/examples/06-custom-schema/14-tabs-block/README.md new file mode 100644 index 0000000000..6b45d10800 --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/README.md @@ -0,0 +1,46 @@ +# Tabs Block + +A tab set built on the layout block API. `tabs` is a layout block whose +`children` are restricted to `tab` panels, and `tab` is a `placeable: +"namedOnly"` layout block, so a panel can only ever exist inside a tab set — +the schema enforces both. Both blocks are layout blocks (`layoutOnly: true`), so +they declare only `renderFrame`, and the child blocks go where `slotRef` is +attached. + +Each panel holds any block. A panel's label is document content and lives in +its props. Which panel is open is not: it belongs to each reader, so it is kept +outside the document, in `localStorage` keyed by the tab set's id. Switching +tabs is therefore not an undo step and is not sent to collaborators. + +**Try it out:** Click a tab to open it, click the open tab for its menu, and +drag a tab to reorder it. + +## What a tab set needs beyond the layout block API + +**Revealing the panel the caret lands in.** A hidden panel is still part of the +document, so the editor will move content into it: Backspace at the start of +the block after a tab set pulls that block into the last panel, which may not +be the open one. `useRevealCaretPanel` opens whichever panel the caret ends up +in, using `editor.onSelectionChange`, so every route in (Backspace, Delete, +arrow keys, drag and drop, paste) is covered at once. + +**Moving the caret when a tab is clicked.** An explicit switch takes the caret +with it when the caret was inside the set. Otherwise the reveal above would +immediately reopen the panel it was left in. + +**A menu instead of buttons.** Clicking the open tab opens a menu to rename, +move or delete it. It is built from `useComponentsContext()`, so it matches +whichever UI library the editor uses. + +**Drag to reorder.** Tabs are sortable with dnd-kit. A tab keeps its block id +when it moves, so the reader's open tab follows it. + +**Empty tabs stay.** The tab set sets `children.dissolve` to `false`. A tab +is content, not layout: an empty tab is still a tab, and when the last tab is +removed, the tab set stays with a new, empty tab. (A column list dissolves: +an empty column is removed, and one column left is just its blocks.) + +**Relevant Docs:** + +- [Layout Blocks](/docs/features/custom-schemas/layout-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) diff --git a/examples/06-custom-schema/14-tabs-block/index.html b/examples/06-custom-schema/14-tabs-block/index.html new file mode 100644 index 0000000000..46ea6b3c71 --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/index.html @@ -0,0 +1,17 @@ + + + + + Tabs Block + + + +
+ + + diff --git a/examples/06-custom-schema/14-tabs-block/main.tsx b/examples/06-custom-schema/14-tabs-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/14-tabs-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/14-tabs-block/package.json b/examples/06-custom-schema/14-tabs-block/package.json new file mode 100644 index 0000000000..de4c50d3c5 --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/package.json @@ -0,0 +1,34 @@ +{ + "name": "@blocknote/example-custom-schema-tabs-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", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2" + }, + "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/14-tabs-block/src/App.tsx b/examples/06-custom-schema/14-tabs-block/src/App.tsx new file mode 100644 index 0000000000..8e21f96f9d --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/src/App.tsx @@ -0,0 +1,110 @@ +import { BlockNoteSchema } 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 { RiLayoutTopLine } from "react-icons/ri"; + +import { createTab, createTabs } from "./Tabs"; +import "./styles.css"; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + tabs: createTabs(), + tab: createTab(), + }, +}); + +// A tab set is only useful with its panels, so the slash menu inserts both. +function insertTabs(editor: typeof schema.BlockNoteEditor) { + return { + title: "Tabs", + subtext: "A tab strip with one panel per tab", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "tabs", + children: [ + { + type: "tab", + props: { label: "First" }, + children: [{ type: "paragraph", content: "First panel" }], + }, + { + type: "tab", + props: { label: "Second" }, + children: [{ type: "paragraph", content: "Second panel" }], + }, + ], + } as any), + aliases: ["tabs", "tab"], + group: "Layout", + icon: , + }; +} + +export default function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "Tabs built on the layout block API. Each panel holds any block.", + }, + { + // A persistent ID so the open tab is remembered across reloads: the + // choice is stored per tab-set id, and a fresh document would + // otherwise get a fresh one. + id: "tabs-demo", + type: "tabs", + children: [ + { + // Persistent IDs alongside the set's, so the remembered choice + // still names a panel after a reload. + id: "tab-install", + type: "tab", + props: { label: "Install" }, + children: [ + { type: "heading", props: { level: 3 }, content: "Install" }, + { type: "paragraph", content: "Run the installer, then reboot." }, + { type: "bulletListItem", content: "Any block works in here" }, + ], + }, + { + id: "tab-configure", + type: "tab", + props: { label: "Configure" }, + children: [ + { type: "heading", props: { level: 3 }, content: "Configure" }, + { type: "paragraph", content: "Edit the config file." }, + ], + }, + ], + } as any, + { type: "paragraph", content: "Press '/' to insert another tab set." }, + { type: "paragraph" }, + ], + }); + + return ( + + + filterSuggestionItems( + [...getDefaultReactSlashMenuItems(editor), insertTabs(editor)], + query, + ) + } + /> + + ); +} diff --git a/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx b/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx new file mode 100644 index 0000000000..b5105a17bf --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/src/Tabs.tsx @@ -0,0 +1,518 @@ +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { Block, BlockNoteEditor } from "@blocknote/core"; +import { + createReactBlockSpec, + useComponentsContext, + usePortalElement, +} from "@blocknote/react"; +import type React from "react"; +import { + RiArrowLeftLine, + RiArrowRightLine, + RiDeleteBinLine, + RiPencilLine, +} from "react-icons/ri"; +import { useEffect, useState, useSyncExternalStore } from "react"; + +import "./styles.css"; + +// Which tab is open is *view state*, not content: it belongs to the reader, +// not to the document. Block props are part of the document, so putting it +// there would sync it to every collaborator and add an undo step for a +// glance. BlockNote's own toggle blocks keep their state out of the document +// the same way, in `localStorage` keyed by block id. +// +// Nothing is stored until the reader picks a tab: with no entry, the first +// panel is the open one. The strip and the panels render in separate React +// roots, which is why this is a store rather than a context. +const openTabs = new Map(); +const listeners = new Set<() => void>(); + +function storageKey(tabsId: string) { + return `tabs-open-${tabsId}`; +} + +/** The panel the reader opened in this set, if they have opened one. */ +function openTabId(tabsId: string): string | undefined { + const known = openTabs.get(tabsId); + if (known !== undefined) { + return known; + } + try { + return window.localStorage.getItem(storageKey(tabsId)) ?? undefined; + } catch { + // Private windows and blocked site data: fall back to the first panel. + return undefined; + } +} + +function openTab(tabsId: string, tabId: string) { + openTabs.set(tabsId, tabId); + try { + window.localStorage.setItem(storageKey(tabsId), tabId); + } catch { + // Not being able to remember the choice is not worth failing over. + } + listeners.forEach((listener) => listener()); +} + +/** + * The panel that is open in this set: the reader's choice when they have made + * one that still names a panel, and the first panel otherwise. + */ +function useActiveTabId(tabsId: string, tabs: Block[]): string { + const chosen = useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + () => openTabId(tabsId), + () => undefined, + ); + + // `tabs` is empty only while a panel that was just removed renders one last + // time (see `TabPanel`); then no panel is open. Otherwise the schema gives + // the set at least one panel (`min: 1`). + return tabs.find((tab) => tab.id === chosen)?.id ?? tabs[0]?.id ?? ""; +} + +/** + * The panels of the tab set being rendered, read back from the editor. + * + * `props.block` is the snapshot the render closed over, so a handler that has + * just inserted or removed a panel cannot see its own change there. The set + * is on screen, so it not being in the document means this component outlived + * its block, which is a bug rather than a state to render around. + */ +function panelsOf(editor: BlockNoteEditor, tabsId: string) { + const tabs = editor.getBlock(tabsId); + if (!tabs) { + throw new Error(`Tab set "${tabsId}" is not in the document`); + } + return tabs.children; +} + +/** Whether `block` is, or contains, the block with `id`. */ +function contains(block: Block, id: string): boolean { + return block.id === id || block.children.some((child) => contains(child, id)); +} + +/** + * Opens whichever panel the caret ends up in. + * + * A hidden panel is still part of the document, so the editor will happily + * move content into it - Backspace at the start of the block *after* a tab + * set pulls that block into the last panel, which may not be the open one. + * Without this the block simply disappears from view. Reacting to the + * selection covers every route in (Backspace, Delete, arrow keys, drag and + * drop, paste) instead of patching one key at a time. + */ +function useRevealCaretPanel( + editor: BlockNoteEditor, + tabsId: string, +) { + useEffect( + () => + editor.onSelectionChange(() => { + // Read the panels back from the editor rather than closing over + // them: the block that just moved into one is not in the list this + // effect was created with. A collaborator can remove the set + // between the selection changing and this running, so a missing one + // is expected here, unlike in the handlers below. + const tabs = editor.getBlock(tabsId)?.children; + if (!tabs) { + return; + } + const here = editor.getTextCursorPosition().block; + const panel = tabs.find((tab) => contains(tab, here.id)); + if (panel && openTabId(tabsId) !== panel.id) { + openTab(tabsId, panel.id); + } + }), + [editor, tabsId], + ); +} + +/** + * A tab panel. It only ever exists inside a `tabs` block, so it declares + * `placeable: "namedOnly"` — the schema then keeps it out of every other + * position, and BlockNote dissolves it into its blocks if it is moved out. + * + * Its props carry the label only. Which panel is open is the reader's own + * state, so it is held outside the document (see the store above) - a block + * prop would travel to every collaborator and cost an undo step. + */ +export const createTab = createReactBlockSpec( + { + type: "tab", + propSchema: { + label: { default: "Tab" }, + }, + content: "none", + layoutOnly: true, + placeable: "namedOnly", + }, + { + // A layout block has no content of its own, so it draws only its frame. The + // panel reads its own open state, so it re-renders when the reader + // switches tabs without the document changing at all. + renderFrame: function TabPanel(props) { + const set = props.editor.getParentBlock(props.block.id); + // `placeable: "namedOnly"` means a panel in the document is always in a + // tab set, so not finding one is a broken document. A panel that was + // just removed is not in the document, but it can render one last time + // before React unmounts it: the reader's open tab changes in the same + // step (`removeTab`). Then it shows closed. + if (!set && props.editor.getBlock(props.block.id)) { + throw new Error(`Tab panel "${props.block.id}" has no tab set`); + } + const open = + useActiveTabId(set?.id ?? "", set?.children ?? []) === props.block.id; + + return ( +
+ ); + }, + }, +); + +/** + * One tab in the strip, draggable to reorder. The whole tab is the handle, and + * the sensor below only starts a drag after a few pixels of movement, so a + * plain click still opens the tab or its menu. + */ +function SortableTab(props: { + id: string; + active: boolean; + editing: boolean; + children: React.ReactNode; +}) { + // Not draggable while its title is being edited, so selecting text in the + // field does not pick the tab up. + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: props.id, disabled: props.editing }); + + return ( + + {props.children} + + ); +} + +/** The tab strip plus the panels. */ +export const createTabs = createReactBlockSpec( + { + type: "tabs", + propSchema: {}, + content: "none", + layoutOnly: true, + // A tab is content, not layout: an empty tab stays, and the tab set + // stays when its last tab is removed (it gets a new, empty tab). + children: { allow: ["tab"], min: 1, dissolve: false }, + }, + { + renderFrame: function TabStrip(props) { + // The editor's own menu primitives, so the tab menu matches whichever + // UI library `BlockNoteView` is using (Mantine, Ariakit or ShadCN) + // instead of being styled by hand. + const Components = useComponentsContext()!; + // The menu opens in the editor's portal element, so it keeps the + // editor's theme and is not clipped by the tab set. + const portalElement = usePortalElement(); + // A few pixels of movement before a drag starts, so clicking a tab + // still opens it rather than picking it up. + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + ); + // The tab whose title is being edited in place, if any. + const [editingId, setEditingId] = useState(undefined); + const tabs = props.block.children; + useRevealCaretPanel(props.editor, props.block.id); + const active = useActiveTabId(props.block.id, tabs); + const activeIndex = tabs.findIndex((tab: any) => tab.id === active); + + // Removing a panel does not re-render its siblings: a panel re-renders + // when its own block changes or when this store notifies, and a + // sibling disappearing is neither. Each survivor therefore keeps the + // `open` it last computed, which was `false` for all of them while the + // removed panel was the chosen one - so the set would show nothing at + // all. Storing the panel actually shown is what tells them. + // + // Nothing is stored until the reader has chosen, so this only ever + // repairs a choice, never makes one. + const ids = tabs.map((tab: any) => tab.id).join(); + useEffect(() => { + const stored = openTabId(props.block.id); + if (stored !== undefined && stored !== active) { + openTab(props.block.id, active); + } + // `tabs` is a fresh array on every render; the ids are what matter. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ids, active, props.block.id]); + + const onDragEnd = (event: DragEndEvent) => { + const from = tabs.findIndex((tab: any) => tab.id === event.active.id); + const to = tabs.findIndex((tab: any) => tab.id === event.over?.id); + if (from !== -1 && to !== -1 && from !== to) { + move(from, to - from); + } + }; + + // The caret follows the switch when it was inside the tab set: leaving + // it in the panel that just closed would put the cursor somewhere the + // reader cannot see, and `useRevealCaretPanel` would immediately reopen + // that panel and undo the switch. + const open = (index: number) => { + const here = props.editor.getTextCursorPosition().block; + const caretInside = + props.editor.isFocused() && + tabs.some((tab: any) => contains(tab, here.id)); + + // Opening a tab touches no document state, so it creates no undo + // step and no change for collaborators. + openTab(props.block.id, tabs[index].id); + + if (caretInside) { + const landing = tabs[index].children[0]; + if (!landing) { + throw new Error("A tab panel should hold a block for the caret"); + } + props.editor.setTextCursorPosition(landing, "start"); + } + }; + + const addTab = () => { + props.editor.insertBlocks( + [{ type: "tab", props: { label: `Tab ${tabs.length + 1}` } } as any], + tabs[tabs.length - 1], + "after", + ); + + // Land in the new panel so it can be typed into straight away. + const next = panelsOf(props.editor, props.block.id); + const added = next[next.length - 1]; + const landing = added.children[0]; + if (!landing) { + throw new Error("A new tab panel should hold a block to type in"); + } + openTab(props.block.id, added.id); + props.editor.setTextCursorPosition(landing, "start"); + props.editor.focus(); + }; + + const removeTab = (index: number) => { + props.editor.removeBlocks([tabs[index]]); + // One tab always survives: the set does not dissolve + // (`dissolve: false`), so removing its last tab leaves a new, empty + // one. + const next = panelsOf(props.editor, props.block.id); + openTab(props.block.id, next[Math.min(index, next.length - 1)].id); + }; + + const move = (index: number, by: number) => { + const to = index + by; + if (to < 0 || to >= tabs.length) { + return; + } + // One transaction, so a move is a single undo step - and so the id + // is free again by the time it is re-inserted. Keeping it matters: + // the reader's open panel is remembered by id, and a new one would + // silently move their choice to another tab. + props.editor.transact(() => { + const moving = tabs[index]; + props.editor.removeBlocks([moving]); + props.editor.insertBlocks( + [moving as any], + tabs[to], + by < 0 ? "before" : "after", + ); + }); + }; + + // Arrow keys move between tabs, as a tablist is expected to. + const onStripKeyDown = (event: React.KeyboardEvent) => { + const deltas: Record = { ArrowLeft: -1, ArrowRight: 1 }; + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? tabs.length - 1 + : event.key in deltas + ? (activeIndex + deltas[event.key] + tabs.length) % tabs.length + : undefined; + if (next === undefined) { + return; + } + event.preventDefault(); + open(next); + // Keep the strip focused so the next arrow keeps stepping. + (event.currentTarget as HTMLElement) + .querySelectorAll("[role='tab']") + [next]?.focus(); + }; + + return ( +
+ {/* Chrome, not document content: it lives outside `slotRef`. */} +
+ + tab.id)} + strategy={horizontalListSortingStrategy} + > + {tabs.map((tab: any, i: number) => { + const editing = tab.id === editingId; + const label = editing ? ( + event.currentTarget.select()} + onBlur={(event) => { + const value = event.currentTarget.value.trim(); + if (value && value !== tab.props.label) { + props.editor.updateBlock(tab, { + props: { label: value }, + }); + } + setEditingId(undefined); + }} + onKeyDown={(event) => { + // Keep the strip's arrow-key navigation out of the field. + event.stopPropagation(); + if (event.key === "Enter") { + event.currentTarget.blur(); + } else if (event.key === "Escape") { + event.currentTarget.value = tab.props.label; + event.currentTarget.blur(); + } + }} + /> + ) : ( + + ); + + return ( + + {/* Clicking a tab that is not open switches to it; clicking + the open one opens its menu, so the strip needs no + always-visible edit buttons. */} + {i === activeIndex && !editing ? ( + + + {label} + + + } + onClick={() => setEditingId(tab.id)} + > + Rename + + {i > 0 && ( + } + onClick={() => move(i, -1)} + > + Move left + + )} + {i < tabs.length - 1 && ( + } + onClick={() => move(i, 1)} + > + Move right + + )} + } + onClick={() => removeTab(i)} + > + Delete tab + + + + ) : ( + label + )} + + ); + })} + + + +
+
+
+ ); + }, + }, +); diff --git a/examples/06-custom-schema/14-tabs-block/src/styles.css b/examples/06-custom-schema/14-tabs-block/src/styles.css new file mode 100644 index 0000000000..a0fbd7d563 --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/src/styles.css @@ -0,0 +1,81 @@ +.tabs { + border: 1px solid #e4e4e7; + border-radius: 12px; + margin: 8px 0; + padding: 10px 14px 14px; +} + +.tabs-strip { + display: flex; + align-items: center; + gap: 2px; + user-select: none; +} + +.tabs-tab { + display: inline-flex; + align-items: center; + border-radius: 999px; +} + +.tabs-tab[data-active="true"] { + background: #f1f2f4; +} + +/* Scoped to the tab's own label: the tab menu's dropdown renders inside the + tab, and a plain `button` selector would restyle its items too. */ +.tabs-label, +.tabs-add { + border: none; + background: none; + cursor: pointer; + font: inherit; + color: #8a8f98; + padding: 5px 14px; + border-radius: 999px; +} + +.tabs-tab[data-active="true"] .tabs-label { + color: #1f2024; + font-weight: 600; +} + +.tabs-label:focus-visible, +.tabs-add:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: -2px; +} + +/* Editing chrome stays out of the way until the reader means to use it. The + space is reserved either way, so revealing it does not shift the strip. */ +.tabs-add { + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; +} + +.tabs-strip:hover .tabs-add, +.tabs-strip:focus-within .tabs-add { + opacity: 1; + pointer-events: auto; +} + +.tabs-body { + /* An empty panel still needs to be clickable. */ + min-height: 1.5rem; +} + +/* Only the open panel is shown; the others stay in the document. The panel + sets this attribute itself from per-reader state, so it is always present + and `[data-active="false"]` would match too - `:not(...)` keeps it working + either way. */ +.tab-panel:not([data-active="true"]) { + display: none; +} + +/* The title while it is being renamed: the same pill, now a text field. */ +input.tabs-label { + cursor: text; + outline: none; + width: 10ch; +} diff --git a/examples/06-custom-schema/14-tabs-block/tsconfig.json b/examples/06-custom-schema/14-tabs-block/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/14-tabs-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/14-tabs-block/vite-env.d.ts b/examples/06-custom-schema/14-tabs-block/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/14-tabs-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/14-tabs-block/vite.config.ts b/examples/06-custom-schema/14-tabs-block/vite.config.ts new file mode 100644 index 0000000000..cbf6ff2ffc --- /dev/null +++ b/examples/06-custom-schema/14-tabs-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/15-stepper-block/.bnexample.json b/examples/06-custom-schema/15-stepper-block/.bnexample.json new file mode 100644 index 0000000000..4751a892b8 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/.bnexample.json @@ -0,0 +1,9 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Intermediate", "Blocks", "Custom Schemas"], + "dependencies": { + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/15-stepper-block/README.md b/examples/06-custom-schema/15-stepper-block/README.md new file mode 100644 index 0000000000..73d87d3ba7 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/README.md @@ -0,0 +1,61 @@ +# Stepper Block + +Numbered steps built on the layout block API. `stepper` is a layout block +restricted to `step` children, and `step` is a `placeable: "namedOnly"` +layout block, so a step can only exist inside a stepper. + +Each step holds any blocks: headings, lists, code. The numbers are a CSS +counter, so they stay correct as steps are added, removed, reordered or +undone. Nothing in the document has to be kept in sync. + +**Add step** below the last step adds an untitled one. + +Both blocks are layout blocks (`layoutOnly: true`), so they declare only +`renderFrame`, and the child blocks go where `slotRef` is attached. A step can +only be inside a stepper, so it gets no drag handle by default. The step sets +`meta: { draggable: true }` to keep it, so the side menu can move or delete a +step. + +## Things worth knowing + +**Enter starts the next step.** By default, Enter on an empty block at the end +of a step moves that block out below the whole stepper, so the keyboard alone +cannot start a new step. `enterStartsNextStep` makes Enter work the way it does +in a list: + +1. Enter at the end of a step adds a block to that step. +2. Enter again, on the empty block, starts the next step and puts the caret + in its title. +3. Enter again, on the empty title, leaves the stepper. It leaves a paragraph + behind, not the empty heading the step was created with. + +The block's `experimental_keyboard` settings cannot insert a new step, so this +is written as an extension, passed as the third argument of +`createReactBlockSpec`. + +## Known limitations + +**The title is not enforced.** A step has no text of its own: its title is +simply its first child, a heading. The user can turn that heading into any +other block, delete it, or put blocks above it. A real title needs a block +with its own text that may only appear inside a stepper, and the layout block API +cannot express that yet: `placeable: "namedOnly"` requires a layout block (a +block without content), and `children.allow` can only name layout blocks. + +**Empty steps stay.** The stepper sets `children.dissolve` to `false`, so a +step that holds only an empty paragraph is not removed, and the stepper stays +when its last step is removed. + +## What the layout block API gives you for free + +- Backspace at the start of a step's title first turns it into a paragraph. + A second Backspace moves it to the end of the previous step, or above the + stepper if it is in the first step. +- A step stays when its blocks are removed. When the last step is removed, + the stepper stays and gets a new step with an empty paragraph. +- Shift-Tab unnests inside a step and stops at the step's edge. + +**Relevant Docs:** + +- [Layout Blocks](/docs/features/custom-schemas/layout-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) diff --git a/examples/06-custom-schema/15-stepper-block/index.html b/examples/06-custom-schema/15-stepper-block/index.html new file mode 100644 index 0000000000..f1f0d5da1b --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/index.html @@ -0,0 +1,17 @@ + + + + + Stepper Block + + + +
+ + + diff --git a/examples/06-custom-schema/15-stepper-block/main.tsx b/examples/06-custom-schema/15-stepper-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-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/15-stepper-block/package.json b/examples/06-custom-schema/15-stepper-block/package.json new file mode 100644 index 0000000000..5e67e12486 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-stepper-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/15-stepper-block/src/App.tsx b/examples/06-custom-schema/15-stepper-block/src/App.tsx new file mode 100644 index 0000000000..bbbec25373 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/src/App.tsx @@ -0,0 +1,108 @@ +import { BlockNoteSchema } 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 { RiListOrdered2 } from "react-icons/ri"; + +import { createStep, createStepper } from "./Stepper"; +import "./styles.css"; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + stepper: createStepper(), + step: createStep(), + }, +}); + +function insertStepper(editor: typeof schema.BlockNoteEditor) { + return { + title: "Stepper", + subtext: "Numbered steps, each holding any blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "stepper", + children: [ + { + type: "step", + children: [ + { type: "heading", props: { level: 3 }, content: "First step" }, + ], + }, + { + type: "step", + children: [ + { type: "heading", props: { level: 3 }, content: "Second step" }, + ], + }, + ], + } as any), + aliases: ["stepper", "steps", "guide", "walkthrough"], + group: "Layout", + icon: , + }; +} + +export default function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "A stepper built on the layout block API. Numbering is CSS.", + }, + { + type: "stepper", + children: [ + { + type: "step", + children: [ + { type: "heading", props: { level: 3 }, content: "Install" }, + { type: "paragraph", content: "Download and run the installer." }, + ], + }, + { + type: "step", + children: [ + { type: "heading", props: { level: 3 }, content: "Configure" }, + { type: "paragraph", content: "Any block works inside a step:" }, + { type: "bulletListItem", content: "lists" }, + { type: "codeBlock", content: "code()" }, + ], + }, + { + type: "step", + children: [ + { type: "heading", props: { level: 3 }, content: "Ship" }, + { type: "paragraph", content: "Deploy it." }, + ], + }, + ], + } as any, + { type: "paragraph", content: "Press '/' to insert another stepper." }, + { type: "paragraph" }, + ], + }); + + return ( + + + filterSuggestionItems( + [...getDefaultReactSlashMenuItems(editor), insertStepper(editor)], + query, + ) + } + /> + + ); +} diff --git a/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx b/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx new file mode 100644 index 0000000000..6888342f4d --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/src/Stepper.tsx @@ -0,0 +1,169 @@ +import type { BlockNoteEditor } from "@blocknote/core"; +import { createExtension } from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +/** + * A new, untitled step. + * + * Its title is an empty heading, the right block for a title. + */ +function newStep() { + return { + type: "step", + children: [{ type: "heading", props: { level: 3 } }], + }; +} + +/** + * Enter on an empty block at the end of a step starts the next step, the way + * Enter on an empty list item starts the next item. BlockNote's own Enter + * handling would move that block out below the whole stepper, which leaves no + * keyboard route to a new step. + * + * The block's `experimental_keyboard` settings cannot insert a new step, so + * this is the one piece that needs an extension, attached through + * `createReactBlockSpec`'s third argument. + */ +const enterStartsNextStep = createExtension({ + key: "stepper-enter-starts-next-step", + keyboardShortcuts: { + Enter: ({ editor }) => { + const { block } = editor.getTextCursorPosition(); + const isEmpty = + Array.isArray(block.content) && + block.content.length === 0 && + block.children.length === 0; + if (!isEmpty) { + return false; + } + const step = editor.getParentBlock(block); + if (step?.type !== "step") { + return false; + } + + // The step holds nothing but this empty block - typically the title of + // a step the previous Enter just started - so this Enter leaves the + // stepper. The built-in handling would carry the empty heading out; + // replacing the step with a paragraph below the stepper leaves the + // caret in plain text instead. + if (step.children.length === 1) { + const stepper = editor.getParentBlock(step); + if (!stepper) { + throw new Error(`Step "${step.id}" is not inside a stepper`); + } + const [landing] = editor.transact(() => { + const inserted = editor.insertBlocks( + [{ type: "paragraph" }], + stepper, + "after", + ); + // If this was the stepper's only step, the stepper goes too. It + // does not dissolve by itself (`dissolve: false`): without this, + // it would stay with a new, empty step. + editor.removeBlocks([stepper.children.length === 1 ? stepper : step]); + return inserted; + }); + editor.setTextCursorPosition(landing, "start"); + return true; + } + + // Otherwise only the *last* block of a step opens the next one. + if (step.children[step.children.length - 1].id !== block.id) { + return false; + } + const [next] = editor.transact(() => { + editor.removeBlocks([block]); + return editor.insertBlocks([newStep()], step, "after"); + }); + editor.setTextCursorPosition(next.children[0], "start"); + return true; + }, + }, +}); + +/** Appends a step and puts the caret in its title. */ +function addStep(editor: BlockNoteEditor, stepperId: string) { + // Read back rather than using the render's snapshot of the stepper. + const stepper = editor.getBlock(stepperId); + if (!stepper) { + throw new Error(`Stepper "${stepperId}" is not in the document`); + } + const [step] = editor.insertBlocks( + [newStep()], + stepper.children[stepper.children.length - 1], + "after", + ); + // Land in the new step's title so it can be named straight away. + editor.setTextCursorPosition(step.children[0], "start"); + editor.focus(); +} + +/** + * One step. Like a column, a step is defined only in terms of the thing that + * holds it, so it declares `placeable: "namedOnly"`: the schema keeps it out + * of every other position, and moving one out dissolves it into its blocks. + * + * A step has no text of its own - its title is simply its first child. The + * layout block API cannot express "a block with its own rich text that may only + * appear inside a stepper": `children.allow` names layout block types only, and + * a block with content is not one. + */ +export const createStep = createReactBlockSpec( + { + type: "step", + propSchema: {}, + content: "none", + layoutOnly: true, + placeable: "namedOnly", + }, + { + // A step that can only be inside a stepper gets no drag handle by + // default. This one keeps it, so the side menu can move or delete it. + meta: { draggable: true }, + renderFrame: (props) => ( +
+ {/* Chrome, not content: it sits outside `slotRef`. The number + comes from a CSS counter. */} +
+ +
+
+
+ ), + }, +); + +export const createStepper = createReactBlockSpec( + { + type: "stepper", + propSchema: {}, + content: "none", + layoutOnly: true, + // A step is content, not layout: an empty step stays, and the stepper + // stays when its last step is removed (it gets a new, empty step). + children: { allow: ["step"], min: 1, dissolve: false }, + }, + { + renderFrame: (props) => { + return ( +
+
+ +
+ ); + }, + }, + [enterStartsNextStep], +); diff --git a/examples/06-custom-schema/15-stepper-block/src/styles.css b/examples/06-custom-schema/15-stepper-block/src/styles.css new file mode 100644 index 0000000000..6e033a9197 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/src/styles.css @@ -0,0 +1,102 @@ +.stepper { + /* The rail runs behind the step numbers, from the first one down to the + "+" of "Add step". It is drawn once here rather than per step, so it + needs no "is this the last step" logic. */ + position: relative; + margin: 8px 0; +} + +.stepper::before { + content: ""; + position: absolute; + left: 12px; + /* The centre of the first number. */ + top: 33px; + bottom: 12px; + width: 1px; + background: var(--bn-colors-border); +} + +/* Numbering is a CSS counter, so it stays correct as steps are added, + removed, dragged or undone - nothing to keep in sync in the document. */ +.stepper-steps { + counter-reset: step; +} + +.step { + display: flex; + /* The side menu of a block in a step is at the left of that block. This gap + is wider than the side menu (48px), so the menu does not cover the + number. */ + gap: 12px; + padding-bottom: 8px; +} + +.step-rail { + flex: none; + width: 25px; + /* Centres the number on the first line of a step's title. The title is a + heading, and its first line starts 21px below its top: 18px of heading + spacing and 3px of line spacing. */ + padding-top: 21px; +} + +/* Every circle gets the editor's background so the rail passes behind it. */ +.step-number, +.stepper-add-icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 25px; + height: 25px; + box-sizing: border-box; + border: 1px solid var(--bn-colors-border); + border-radius: 999px; + background: var(--bn-colors-editor-background); + color: var(--bn-colors-side-menu); + font: inherit; + font-size: 0.8rem; + padding: 0; + user-select: none; +} + +.step-number { + counter-increment: step; + color: var(--bn-colors-editor-text); +} + +.step-number::before { + content: counter(step); +} + +.stepper-add:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 1px; +} + +.step-body { + flex: 1; + min-width: 0; +} + +/* Do not remove the top spacing of the step's title (a heading). The side + menu is centred on the first line of a heading with the default spacing + only. With less spacing, the menu is below the line. */ + +.stepper-add { + display: flex; + align-items: center; + /* The same as the gap of a step, so "Add step" aligns with the steps. */ + gap: 12px; + border: none; + background: none; + padding: 0; + cursor: pointer; + font: inherit; + color: var(--bn-colors-side-menu); +} + +.stepper-add:hover { + color: var(--bn-colors-hovered-text); +} diff --git a/examples/06-custom-schema/15-stepper-block/tsconfig.json b/examples/06-custom-schema/15-stepper-block/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-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/15-stepper-block/vite-env.d.ts b/examples/06-custom-schema/15-stepper-block/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/15-stepper-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/15-stepper-block/vite.config.ts b/examples/06-custom-schema/15-stepper-block/vite.config.ts new file mode 100644 index 0000000000..cbf6ff2ffc --- /dev/null +++ b/examples/06-custom-schema/15-stepper-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/draggable-inline-content/vite.config.ts b/examples/06-custom-schema/draggable-inline-content/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/draggable-inline-content/vite.config.ts +++ b/examples/06-custom-schema/draggable-inline-content/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/react-custom-blocks/vite.config.ts b/examples/06-custom-schema/react-custom-blocks/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/react-custom-blocks/vite.config.ts +++ b/examples/06-custom-schema/react-custom-blocks/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/react-custom-inline-content/vite.config.ts b/examples/06-custom-schema/react-custom-inline-content/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/react-custom-inline-content/vite.config.ts +++ b/examples/06-custom-schema/react-custom-inline-content/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/06-custom-schema/react-custom-styles/vite.config.ts b/examples/06-custom-schema/react-custom-styles/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/06-custom-schema/react-custom-styles/vite.config.ts +++ b/examples/06-custom-schema/react-custom-styles/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/01-partykit/vite.config.ts b/examples/07-collaboration/01-partykit/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/01-partykit/vite.config.ts +++ b/examples/07-collaboration/01-partykit/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/02-liveblocks/vite.config.ts b/examples/07-collaboration/02-liveblocks/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/02-liveblocks/vite.config.ts +++ b/examples/07-collaboration/02-liveblocks/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/03-y-sweet/vite.config.ts b/examples/07-collaboration/03-y-sweet/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/03-y-sweet/vite.config.ts +++ b/examples/07-collaboration/03-y-sweet/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/04-electric-sql/vite.config.ts b/examples/07-collaboration/04-electric-sql/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/04-electric-sql/vite.config.ts +++ b/examples/07-collaboration/04-electric-sql/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/05-comments/vite.config.ts b/examples/07-collaboration/05-comments/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/05-comments/vite.config.ts +++ b/examples/07-collaboration/05-comments/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts b/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts +++ b/examples/07-collaboration/06-comments-with-sidebar/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/07-ghost-writer/vite.config.ts b/examples/07-collaboration/07-ghost-writer/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/07-ghost-writer/vite.config.ts +++ b/examples/07-collaboration/07-ghost-writer/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/08-forking/vite.config.ts b/examples/07-collaboration/08-forking/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/08-forking/vite.config.ts +++ b/examples/07-collaboration/08-forking/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/09-comments-testing/vite.config.ts b/examples/07-collaboration/09-comments-testing/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/09-comments-testing/vite.config.ts +++ b/examples/07-collaboration/09-comments-testing/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts b/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts +++ b/examples/07-collaboration/10-suggestion-multi-editor/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/11-versioning-yjs13/vite.config.ts b/examples/07-collaboration/11-versioning-yjs13/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/11-versioning-yjs13/vite.config.ts +++ b/examples/07-collaboration/11-versioning-yjs13/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts b/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts +++ b/examples/07-collaboration/12-multi-doc-versioning/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/13-versioning-yjs14/vite.config.ts b/examples/07-collaboration/13-versioning-yjs14/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/13-versioning-yjs14/vite.config.ts +++ b/examples/07-collaboration/13-versioning-yjs14/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/07-collaboration/14-suggestion-gallery/vite.config.ts +++ b/examples/07-collaboration/14-suggestion-gallery/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts b/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts +++ b/examples/08-extensions/01-tiptap-arrow-conversion/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/08-extensions/02-versioning/vite.config.ts b/examples/08-extensions/02-versioning/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/08-extensions/02-versioning/vite.config.ts +++ b/examples/08-extensions/02-versioning/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/09-ai/01-minimal/vite.config.ts b/examples/09-ai/01-minimal/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/09-ai/01-minimal/vite.config.ts +++ b/examples/09-ai/01-minimal/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/09-ai/02-playground/vite.config.ts b/examples/09-ai/02-playground/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/09-ai/02-playground/vite.config.ts +++ b/examples/09-ai/02-playground/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/09-ai/03-custom-ai-menu-items/vite.config.ts b/examples/09-ai/03-custom-ai-menu-items/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/09-ai/03-custom-ai-menu-items/vite.config.ts +++ b/examples/09-ai/03-custom-ai-menu-items/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/09-ai/04-with-collaboration/vite.config.ts b/examples/09-ai/04-with-collaboration/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/09-ai/04-with-collaboration/vite.config.ts +++ b/examples/09-ai/04-with-collaboration/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/09-ai/05-manual-execution/vite.config.ts b/examples/09-ai/05-manual-execution/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/09-ai/05-manual-execution/vite.config.ts +++ b/examples/09-ai/05-manual-execution/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/09-ai/06-client-side-transport/vite.config.ts b/examples/09-ai/06-client-side-transport/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/09-ai/06-client-side-transport/vite.config.ts +++ b/examples/09-ai/06-client-side-transport/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/09-ai/07-server-persistence/vite.config.ts b/examples/09-ai/07-server-persistence/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/09-ai/07-server-persistence/vite.config.ts +++ b/examples/09-ai/07-server-persistence/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts b/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts +++ b/examples/vanilla-js/react-vanilla-custom-blocks/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts b/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts +++ b/examples/vanilla-js/react-vanilla-custom-inline-content/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts b/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts +++ b/examples/vanilla-js/react-vanilla-custom-styles/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts b/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts index a96f1f04ff..cbf6ff2ffc 100644 --- a/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts +++ b/examples/vanilla-js/vanilla-custom-side-menu/vite.config.ts @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/", + "../../../packages/core/src/", ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/", + "../../../packages/react/src/", ), } as any), }, diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index 9d695e38b6..c2045121f4 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -59,9 +59,10 @@ export function insertBlocks< } // `getInsertionPos` can only answer for the first node's type: the fragment - // doesn't exist yet when it runs. The whole fragment still has to fit, so it - // is checked here, where the nodes are known, rather than left to `tr.step` - // to reject with a ProseMirror-level message. + // doesn't exist yet when it runs. The whole fragment still has to fit — a + // `blockGroup` takes a paragraph but not a `namedOnly` block — so it is + // checked here, where the nodes are known, rather than left to `tr.step` to + // reject with a ProseMirror-level message. if ( target.wrapIn && !target.wrapIn.validContent(Fragment.from(nodesToInsert)) diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts index 8da2132a40..8d5cab072f 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -8,12 +8,48 @@ import { 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. +// `renderFrame` only has to exist for `createBlockSpec` to accept the spec. +const layoutBlock = (type: string, config: Record) => + createBlockSpec({ type, propSchema: {}, ...config } as any, { + renderFrame: () => { + throw new Error("not rendered in this suite"); + }, + })(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + // A layout block of any blocks. `"first-child"`/`"last-child"` insert at + // the start or end of its children. + box: layoutBlock("box", { + content: "none", + layoutOnly: true, + }), + // A layout block that only accepts other layout blocks, so an insertion has to + // descend a level to find a place for a regular block. + grid: layoutBlock("grid", { + content: "none", + layoutOnly: true, + children: { allow: ["cell"], min: 2 }, + }), + cell: layoutBlock("cell", { + content: "none", + layoutOnly: true, + placeable: "namedOnly", + }), + } as const, +}); let editor: BlockNoteEditor; beforeAll(() => { - editor = BlockNoteEditor.create() as any; + editor = BlockNoteEditor.create({ schema }) as any; }); afterAll(() => { @@ -28,86 +64,154 @@ beforeEach(() => { }); describe('insertBlocks "first-child" / "last-child"', () => { - it("nests under a childless block, creating the blockGroup", () => { - expect(editor.getBlock("p-0")!.children).toHaveLength(0); + 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" }], - "p-0", + "b-0", "first-child", ); editor.insertBlocks( [{ id: "last", type: "paragraph" }], - "p-0", + "b-0", "last-child", ); - expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ "first", + "existing", "last", ]); }); - it("prepends and appends around existing children", () => { + it("descends into a nested layout block that accepts the block", () => { editor.replaceBlocks(editor.document, [ { - id: "p-0", - type: "paragraph", - content: "Paragraph 0", - children: [{ id: "existing", type: "paragraph", content: "Existing" }], + 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" }], - "p-0", + "g-0", "first-child", ); editor.insertBlocks( [{ id: "last", type: "paragraph" }], + "g-0", + "last-child", + ); + + 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", "last-child", ); + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "p-0", + "first-child", + ); expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ "first", "existing", - "last", ]); }); - it("still inserts siblings with the default and explicit placements", () => { - editor.insertBlocks([{ id: "after", type: "paragraph" }], "p-0"); - editor.insertBlocks([{ id: "before", type: "paragraph" }], "p-0", "before"); - editor.insertBlocks([{ id: "sibling", type: "paragraph" }], "p-0", "after"); + 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(/at "after" of block "c-0": no valid position/); + }); - expect(editor.document.map((block) => block.id)).toEqual([ - "after", - "before", - "p-0", - "sibling", + it("throws when only the first of several blocks would fit", () => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, ]); + + // Nesting under a childless regular block wraps the batch in a fresh + // `blockGroup`, which takes the paragraph but not the `namedOnly` + // cell. Validating only the first node used to let the batch through and + // fail later with a raw ProseMirror `ReplaceError`. + expect(() => + editor.insertBlocks( + [{ type: "paragraph" }, { type: "cell" }], + "p-0", + "last-child", + ), + ).toThrow(/at "last-child" of block "p-0": .* doesn't accept them/); + + expect(editor.getBlock("p-0")!.children).toEqual([]); }); it("still inserts a batch that fits in full", () => { + editor.replaceBlocks(editor.document, [ + { + id: "b-0", + type: "box", + children: [{ id: "existing", type: "paragraph" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + editor.insertBlocks( [ { id: "one", type: "paragraph" }, { id: "two", type: "paragraph" }, ], - "p-0", + "b-0", "last-child", ); - expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ + "existing", "one", "two", ]); }); - - it("throws when the reference block does not exist", () => { - expect(() => - editor.insertBlocks([{ type: "paragraph" }], "missing-id", "last-child"), - ).toThrow(/Block with ID missing-id not found/); - }); }); diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts index 41a4fca117..1aaf1f388a 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { getParentBlockInfo } from "../../../getBlockInfoFromPos.js"; +import { getNodeById } from "../../../nodeUtil.js"; +import { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js"; import { mergeBlocksCommand } from "./mergeBlocks.js"; const getEditor = setupTestEnv(); @@ -41,6 +43,23 @@ describe("Test mergeBlocks", () => { mergeBlocks(getPosBeforeSelectedBlock()); + // The merged block's children are lifted into its place, not dropped + // with it. The lift and the delete go through the one transaction tiptap + // hands the command (its chainable state's `tr` getter returns the same + // transaction every time), so the lift must survive the dispatch. + expect(getEditor().getBlock("paragraph-with-children")).toBeUndefined(); + expect(getEditor().getBlock("paragraph-1")!.content).toEqual([ + { type: "text", text: "Paragraph 1Paragraph with children", styles: {} }, + ]); + expect(getEditor().document.map((block) => block.id)).toContain( + "nested-paragraph-0", + ); + expect( + getEditor() + .getBlock("nested-paragraph-0")! + .children.map((c) => c.id), + ).toEqual(["double-nested-paragraph-0"]); + expect(getEditor().document).toMatchSnapshot(); }); @@ -98,19 +117,10 @@ describe("Test mergeBlocks", () => { const originalDocument = getEditor().document; const ret = mergeBlocks(getPosBeforeSelectedBlock()); + // The empty paragraph above has the same type and props, so the block + // moves up into its place, keeping its id (#550). expect(getEditor().document).toEqual( - originalDocument - .filter((block) => block.id !== "paragraph-8") - .map((block) => - block.id === "empty-paragraph" - ? { - ...block, - content: originalDocument.find( - (source) => source.id === "paragraph-8", - )!.content, - } - : block, - ), + originalDocument.filter((block) => block.id !== "empty-paragraph"), ); expect(ret).toBe(true); }); @@ -155,3 +165,87 @@ describe("Test mergeBlocks", () => { expect(ret).toBeFalsy(); }); }); + +describe("Test mergeBlocks at layout block boundaries", () => { + const getLayoutEditor = setupTestEnv({ + schema: layoutSchema, + document: [ + { id: "before-callout", type: "paragraph", content: "Before callout" }, + { + id: "callout-0", + type: "callout", + children: [ + { + id: "callout-child-0", + type: "paragraph", + content: "Callout child 0", + }, + { + id: "callout-child-1", + type: "paragraph", + content: "Callout child 1", + }, + ], + }, + { id: "after-callout", type: "paragraph", content: "After callout" }, + ], + }); + + function mergeLayoutBlocks(posBetweenBlocks: number) { + return getLayoutEditor()._tiptapEditor.commands.command( + mergeBlocksCommand(posBetweenBlocks), + ); + } + + function getPosBefore(id: string) { + return getLayoutEditor().transact((tr) => { + const node = getNodeById(id, tr.doc); + if (!node) { + throw new Error(`No block with id "${id}" in the test document`); + } + return node.posBeforeNode; + }); + } + + // A layout block's first child has no previous sibling, so there is nothing to + // merge it into. The block above it on screen sits outside the layout block. + it("Does not merge a layout block's first child out of the layout block", () => { + const originalDocument = getLayoutEditor().document; + const ret = mergeLayoutBlocks(getPosBefore("callout-child-0")); + + expect(ret).toBeFalsy(); + expect(getLayoutEditor().document).toEqual(originalDocument); + }); + + // A layout block has no content of its own, so there is nothing to merge. + it("Does not merge a layout block into the block above it", () => { + const originalDocument = getLayoutEditor().document; + const ret = mergeLayoutBlocks(getPosBefore("callout-0")); + + expect(ret).toBeFalsy(); + expect(getLayoutEditor().document).toEqual(originalDocument); + }); + + // `mergeBlocksCommand` treats a layout block like any other block with children + // and merges into its last descendant, which puts the merged text inside the + // layout block. Backspace never produces this, because + // `KeyboardShortcutsExtension` bails out when the previous sibling has no + // inline content and moves the block into the layout block instead. So this is + // the command's behaviour on its own, not the editor's; it is pinned here + // because `mergeBlocks.ts` documents the opposite. + it("Merges a block into the last descendant of the layout block above it", () => { + const ret = mergeLayoutBlocks(getPosBefore("after-callout")); + + expect(ret).toBeTruthy(); + + const document = getLayoutEditor().document; + + expect(document.map((block) => block.id)).toEqual([ + "before-callout", + "callout-0", + ]); + expect(document[1].children[1].content).toEqual([ + { type: "text", text: "Callout child 1After callout", styles: {} }, + ]); + }); +}); diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index e61e9f5139..df51fdef67 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,5 +1,10 @@ import { Fragment } from "prosemirror-model"; -import { EditorState, Selection, Transaction } from "prosemirror-state"; +import { + EditorState, + Selection, + TextSelection, + Transaction, +} from "prosemirror-state"; import { type BlockInfo, @@ -12,21 +17,17 @@ import { type ContentBlockInfo = Extract; /** - * Whether two blocks can merge: both must hold inline content. Merging into - * or out of container blocks (columnLists, callouts, ...) is intentionally - * unsupported; the container-boundary Backspace/Delete branches in - * `KeyboardShortcutsExtension` move blocks across the boundary instead. + * Returns the content to append, or undefined when the blocks cannot merge. + * Only inline content merges, into inline content: a block with plain-text + * content (e.g. a code block) never takes merged text. */ -function canMerge( - prevBlockInfo: BlockInfo, - nextBlockInfo: BlockInfo, -): prevBlockInfo is ContentBlockInfo { - return ( - prevBlockInfo.hasContent && - prevBlockInfo.contentKind === "inline" && - nextBlockInfo.hasContent && - nextBlockInfo.contentKind === "inline" - ); +export function getMergeContent( + current: ContentBlockInfo, + next: ContentBlockInfo, +): Fragment | undefined { + return current.contentKind === "inline" && next.contentKind === "inline" + ? next.content.node.content + : undefined; } /** Merge a first child into its parent, promoting descendants into its place. */ @@ -36,15 +37,16 @@ function mergeIntoParent( parent: ContentBlockInfo, child: ContentBlockInfo, ): boolean { - if (!parent.children || !canMerge(parent, child)) { + if (!parent.children) { return false; } - const content = child.content.node.content; + const content = getMergeContent(parent, child); if ( - content.size > 0 && - !parent.content.node.type.validContent( - parent.content.node.content.append(content), - ) + content === undefined || + (content.size > 0 && + !parent.content.node.type.validContent( + parent.content.node.content.append(content), + )) ) { return false; } @@ -80,8 +82,10 @@ function mergeIntoParent( * back from there: the previous sibling's deepest descendant, or the parent * when the block is its first child. * @returns A tiptap command that returns `false` (leaving the doc untouched) - * when the two blocks can't merge: no block above, or either side isn't an - * inline-content block. + * when the two blocks can't merge: no compatible text block above. The block + * above may be empty. With the same type and props, the block moves up into + * its place. Otherwise the text takes the empty block's type and props (and + * id), as in Notion. */ export const mergeBlocksCommand = (posBetweenBlocks: number) => @@ -93,16 +97,15 @@ export const mergeBlocksCommand = dispatch: ((tr: Transaction) => void) | undefined; }) => { const nextBlockInfo = getBlockInfoAt(state.doc, posBetweenBlocks); + if (!nextBlockInfo.hasContent) { + return false; + } - const prevBlockInfo = getPrevBlockInfo( + const prevSibling = getPrevBlockInfo( state.doc, nextBlockInfo.block.beforePos, ); - - if (!prevBlockInfo) { - if (!nextBlockInfo.hasContent || nextBlockInfo.contentKind !== "inline") { - return false; - } + if (!prevSibling) { const parent = getParentBlockInfo( state.doc, nextBlockInfo.block.beforePos, @@ -114,23 +117,43 @@ export const mergeBlocksCommand = } // The block we merge into is the last descendant of the previous block: - // visually, that's the block directly above the boundary. It may be empty: - // the text then takes its type and props, as in Notion. - const bottomNestedBlockInfo = getLastDescendantBlockInfo(prevBlockInfo); + // visually, that's the block directly above the boundary. + const prevBlockInfo = getLastDescendantBlockInfo(prevSibling); + if (!prevBlockInfo.hasContent) { + return false; + } + if (getMergeContent(prevBlockInfo, nextBlockInfo) === undefined) { + return false; + } + + // An empty block above with the same type and props adds nothing: the + // block moves up into its place, keeping its id and children (#550). if ( - !canMerge(bottomNestedBlockInfo, nextBlockInfo) || - !nextBlockInfo.hasContent + prevBlockInfo.isContentEmpty && + prevBlockInfo.content.node.sameMarkup(nextBlockInfo.content.node) ) { - return false; + if (dispatch) { + const tr = state.tr + .delete(nextBlockInfo.block.beforePos, nextBlockInfo.block.afterPos) + .replaceWith( + prevBlockInfo.block.beforePos, + prevBlockInfo.block.afterPos, + nextBlockInfo.block.node, + ); + tr.setSelection( + TextSelection.create(tr.doc, prevBlockInfo.contentStart), + ); + dispatch(tr.scrollIntoView()); + } + return true; } - // Un-nests the next block's children by one level, so they survive as - // siblings of the merged block rather than as children of a block that no - // longer exists once the boundary below is deleted. - // - // Note `state.tr` is tiptap's chainable state, whose getter returns the one - // transaction shared by the command chain (not a fresh `Transaction` like - // `EditorState.tr`), so this lift carries over into the `dispatch` below. + // Lift children before removing their parent. `state.tr` here is not a + // fresh transaction: tiptap's chainable state (`createChainableState`) + // returns the one transaction the command runs in from every `tr` read, + // so this lift and the delete below are dispatched together. The "Second + // block has children" test in mergeBlocks.test.ts pins that the lifted + // children survive the merge. if (dispatch && nextBlockInfo.children) { const childBlocksRange = state.doc .resolve(nextBlockInfo.children.childrenStart) @@ -156,10 +179,7 @@ export const mergeBlocksCommand = // second one to stitch them together. if (dispatch) { dispatch( - state.tr.delete( - bottomNestedBlockInfo.contentEnd, - nextBlockInfo.contentStart, - ), + state.tr.delete(prevBlockInfo.contentEnd, nextBlockInfo.contentStart), ); } 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 f9bba17c3f..4582f07b1c 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts @@ -7,16 +7,25 @@ import { getBlockInfoFromSelection, getNodeId, } from "../../../getBlockInfoFromPos.js"; -import { setupTestEnv } from "../../setupTestEnv.js"; +import { getNodeById } from "../../../nodeUtil.js"; +import { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js"; +import { setupTestEnv, testDocument } from "../../setupTestEnv.js"; import { moveBlocksDown, moveBlocksUp, moveSelectedBlocksAndSelection, } from "./moveBlocks.js"; -const getEditor = setupTestEnv(); +const getEditor = setupTestEnv< + typeof layoutSchema.blockSchema, + typeof layoutSchema.inlineContentSchema, + typeof layoutSchema.styleSchema +>({ + schema: layoutSchema, + document: testDocument, +}); -function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { +function makeSelectionSpanContent(selectionType: "text" | "cell") { const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr)); if (!blockInfo.hasContent) { throw new Error( @@ -36,10 +45,6 @@ function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { ), ), ); - } else if (selectionType === "node") { - editor.transact((tr) => - tr.setSelection(NodeSelection.create(tr.doc, content.beforePos)), - ); } else { editor.transact((tr) => tr.setSelection( @@ -69,19 +74,30 @@ describe("Test moveSelectedBlockAndSelection", () => { ).toBeTruthy(); }); - it("Node selection", () => { - getEditor().setTextCursorPosition("image-0"); - makeSelectionSpanContent("node"); - - moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before"); - - const selection = getEditor().transact((tr) => tr.selection); - getEditor().setTextCursorPosition("image-0"); - makeSelectionSpanContent("node"); - - expect( - selection.eq(getEditor().transact((tr) => tr.selection)), - ).toBeTruthy(); + it.each([ + { type: "image", offset: 1 }, + { type: "callout", offset: 0 }, + ] as const)("Node selection: $type", ({ type, offset }) => { + const editor = getEditor(); + editor.insertBlocks([{ id: "selected", type }], "paragraph-1", "after"); + editor.transact((tr) => { + const block = getNodeById("selected", tr.doc)!; + tr.setSelection( + NodeSelection.create(tr.doc, block.posBeforeNode + offset), + ); + }); + + moveSelectedBlocksAndSelection(editor, "paragraph-0", "before"); + + expect(editor.document[0].id).toBe("selected"); + editor.transact((tr) => { + const moved = getNodeById("selected", tr.doc)!; + expect( + tr.selection.eq( + NodeSelection.create(tr.doc, moved.posBeforeNode + offset), + ), + ).toBe(true); + }); }); it("Cell selection", () => { diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 51ed7e6852..09a05dec14 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -1,4 +1,4 @@ -import type { NodeType } from "prosemirror-model"; +import type { NodeType, Schema } from "prosemirror-model"; import { NodeSelection, Selection, @@ -11,15 +11,38 @@ import { Block } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; import { BlockIdentifier } from "../../../../schema/index.js"; import { - getBlockInfoNearPos, - getBlockInfoAt, + isLayoutNode, + isNamedOnly, +} from "../../../../schema/blocks/children.js"; +import { getInsertionPos, + getBlockInfoAt, + getBlockInfoNearPos, getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; import { insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; +/** + * Dissolves `placeable: "namedOnly"` blocks into their children. + * + * A `namedOnly` block (a `column`, say) is defined only in terms of the + * layout block that holds it, so it can't land anywhere a regular block goes — + * moving one out of its layout block moves its children instead. Every other + * block passes through as itself. + */ +function dissolveLayoutOnlyBlocks( + blocks: Block[], + pmSchema: Schema, +): Block[] { + return blocks.flatMap((block) => + isNamedOnly(pmSchema.nodes[block.type]) + ? dissolveLayoutOnlyBlocks(block.children, pmSchema) + : [block], + ); +} + type BlockSelectionData = ( | { type: "text"; @@ -114,7 +137,11 @@ function updateBlockSelectionFromData( anchorBlockPos + data.headCellOffset, ); } else if (data.type === "node") { - selection = NodeSelection.create(tr.doc, anchorBlockPos + 1); + const blockInfo = getBlockInfoAt(tr.doc, anchorBlockPos); + selection = NodeSelection.create( + tr.doc, + blockInfo.hasContent ? blockInfo.content.beforePos : anchorBlockPos, + ); } else { const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode; if (headBlockPos === undefined) { @@ -133,16 +160,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. @@ -171,10 +188,10 @@ 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, [], { fixLayoutBlocks: false }); insertBlocks( tr, - flattenColumns(blocks), + dissolveLayoutOnlyBlocks(blocks, editor.pmSchema), referenceBlock, placement, ); @@ -209,295 +226,125 @@ export function moveSelectedBlocksAndSelection( }); } -/** - * All a placement check needs to know about the block being moved: where it - * currently sits, and what would land at the destination. Neither changes as a - * placement search walks the document, so both are resolved once up front. - */ -type MovedBlock = { - /** The moved block's ID, to locate it in the doc. */ - id: string; - /** - * The PM node type that would actually be inserted: a child-holding wrapper - * block (e.g. a `columnList`) goes in as its own node type; anything else - * as a generic `blockContainer` wrapper. - */ - nodeType: NodeType; -}; - -function toMovedBlock( +/** The first node type inserted after dissolving layout-only blocks. */ +function getMovedNodeType( editor: BlockNoteEditor, block: Block, -): MovedBlock { - const type = editor.pmSchema.nodes[block.type]; - - return { - id: block.id, - nodeType: - type && type.isInGroup("bnBlock") && type.isInGroup("childContainer") - ? type - : editor.pmSchema.nodes["blockContainer"], - }; -} - -// Checks if a block would be in a valid place after being moved -// before/after `referenceBlock`. A regular block nests under any block (it -// goes into that block's `blockGroup`), but a wrapper 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", - movedBlock: MovedBlock, -): boolean { - return editor.transact((tr) => { - const posInfo = getNodeById(referenceBlock.id, tr.doc); - const movedPosInfo = getNodeById(movedBlock.id, tr.doc); - if (!posInfo || !movedPosInfo) { - return false; - } - - const target = getInsertionPos( - tr.doc, - getBlockInfoAt(tr.doc, posInfo.posBeforeNode), - placement, - movedBlock.nodeType, - ); - return target !== null; - }); -} - -/** - * Gets the placement for moving a block up. This has 3 cases: - * 1. If the block has a previous sibling without children, the placement is - * before it. - * 2. If the block has a previous sibling with children, the placement is after - * the last child. - * 3. If the block has no previous sibling, but is nested, the placement is - * before its parent. - * If the placement is invalid, the function is called recursively until a valid - * placement is found. Returns undefined if no valid placement is found, meaning - * the block is already at the top of the document. - * - * @param movedBlock What is being moved (see {@link MovedBlock}). Carried - * through the recursion because "is this placement valid?" depends on it: a - * candidate destination has to accept the moved node's type. Only read by - * `checkPlacementIsValid`. - * @param prevBlock The candidate previous sibling, i.e. the block the - * placement is measured against. Steps further back on each recursion. - * @param parentBlock The parent of `prevBlock`'s level, used for case 3. - */ -function getMoveUpPlacement( - editor: BlockNoteEditor, - movedBlock: MovedBlock, - prevBlock?: Block, - parentBlock?: Block, -): - | { referenceBlock: BlockIdentifier; placement: "before" | "after" } - | undefined { - let referenceBlock: Block | undefined; - let placement: "before" | "after" | undefined; - - if (!prevBlock) { - if (parentBlock) { - referenceBlock = parentBlock; - placement = "before"; - } - } else if (prevBlock.children.length > 0) { - referenceBlock = prevBlock.children[prevBlock.children.length - 1]; - placement = "after"; - } else { - referenceBlock = prevBlock; - placement = "before"; - } - - // Case when the block is already at the top of the document. - if (!referenceBlock || !placement) { - return undefined; - } - - if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) { - const referenceBlockParent = editor.getParentBlock(referenceBlock); - return getMoveUpPlacement( - editor, - movedBlock, - placement === "after" - ? referenceBlock - : editor.getPrevBlock(referenceBlock), - referenceBlockParent, - ); - } - - return { referenceBlock, placement }; +): NodeType { + const first = dissolveLayoutOnlyBlocks([block], editor.pmSchema)[0]; + const type = first && editor.pmSchema.nodes[first.type]; + return type && isLayoutNode(type) + ? type + : editor.pmSchema.nodes["blockContainer"]; } /** - * Gets the placement for moving a block down. This has 3 cases: - * 1. If the block has a next sibling without children, the placement is after - * it. - * 2. If the block has a next sibling with children, the placement is before the - * first child. - * 3. If the block has no next sibling, but is nested, the placement is - * after its parent. - * If the placement is invalid, the function is called recursively until a valid - * placement is found. Returns undefined if no valid placement is found, meaning - * the block is already at the bottom of the document. - * - * @param movedBlock What is being moved; see `getMoveUpPlacement`. - * @param nextBlock The candidate next sibling, i.e. the block the placement is - * measured against. Steps further forward on each recursion. - * @param parentBlock The parent of `nextBlock`'s level, used for case 3. + * Searches in document order for a placement accepted by the moved node's type. + * Moving past a sibling with children enters its nearest child; reaching the + * end of a sibling list moves outside its parent. */ -function getMoveDownPlacement( +function getMovePlacement( editor: BlockNoteEditor, - movedBlock: MovedBlock, - nextBlock?: Block, - parentBlock?: Block, + nodeType: NodeType, + direction: "up" | "down", + sibling?: Block, + parent?: Block, ): | { referenceBlock: BlockIdentifier; placement: "before" | "after" } | undefined { - let referenceBlock: Block | undefined; - let placement: "before" | "after" | undefined; - - if (!nextBlock) { - if (parentBlock) { - referenceBlock = parentBlock; - placement = "after"; + const outside = direction === "up" ? "before" : "after"; + const inside = direction === "up" ? "after" : "before"; + while (sibling || parent) { + const hasChildren = sibling && sibling.children.length > 0; + const referenceBlock = sibling + ? hasChildren + ? sibling.children[direction === "up" ? sibling.children.length - 1 : 0] + : sibling + : parent!; + const placement = hasChildren ? inside : outside; + const valid = editor.transact((tr) => { + const target = getNodeById(referenceBlock.id, tr.doc); + return ( + target !== undefined && + getInsertionPos( + tr.doc, + getBlockInfoAt(tr.doc, target.posBeforeNode), + placement, + nodeType, + ) !== null + ); + }); + if (valid) { + return { referenceBlock, placement }; } - } else if (nextBlock.children.length > 0) { - referenceBlock = nextBlock.children[0]; - placement = "before"; - } else { - referenceBlock = nextBlock; - placement = "after"; - } - - // Case when the block is already at the bottom of the document. - if (!referenceBlock || !placement) { - return undefined; - } - - if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) { - const referenceBlockParent = editor.getParentBlock(referenceBlock); - return getMoveDownPlacement( - editor, - movedBlock, - placement === "before" + parent = editor.getParentBlock(referenceBlock); + sibling = + placement === inside ? referenceBlock - : editor.getNextBlock(referenceBlock), - referenceBlockParent, - ); + : direction === "up" + ? editor.getPrevBlock(referenceBlock) + : editor.getNextBlock(referenceBlock); } - - return { referenceBlock, placement }; + return undefined; } -export function moveBlocksUp( +function moveBlocksInDirection( editor: BlockNoteEditor, + direction: "up" | "down", blockIdentifier?: BlockIdentifier, ) { editor.transact(() => { - let sourceBlock: Block | undefined; + let blocks: Block[]; if (blockIdentifier) { - sourceBlock = editor.getBlock(blockIdentifier); - if (!sourceBlock) { + const block = editor.getBlock(blockIdentifier); + if (!block) { return; } + blocks = [block]; } else { - const selection = editor.getSelection(); - sourceBlock = - selection?.blocks[0] || editor.getTextCursorPosition().block; + blocks = editor.getSelection()?.blocks || [ + editor.getTextCursorPosition().block, + ]; } - const moveUpPlacement = getMoveUpPlacement( + // The last selected block anchors a downward move, but insertion always + // starts with the first selected block. + const sourceBlock = blocks[direction === "up" ? 0 : blocks.length - 1]; + const target = getMovePlacement( editor, - // `moveBlocks` inserts the flattened selection (a `column` goes in as - // its children), so the placement is validated for the block that - // actually lands at the destination, not for the raw block. - toMovedBlock(editor, flattenColumns([sourceBlock])[0] ?? sourceBlock), - editor.getPrevBlock(sourceBlock), + getMovedNodeType(editor, blocks[0]), + direction, + direction === "up" + ? editor.getPrevBlock(sourceBlock) + : editor.getNextBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); - - if (!moveUpPlacement) { + if (!target) { return; } if (blockIdentifier) { - moveBlocks( - editor, - [sourceBlock], - moveUpPlacement.referenceBlock, - moveUpPlacement.placement, - ); + moveBlocks(editor, blocks, target.referenceBlock, target.placement); } else { moveSelectedBlocksAndSelection( editor, - moveUpPlacement.referenceBlock, - moveUpPlacement.placement, + target.referenceBlock, + target.placement, ); } }); } -export function moveBlocksDown( +export function moveBlocksUp( editor: BlockNoteEditor, blockIdentifier?: BlockIdentifier, ) { - 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, - // See `moveBlocksUp`: validate for the flattened block that actually - // lands at the destination. - toMovedBlock( - editor, - flattenColumns([firstMovedBlock])[0] ?? firstMovedBlock, - ), - editor.getNextBlock(sourceBlock), - editor.getParentBlock(sourceBlock), - ); - - if (!moveDownPlacement) { - return; - } + moveBlocksInDirection(editor, "up", blockIdentifier); +} - if (blockIdentifier) { - moveBlocks( - editor, - [sourceBlock], - moveDownPlacement.referenceBlock, - moveDownPlacement.placement, - ); - } else { - moveSelectedBlocksAndSelection( - editor, - moveDownPlacement.referenceBlock, - moveDownPlacement.placement, - ); - } - }); +export function moveBlocksDown( + editor: BlockNoteEditor, + blockIdentifier?: BlockIdentifier, +) { + moveBlocksInDirection(editor, "down", blockIdentifier); } diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts index 8247e9391c..fb83bc8a10 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { afterAll, beforeAll } from "vite-plus/test"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js"; /** * Custom test setup with a document designed to reproduce nesting/unnesting bugs. @@ -646,6 +647,261 @@ describe("unnestBlock / liftListItem", () => { }); }); +// A second editor, on a schema that has layout blocks. `setupNestTestEnv` +// builds a default-schema editor, which can't express any of the cases below. +function setupLayoutNestTestEnv() { + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + beforeAll(() => { + editor = BlockNoteEditor.create({ schema: layoutSchema }); + editor.mount(div); + }); + + afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; + }); + + return (doc: PartialBlock[]) => { + editor.replaceBlocks(editor.document, doc); + return editor; + }; +} + +// `canNestBlock` and `canUnnestBlock` run the real command on a transaction +// that is thrown away, rather than restating its preconditions. The cases here +// are the ones where the old, restated preconditions gave the wrong answer: +// they looked at a previous sibling's mere existence and at the block's depth, +// neither of which knows anything about layout blocks. +describe("canNestBlock / canUnnestBlock around layout blocks", () => { + const withLayoutEditor = setupLayoutNestTestEnv(); + + it("Reports that a block cannot be nested under a layout block sibling", () => { + const editor = withLayoutEditor([ + { + id: "callout-0", + type: "callout", + children: [ + { id: "callout-child", type: "paragraph", content: "Callout child" }, + ], + }, + { id: "paragraph-0", type: "paragraph", content: "Paragraph 0" }, + ]); + + editor.setTextCursorPosition("paragraph-0", "start"); + + const before = editor.document; + expect(editor.canNestBlock()).toBe(false); + + // And the answer matches what nesting actually does. + editor.nestBlock(); + expect(editor.document).toEqual(before); + }); + + it("Reports that a layout block's child cannot be unnested out of it", () => { + const editor = withLayoutEditor([ + { + id: "callout-0", + type: "callout", + children: [ + { id: "callout-child", type: "paragraph", content: "Callout child" }, + ], + }, + ]); + + editor.setTextCursorPosition("callout-child", "start"); + + const before = editor.document; + expect(editor.canUnnestBlock()).toBe(false); + + editor.unnestBlock(); + expect(editor.document).toEqual(before); + }); + + it("Reports that a block with a plain previous sibling can be nested", () => { + const editor = withLayoutEditor([ + { id: "paragraph-0", type: "paragraph", content: "Paragraph 0" }, + { id: "paragraph-1", type: "paragraph", content: "Paragraph 1" }, + ]); + + editor.setTextCursorPosition("paragraph-1", "start"); + + const before = editor.document; + expect(editor.canNestBlock()).toBe(true); + // The probe runs the command on a transaction it never dispatches, so + // answering must not change the document. + expect(editor.document).toEqual(before); + + editor.nestBlock(); + expect(editor.getBlock("paragraph-0")!.children.map((c) => c.id)).toEqual([ + "paragraph-1", + ]); + expect(editor.canUnnestBlock()).toBe(true); + }); + + it("Nests and unnests a block inside a layout block's children", () => { + const editor = withLayoutEditor([ + { + id: "callout-0", + type: "callout", + children: [ + { id: "child-0", type: "paragraph", content: "Child 0" }, + { id: "child-1", type: "paragraph", content: "Child 1" }, + ], + }, + ]); + + const before = editor.document; + + editor.setTextCursorPosition("child-1", "start"); + expect(editor.canNestBlock()).toBe(true); + editor.nestBlock(); + + expect(editor.getBlock("callout-0")!.children.map((c) => c.id)).toEqual([ + "child-0", + ]); + expect(editor.getBlock("child-0")!.children.map((c) => c.id)).toEqual([ + "child-1", + ]); + + editor.setTextCursorPosition("child-1", "start"); + expect(editor.canUnnestBlock()).toBe(true); + editor.unnestBlock(); + + expect(editor.document).toEqual(before); + }); + + // The nest step moves the block into the previous sibling's children, where + // ProseMirror can refuse it. That refusal has to come back as `false` + // rather than escape as a `TransformError`: `canNestBlock` runs on every + // selection change (the formatting toolbar's nest buttons), and Tab runs + // `nestBlock` in the keymap. + // + // Here the previous sibling's children end in a layout block. The step opens + // into that last child to join the block onto it, which only works for a + // `blockContainer`: "Cannot join blockContainer onto callout". + it("Nests after a layout block at the end of the previous sibling's children", () => { + const editor = withLayoutEditor([ + { + id: "paragraph-0", + type: "paragraph", + content: "Paragraph 0", + children: [ + { + id: "callout-0", + type: "callout", + children: [ + { id: "callout-child", type: "paragraph", content: "In callout" }, + ], + }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + editor.setTextCursorPosition("after", "start"); + + expect(editor.canNestBlock()).toBe(true); + editor.nestBlock(); + expect( + editor.getBlock("paragraph-0")!.children.map((child) => child.id), + ).toEqual(["callout-0", "after"]); + expect(editor.getTextCursorPosition().block.id).toBe("after"); + }); +}); + +// A `grid` holds only `gridCell`s, so a selection spanning two cells has no +// nestable range inside the grid. The range has to resolve outside it, at the +// `blockGroup` the grid sits in, so Tab moves the grid as a unit rather than +// doing nothing. `columnList`/`column` in `@blocknote/xl-multi-column` are the +// same shape, and the user-facing case this guards. +describe("Nesting a selection that spans two of a layout block's children", () => { + const withLayoutEditor = setupLayoutNestTestEnv(); + + function gridWith(id: string) { + return { + id, + type: "grid" as const, + children: [ + { + id: `${id}-cell-a`, + type: "gridCell" as const, + children: [ + { id: `${id}-a`, type: "paragraph" as const, content: "A" }, + ], + }, + { + id: `${id}-cell-b`, + type: "gridCell" as const, + children: [ + { id: `${id}-b`, type: "paragraph" as const, content: "B" }, + ], + }, + ], + }; + } + + it("Nests the whole grid under its previous sibling", () => { + const editor = withLayoutEditor([ + { id: "paragraph-0", type: "paragraph", content: "Paragraph 0" }, + gridWith("grid-0"), + ]); + + editor.setSelection("grid-0-a", "grid-0-b"); + + expect(editor.canNestBlock()).toBe(true); + editor.nestBlock(); + + expect(editor.document.map((block) => block.id)).toEqual(["paragraph-0"]); + expect(editor.getBlock("paragraph-0")!.children.map((c) => c.id)).toEqual([ + "grid-0", + ]); + // The grid itself is untouched — only its position changed. + expect(editor.getBlock("grid-0")!.children.map((c) => c.id)).toEqual([ + "grid-0-cell-a", + "grid-0-cell-b", + ]); + }); + + it("Unnests the whole grid out of its parent", () => { + const editor = withLayoutEditor([ + { + id: "paragraph-0", + type: "paragraph", + content: "Paragraph 0", + children: [gridWith("grid-0")], + }, + ]); + + editor.setSelection("grid-0-a", "grid-0-b"); + + expect(editor.canUnnestBlock()).toBe(true); + editor.unnestBlock(); + + expect(editor.document.map((block) => block.id)).toEqual([ + "paragraph-0", + "grid-0", + ]); + expect(editor.getBlock("paragraph-0")!.children).toEqual([]); + expect(editor.getBlock("grid-0")!.children.map((c) => c.id)).toEqual([ + "grid-0-cell-a", + "grid-0-cell-b", + ]); + }); + + it("Reports no nesting when the grid has no previous sibling", () => { + const editor = withLayoutEditor([gridWith("grid-0")]); + + editor.setSelection("grid-0-a", "grid-0-b"); + + const before = editor.document; + expect(editor.canNestBlock()).toBe(false); + editor.nestBlock(); + expect(editor.document).toEqual(before); + }); +}); + /** Recursively collects all block IDs from a document */ function flattenBlockIds(blocks: any[]): string[] { const ids: string[] = []; diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index 55f7200f3f..52136f092e 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -3,6 +3,9 @@ import { Transaction } from "prosemirror-state"; import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { CHILD_CONTAINER_GROUP } from "../../../../schema/blocks/children.js"; +import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; + /** * Whether `node` is the sibling list that nesting and unnesting operate on: a * node that holds child blocks, and can hold the kind of node being moved. @@ -18,7 +21,7 @@ import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; function holdsItems(node: Node, itemType: NodeType) { return ( node.childCount > 0 && - node.type.isInGroup("childContainer") && + node.type.isInGroup(CHILD_CONTAINER_GROUP) && node.type.contentMatch.matchType(itemType) !== null ); } @@ -32,6 +35,10 @@ function holdsItems(node: Node, itemType: NodeType) { * 2. nestedBefore checks groupType instead of parent.type * 3. Slice creates groupType instead of parent.type * 4. Operates on Transaction directly instead of state+dispatch + * 5. Opens the slice into the previous sibling's child group, not into the + * group's last child. The original cut one level deeper to keep `insert` + * at 1; that failed when the last child was a layout block node (a column + * list), which a `blockContainer` can't be joined onto. */ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { const { $from, $to } = tr.selection; @@ -50,26 +57,32 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { } const nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change 2 - const inner = Fragment.from(nestedBefore ? itemType.create() : null); + // The slice opens into the previous sibling and, when it already has a child + // group, into that group, so the block becomes its last child. (change 5) + const depth = nestedBefore ? 2 : 1; const slice = new Slice( - Fragment.from( - itemType.create(null, Fragment.from(groupType.create(null, inner))), // change 3 - ), - nestedBefore ? 3 : 1, + Fragment.from(itemType.create(null, Fragment.from(groupType.create()))), // change 3 + depth, 0, ); const before = range.start; const after = range.end; + // Nothing above can make the schema refuse this step: `parent` holds + // regular blocks, so it may lose one (a layout block of any blocks has no + // `min` above 1), and the slice only joins the previous sibling and its + // group. A refusal here would be a bug, so it throws. tr.step( new ReplaceAroundStep( - before - (nestedBefore ? 3 : 1), + before - depth, after, before, after, slice, - 1, + // `Slice.insertAt` adds the slice's open depth, so this lands at content + // position 2: inside the group. + 2 - depth, true, ), ).scrollIntoView(); @@ -175,6 +188,9 @@ export function liftItem( tr: Transaction, itemType: NodeType, groupType: NodeType, // change 2 + // Whether a block may be outdented out of `parent` (its + // `keyboard.childrenCanOutdent` setting). + canOutdentFrom: (parent: Node) => boolean, ) { const { $from, $to } = tr.selection; const range = $from.blockRange($to, (node) => holdsItems(node, itemType)); // change 1 @@ -182,7 +198,14 @@ export function liftItem( return false; } - if ($from.node(range.depth - 1).type === itemType) { + const parent = $from.node(range.depth - 1); + // A block whose children can't be outdented keeps them: unnesting stops at + // its edge rather than lifting the block out of it. + if (parent.type === itemType && !canOutdentFrom(parent)) { + return false; + } + + if (parent.type === itemType) { // Inside a parent node return liftToOuterList(tr, itemType, groupType, range); // change 2 } @@ -198,6 +221,12 @@ function unnestCommand(editor: BlockNoteEditor) { tr, editor.pmSchema.nodes["blockContainer"], editor.pmSchema.nodes["blockGroup"], + (parent) => { + const block = nodeToBlock(parent, tr.doc); + return editor.schema.blockSpecs[block.type].implementation.keyboard( + block, + ).childrenCanOutdent; + }, ); } @@ -208,8 +237,8 @@ export function unnestBlock(editor: BlockNoteEditor) { // `canExec` hands the command a transaction it never dispatches, so "can I // nest?" is answered by nesting and throwing the result away. A second // statement of the preconditions would drift from the command it describes — -// and did: it read a previous sibling's mere existence, so a block before the -// cursor enabled the button while `nestBlock` did nothing. +// and did: it read a previous sibling's mere existence, so a layout block +// before the cursor enabled the button while `nestBlock` did nothing. export function canNestBlock(editor: BlockNoteEditor) { return editor.canExec((state) => nestCommand(editor)(state.tr)); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d9e1e72981..d2d43aa101 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -1,7 +1,10 @@ import { type Node } from "prosemirror-model"; import { type Transaction } from "prosemirror-state"; import type { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; -import { getNodeId } from "../../../getBlockInfoFromPos.js"; +import { + getNodeId, + getAncestorLayoutBlocks, +} from "../../../getBlockInfoFromPos.js"; import type { BlockIdentifier, BlockSchema, @@ -11,7 +14,7 @@ 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 { fixLayoutBlocksById } from "../../layoutBlocks/fixLayoutBlock.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +25,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixLayoutBlocks?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,13 +46,21 @@ export function removeAndInsertBlocks< ), ); const removedBlocks: Block[] = []; - const columnListPositions = new Set(); + // Ancestor layout blocks 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 layoutBlocksToFix: { id: string; depth: number }[] = []; const idOfFirstBlock = typeof blocksToRemove[0] === "string" ? blocksToRemove[0] : blocksToRemove[0].id; - let removedSize = 0; + + // The walk below reads the document as it is now, but mutates it as it + // goes, so its positions go stale. `tr.mapping` already tracks exactly + // that; sliced from here so it ignores steps the caller added earlier. + const stepsBefore = tr.steps.length; + const mapPos = (pos: number) => tr.mapping.slice(stepsBefore).map(pos); tr.doc.descendants((node, pos) => { // Skips traversing nodes after all target blocks have been removed. @@ -73,39 +84,35 @@ export function removeAndInsertBlocks< idsOfBlocksToRemove.delete(nodeId); if (blocksToInsert.length > 0 && nodeId === idOfFirstBlock) { - const oldDocSize = tr.doc.nodeSize; - tr.insert(pos, nodesToInsert); - const newDocSize = tr.doc.nodeSize; - - removedSize += oldDocSize - newDocSize; + tr.insert(mapPos(pos), nodesToInsert); } - const oldDocSize = tr.doc.nodeSize; + const $pos = tr.doc.resolve(mapPos(pos)); - 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 layoutBlock of getAncestorLayoutBlocks($pos.doc, $pos.pos)) { + if (!layoutBlocksToFix.some((c) => c.id === layoutBlock.id)) { + layoutBlocksToFix.push(layoutBlock); + } } + // When the block is the only child of a nested `blockGroup`, delete the + // group with it (`blockGroup` acting as a `min: 1` layout block that unwraps + // when emptied). This can't route through `fixLayoutBlock`: repair runs after + // the delete, and by then ProseMirror's replace-fitting has padded the + // `blockGroupChild+` group with a fresh empty `blockContainer` + // indistinguishable from an intentional one. Only here, before the + // delete, is "this was the group's last child" still knowable. + const parent = $pos.node(); if ( - $pos.node().type.name === "blockGroup" && + parent.type.name === "blockGroup" && $pos.node($pos.depth - 1).type.name !== "doc" && - $pos.node().childCount === 1 + parent.childCount === 1 ) { - // Checks if the block is the only child of a parent `blockGroup` node. - // In this case, we need to delete the parent `blockGroup` node instead - // of just the `blockContainer`. tr.delete($pos.before(), $pos.after()); } else { - tr.delete(pos - removedSize, pos - removedSize + node.nodeSize); + tr.delete($pos.pos, $pos.pos + node.nodeSize); } - const newDocSize = tr.doc.nodeSize; - removedSize += oldDocSize - newDocSize; - return false; }); @@ -119,11 +126,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 layout blocks 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 layout blocks as-is. + if (options.fixLayoutBlocks !== false) { + fixLayoutBlocksById(tr, layoutBlocksToFix); } // 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 b403aec535..9f2ba196f5 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -8,6 +8,7 @@ import { getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; +import { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { splitBlockCommand } from "./splitBlock.js"; @@ -17,9 +18,10 @@ function splitBlock( posInBlock: number, keepType?: boolean, keepProps?: boolean, + keepChildren?: boolean, ) { getEditor()._tiptapEditor.commands.command( - splitBlockCommand(posInBlock, keepType, keepProps), + splitBlockCommand(posInBlock, keepType, keepProps, keepChildren), ); } @@ -147,3 +149,216 @@ describe("Test splitBlocks", () => { expect(anchorIsAtStartOfNewBlock).toBeTruthy(); }); }); + +// `splitBlockTr` splits two levels deep (`blockContent` and its +// `blockContainer`), which assumes the block's parent is a children holder that +// accepts another `blockContainer`. A layout block's children holder is a +// different node type than `blockGroup`, so these pin that the split lands +// inside the layout block rather than tearing it open. +describe("Test splitBlocks inside layout blocks", () => { + const getLayoutEditor = setupTestEnv({ + schema: layoutSchema, + document: [ + { id: "before", type: "paragraph", content: "Before" }, + { + id: "callout-0", + type: "callout", + children: [ + { + id: "callout-child-0", + type: "paragraph", + content: "Callout child", + }, + { + id: "callout-child-1", + type: "heading", + content: "Callout heading", + children: [ + { + id: "nested-child", + type: "paragraph", + content: "Nested child", + }, + ], + }, + ], + }, + { + id: "grid-0", + type: "grid", + children: [ + { + id: "cell-0", + type: "gridCell", + children: [ + { id: "cell-0-p", type: "paragraph", content: "Cell zero" }, + ], + }, + { + id: "cell-1", + type: "gridCell", + children: [ + { id: "cell-1-p", type: "paragraph", content: "Cell one" }, + ], + }, + ], + }, + ], + }); + + function splitLayoutBlock( + blockId: string, + offset: number, + keepChildren = false, + ) { + const editor = getLayoutEditor(); + + const posInBlock = editor.transact((tr) => { + const posInfo = getNodeById(blockId, tr.doc); + if (!posInfo) { + throw new Error(`Block with ID ${blockId} not found`); + } + + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + + // A layout block has no content to offset into, so we aim at the node + // itself, which is where a `NodeSelection` on it would put the anchor. + return info.hasContent + ? info.content.beforePos + offset + 1 + : info.block.beforePos; + }); + + return editor._tiptapEditor.commands.command( + splitBlockCommand(posInBlock, true, undefined, keepChildren), + ); + } + + function textOf(block: { content?: any }) { + return (block.content as { text: string }[]).map((c) => c.text).join(""); + } + + it("Splits a block inside a layout block in place", () => { + expect(splitLayoutBlock("callout-child-0", 7)).toBe(true); + + const document = getLayoutEditor().document; + + expect(document.map((block) => block.id)).toEqual([ + "before", + "callout-0", + "grid-0", + ]); + + const callout = document[1]; + expect(callout.type).toBe("callout"); + expect(callout.children.map(textOf)).toEqual([ + "Callout", + " child", + "Callout heading", + ]); + + expect(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Moves the block's children to the second half of the split", () => { + expect(splitLayoutBlock("callout-child-1", 7)).toBe(true); + + const callout = getLayoutEditor().document[1]; + + expect(callout.children.map(textOf)).toEqual([ + "Callout child", + "Callout", + " heading", + ]); + // The children follow the second half, as they do at the top level. + expect(callout.children[1].children).toEqual([]); + expect(callout.children[2].children.map((child) => child.id)).toEqual([ + "nested-child", + ]); + + expect(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Keeps the block's children on the first half with `keepChildren`", () => { + expect(splitLayoutBlock("callout-child-1", 7, true)).toBe(true); + + const callout = getLayoutEditor().document[1]; + + expect(callout.children.map(textOf)).toEqual([ + "Callout child", + "Callout", + " heading", + ]); + expect(callout.children[2].children).toEqual([]); + expect(callout.children[1].children.map((child) => child.id)).toEqual([ + "nested-child", + ]); + + expect(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Splits a block inside a nested layout block", () => { + expect(splitLayoutBlock("cell-0-p", 4)).toBe(true); + + const grid = getLayoutEditor().document[2]; + + expect(grid.type).toBe("grid"); + expect(grid.children.map((cell) => cell.id)).toEqual(["cell-0", "cell-1"]); + expect(grid.children[0].children.map(textOf)).toEqual(["Cell", " zero"]); + expect(grid.children[1].children.map(textOf)).toEqual(["Cell one"]); + + expect(() => getLayoutEditor().prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Does not split a layout block itself", () => { + const before = getLayoutEditor().document; + + expect(splitLayoutBlock("callout-0", 0)).toBe(false); + + expect(getLayoutEditor().document).toEqual(before); + }); +}); + +// By default the children follow the text after the caret into the second +// block. `keepChildren` (a closed toggle) keeps them with the first half, +// except at the start, where the whole content moves down. +describe("split child ownership", () => { + it.each([ + ["start", false, "second"], + ["middle", false, "second"], + ["end", false, "second"], + ["start", true, "second"], + ["middle", true, "first"], + ["end", true, "first"], + ] as const)( + "splitting at the %s with keepChildren %s leaves the children with the %s block", + (where, keepChildren, owner) => { + const editor = getEditor(); + const id = "paragraph-with-children"; + const children = editor.getBlock(id)!.children; + const target = getNodeById(id, editor.prosemirrorState.doc)!; + const info = getBlockInfoFromNode(target.node, target.posBeforeNode); + if (!info.hasContent) { + throw new Error("Expected content block"); + } + const offset = + where === "start" + ? 0 + : where === "end" + ? info.contentEnd - info.contentStart + : 4; + setSelectionWithOffset(editor.prosemirrorState.doc, id, offset); + splitBlock( + editor.prosemirrorState.selection.from, + undefined, + undefined, + keepChildren, + ); + const index = editor.document.findIndex((block) => block.id === id); + const [first, second] = editor.document.slice(index, index + 2); + expect((owner === "first" ? first : second).children).toEqual(children); + expect((owner === "first" ? second : first).children).toEqual([]); + expect(editor.getTextCursorPosition().block.id).toBe(second.id); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }, + ); +}); diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index d5229da6bf..96b82dbd22 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -10,6 +10,7 @@ export const splitBlockCommand = ( posInBlock: number, keepType?: boolean, keepProps?: boolean, + keepChildren?: boolean, ) => { return ({ state, @@ -19,7 +20,13 @@ export const splitBlockCommand = ( dispatch: ((args?: any) => any) | undefined; }) => { if (dispatch) { - return splitBlockTr(state.tr, posInBlock, keepType, keepProps); + return splitBlockTr( + state.tr, + posInBlock, + keepType, + keepProps, + keepChildren, + ); } return true; @@ -31,6 +38,7 @@ export const splitBlockTr = ( posInBlock: number, keepType?: boolean, keepProps?: boolean, + keepChildren?: boolean, ): boolean => { const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock); @@ -55,7 +63,28 @@ export const splitBlockTr = ( }, ]; + // The children follow the text after the caret into the second block, as + // `tr.split` leaves them. With `keepChildren` they move back to the first + // half, within the same undo step. At the start, the whole content goes to + // the second block, and the children with it. + const children = + keepChildren && posInBlock !== info.contentStart + ? info.children + : undefined; + if (children) { + tr.delete(children.beforePos, children.afterPos); + } tr.split(posInBlock, 2, types); + if (children) { + const original = tr.doc.nodeAt(info.block.beforePos); + if (!original?.firstChild) { + throw new Error("Split lost its original block"); + } + tr.insert( + info.block.beforePos + 1 + original.firstChild.nodeSize, + children.node, + ); + } return true; }; 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 77d2cad826..2aca479d37 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts @@ -2,13 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import type { PartialBlock } from "../../../../blocks/defaultBlocks.js"; import { getBlockInfoFromNode } from "../../../getBlockInfoFromPos.js"; - -// Adapter over the renamed producer: `getNodeById` already returns the -// `{ node, posBeforeNode }` pair it takes. -function getBlockInfo(posInfo: { node: any; posBeforeNode: number }) { - return getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); -} import { getNodeById } from "../../../nodeUtil.js"; +import { layoutSchema } from "../../layoutBlocks/layoutBlocks.fixture.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { updateBlock } from "./updateBlock.js"; @@ -183,9 +178,11 @@ describe("Test updateBlock", () => { }); it("Update partial (offset start)", () => { - const info = getBlockInfo( - getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById( + "heading-with-everything", + getEditor().prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); @@ -212,9 +209,11 @@ describe("Test updateBlock", () => { }); it("Update partial (offset start + end)", () => { - const info = getBlockInfo( - getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById( + "heading-with-everything", + getEditor().prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); @@ -242,9 +241,11 @@ describe("Test updateBlock", () => { }); it("Update partial (props + offset end)", () => { - const info = getBlockInfo( - getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById( + "heading-with-everything", + getEditor().prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); @@ -275,9 +276,8 @@ describe("Test updateBlock", () => { }); it("Update partial (table cell)", () => { - const info = getBlockInfo( - getNodeById("table-0", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById("table-0", getEditor().prosemirrorState.doc)!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); if (!info.hasContent) { throw new Error("table-0 is not a block container"); @@ -305,9 +305,8 @@ describe("Test updateBlock", () => { }); it("Update partial (table row)", () => { - const info = getBlockInfo( - getNodeById("table-0", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById("table-0", getEditor().prosemirrorState.doc)!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); if (!info.hasContent) { throw new Error("table-0 is not a block container"); @@ -940,12 +939,11 @@ describe("Test updateBlock minimal steps", () => { it("Type change with offset content replace stays minimal and valid", () => { const editor = getEditor(); - const info = getBlockInfo( - getNodeById( - "paragraph-with-styled-content", - editor.prosemirrorState.doc, - )!, - ); + const posInfo = getNodeById( + "paragraph-with-styled-content", + editor.prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); if (!info.hasContent) { throw new Error("paragraph-with-styled-content is not a block container"); } @@ -982,3 +980,231 @@ describe("Test updateBlock minimal steps", () => { expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); }); }); + +// Changing a block's type across the content/layout block divide can't happen in +// place, so `updateBlock` rebuilds the node and has to decide what to do with +// the content the old shape held and the new one can't. These tests pin that +// decision. Assertions are explicit rather than snapshotted because the point +// is *where* the carried content ends up. +describe("Test updateBlock content carry-over", () => { + const getLayoutEditor = setupTestEnv({ + schema: layoutSchema, + document: [ + { + id: "paragraph-with-text", + type: "paragraph", + content: "Paragraph with text", + }, + { + id: "empty-paragraph", + type: "paragraph", + }, + { + id: "paragraph-with-text-and-children", + type: "paragraph", + content: "Parent text", + children: [ + { + id: "existing-child", + type: "paragraph", + content: "Existing child", + }, + ], + }, + { + id: "table-0", + type: "table", + content: { + type: "tableContent", + rows: [{ cells: ["Cell 1", "Cell 2"] }], + }, + }, + { + id: "callout-0", + type: "callout", + children: [ + { + id: "callout-child", + type: "paragraph", + content: "Callout child", + }, + ], + }, + ], + }); + + // A block that changes shape is rebuilt rather than updated in place. The + // rebuilt block keeps its ID, so comments and other references to it stay + // valid. Before, it got a new ID; the first and the last test check this. + it("Moves inline content into a child paragraph when becoming a layout block", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => + updateBlock(tr, "paragraph-with-text", { type: "callout" }), + ); + + const block = editor.document[0] as any; + expect(block.type).toBe("callout"); + expect(block.id).toBe("paragraph-with-text"); + expect(block.children).toHaveLength(1); + expect(block.children[0].type).toBe("paragraph"); + expect(block.children[0].content).toEqual([ + { type: "text", text: "Paragraph with text", styles: {} }, + ]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Fills a layout block's children when there is no content to carry", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => + updateBlock(tr, "empty-paragraph", { type: "pair" }), + ); + + // An empty paragraph carries nothing, so the rebuilt node holds just the + // empty fill its content expression requires — two callouts for the + // pair's `min: 2`, each with the one empty block a callout needs. + const block = editor.document[1] as any; + expect(block.type).toBe("pair"); + expect(block.children.map((child: any) => child.type)).toEqual([ + "callout", + "callout", + ]); + for (const callout of block.children) { + expect(callout.children.map((child: any) => child.content)).toEqual([[]]); + } + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Puts carried content before existing children", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => + updateBlock(tr, "paragraph-with-text-and-children", { type: "callout" }), + ); + + // The paragraph holding the carried text takes the place the text used to + // occupy, i.e. above the children that were already nested under it. + const block = editor.document[2] as any; + expect(block.type).toBe("callout"); + expect(block.children.map((child: any) => child.content[0].text)).toEqual([ + "Parent text", + "Existing child", + ]); + expect(block.children[1].id).toBe("existing-child"); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Drops table content when becoming a layout block", () => { + const editor = getLayoutEditor(); + // Table content isn't an inline array, so there is no sensible paragraph to + // wrap it in. It's dropped, and the layout block seeds as if the block had + // been empty. + expect(() => + editor.transact((tr) => updateBlock(tr, "table-0", { type: "callout" })), + ).not.toThrow(); + + const block = editor.document[3] as any; + expect(block.type).toBe("callout"); + expect(block.content).toBeUndefined(); + expect(block.children).toHaveLength(1); + expect(block.children[0].type).toBe("paragraph"); + expect(block.children[0].content).toEqual([]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it.each(["paragraph-with-text", "callout-0"])( + "Rejects conversion of %s rather than dropping incompatible children", + (id) => { + const editor = getLayoutEditor(); + const before = editor.prosemirrorState.doc; + expect(() => + editor.transact((tr) => updateBlock(tr, id, { type: "grid" })), + ).toThrow(); + expect(editor.prosemirrorState.doc.eq(before)).toBe(true); + }, + ); + + it("Allows explicit replacement children when converting to a restricted layout block", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => + updateBlock(tr, "callout-0", { + type: "grid", + children: [ + { + type: "gridCell", + children: [{ type: "paragraph", content: "Replacement" }], + }, + { type: "gridCell", children: [{ type: "paragraph" }] }, + ], + }), + ); + expect(editor.document[4].type).toBe("grid"); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Keeps a layout block's children when changing between layout block types", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => updateBlock(tr, "callout-0", { type: "box" })); + + // Neither layout block holds content of its own, so the children move across + // untouched rather than being re-seeded. + const block = editor.document[4] as any; + expect(block.type).toBe("box"); + expect(block.children.map((child: any) => child.id)).toEqual([ + "callout-child", + ]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Keeps a layout block's children alongside the content it gains", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => + updateBlock(tr, "callout-0", { + type: "heading", + content: "Now a heading", + }), + ); + + // The layout block had nowhere to put inline content; the heading does, so + // the given content lands there and the children stay nested under it. + const block = editor.document[4] as any; + expect(block.type).toBe("heading"); + expect(block.content).toEqual([ + { type: "text", text: "Now a heading", styles: {} }, + ]); + expect(block.children.map((child: any) => child.id)).toEqual([ + "callout-child", + ]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Updates a layout block's props without rebuilding it", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => + updateBlock(tr, "callout-0", { props: { flavor: "warning" } }), + ); + + // No shape change, so this is an in-place attribute update: the layout block + // keeps its ID (unlike the type changes above) and its children. + const block = editor.document[4] as any; + expect(block.id).toBe("callout-0"); + expect(block.props.flavor).toBe("warning"); + expect(block.children.map((child: any) => child.id)).toEqual([ + "callout-child", + ]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Keeps a layout block's children and invents no content when becoming a block", () => { + const editor = getLayoutEditor(); + editor.transact((tr) => + updateBlock(tr, "callout-0", { type: "paragraph" }), + ); + + const block = editor.document[4] as any; + expect(block.type).toBe("paragraph"); + expect(block.id).toBe("callout-0"); + expect(block.content).toEqual([]); + expect(block.children).toHaveLength(1); + expect(block.children[0].id).toBe("callout-child"); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); +}); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index e487f99fd4..59e8d93bb9 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -27,7 +27,11 @@ 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 { + createBlockGroup, + isLayoutNode, +} from "../../../../schema/blocks/children.js"; // for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface export const updateBlockCommand = < @@ -70,10 +74,10 @@ export function updateBlockTr< const stepsBefore = tr.mapping.maps.length; const blockInfo = getBlockInfoAt(tr.doc, posBeforeBlock); - let cellAnchor: CellAnchor | null = null; - if (blockInfo.blockNoteType === "table") { - cellAnchor = captureCellAnchor(tr); - } + const cellAnchor = + blockInfo.hasContent && blockInfo.blockNoteType === "table" + ? captureCellAnchor(tr) + : null; const pmSchema = getPmSchema(tr); @@ -85,8 +89,6 @@ export function updateBlockTr< throw new Error("Invalid replaceFromPos or replaceToPos"); } - // Adds blockGroup node with child blocks if necessary. - const newBlockType = block.type || blockInfo.blockNoteType; const newNodeType = pmSchema.nodes[newBlockType]; const newBnBlockNodeType = newNodeType.isInGroup("bnBlock") @@ -109,27 +111,58 @@ export function updateBlockTr< ? replaceToPos - blockInfo.contentStart : undefined; - // `hasContent` is exactly `blockContainer`-ness, and a block type resolves - // to either a `blockContent` node (a regular block) or a `bnBlock` one (a - // wrapper), so the two together say whether the update keeps the block's - // shape. Only a same-shape update can happen in place. - if (blockInfo.hasContent !== newNodeType.isInGroup("blockContent")) { - // switching from blockContainer to non-blockContainer or v.v. - // currently breaking for column slash menu items converting empty block - // to column. - - // currently, we calculate the new node and replace the entire node with the desired new node. - // 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.block.node, tr.doc); + // Rebuild when the block's shape changes or a layout block's existing children + // cannot satisfy the new type (e.g. changing to a pair requiring two children). + if ( + blockInfo.hasContent !== newNodeType.isInGroup("blockContent") || + (!blockInfo.hasContent && + !newNodeType.validContent(blockInfo.block.node.content)) + ) { + const existingBlock: Block = nodeToBlock( + blockInfo.block.node, + tr.doc, + ); + const targetConfig = getBlockSchema(pmSchema)[newBlockType]; + let content: PartialBlock["content"]; + const children: PartialBlock[] = [...existingBlock.children]; + if (Array.isArray(existingBlock.content) && existingBlock.content.length) { + if ( + targetConfig.content === "inline" || + targetConfig.content === "plain" + ) { + content = existingBlock.content; + } else if (isLayoutNode(newNodeType)) { + children.unshift({ type: "paragraph", content: existingBlock.content }); + } + } + const replacementNode = blockToNode( { - children: existingBlock.children, // if no children are passed in, use existing children + // The rebuilt block keeps its id, so references to it stay valid. + id: existingBlock.id, + ...(content ? { content } : {}), + // Omit empty children so a new layout block can seed its required children. + ...(children.length > 0 ? { children } : {}), ...block, }, pmSchema, ); replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc + + // Validate the parent too: a valid column still cannot replace a root block. + const $oldPos = tr.doc.resolve(blockInfo.block.beforePos); + if ( + !$oldPos.parent.canReplace( + $oldPos.index(), + $oldPos.index(), + Fragment.from(replacementNode), + ) + ) { + throw new Error( + `Cannot update block to "${newBlockType}": a "${$oldPos.parent.type.name}" doesn't accept it`, + ); + } + tr.replaceWith( blockInfo.block.beforePos, blockInfo.block.afterPos, @@ -163,8 +196,12 @@ export function updateBlockTr< ...block.props, }); - if (cellAnchor) { - restoreCellAnchor(tr, blockInfo, cellAnchor, stepsBefore); + if (cellAnchor && blockInfo.hasContent) { + restoreCellAnchor( + tr, + tr.mapping.slice(stepsBefore).map(blockInfo.content.beforePos), + cellAnchor, + ); } } @@ -177,12 +214,7 @@ function updateBlockContentNode< tr: Transform, oldNodeType: NodeType, newNodeType: NodeType, - blockInfo: { - children?: - | { node: PMNode; beforePos: number; afterPos: number } - | undefined; - content: { node: PMNode; beforePos: number; afterPos: number }; - }, + blockInfo: Extract, replaceFromOffset?: number, replaceToOffset?: number, ) { @@ -211,7 +243,7 @@ function updateBlockContentNode< // no custom content has been provided, use existing content IF possible // Since some block types contain inline content and others don't, // we either need to call setNodeMarkup to just update type & - // attributes, or replaceWith to replace the whole blockContent. + // attributes, or replaceWith to replace the whole content. const oldContent = blockInfo.content.node.content; if (oldNodeType.spec.content === "") { // keep old content, because it's empty anyway and should be compatible with @@ -235,7 +267,7 @@ function updateBlockContentNode< } } - // Now, changes the blockContent node type and adds the provided props + // Now, changes the content node type and adds the provided props // as attributes. Also preserves all existing attributes that are // compatible with the new type. // @@ -520,11 +552,10 @@ function updateChildren< return node; }); - // Checks if a blockGroup node already exists. if (blockInfo.children) { - // Replaces the child nodes in the existing blockGroup, only touching the - // range that actually changed (keeping unchanged leading/trailing - // children untouched). + // Replaces the child nodes in the existing children holder, only + // touching the range that actually changed (keeping unchanged + // leading/trailing children untouched). replaceContentMinimal( tr, blockInfo.children.beforePos, @@ -532,11 +563,12 @@ function updateChildren< ); } else if (blockInfo.hasContent) { // A `blockContainer` with no children yet: its `blockGroup` is lazy - // (`blockContent blockGroup?`), so insert a new one after the content - // node. + // (`blockContent blockGroup?`), so create it around the child nodes and + // insert it after the content node. (Layout blocks always have a children + // holder, so no holder implies a `blockContainer`.) tr.insert( blockInfo.content.afterPos, - pmSchema.nodes["blockGroup"].createChecked({}, childNodes), + createBlockGroup(pmSchema, childNodes), ); } } @@ -639,34 +671,10 @@ export function captureCellAnchor(tr: Transform): CellAnchor | null { function restoreCellAnchor( tr: Transform | Transaction, - blockInfo: BlockInfo, + tablePos: number, a: CellAnchor, - stepsBefore: number, ): boolean { - if (blockInfo.blockNoteType !== "table") { - return false; - } - - // 1) Resolve the table node in the current document - let tablePos = -1; - - if (blockInfo.hasContent) { - // Prefer the content position when available (points directly at the PM table node) - tablePos = tr.mapping.slice(stepsBefore).map(blockInfo.content.beforePos); - } else { - // Fallback: scan within the mapped block range to find the inner table node - const start = tr.mapping.slice(stepsBefore).map(blockInfo.block.beforePos); - const end = start + (tr.doc.nodeAt(start)?.nodeSize || 0); - tr.doc.nodesBetween(start, end, (node, pos) => { - if (node.type.name === "table") { - tablePos = pos; - return false; - } - return true; - }); - } - - const table = tablePos >= 0 ? tr.doc.nodeAt(tablePos) : null; + const table = tr.doc.nodeAt(tablePos); if (!table || table.type.name !== "table") { return false; } diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/fixLayoutBlock.ts b/packages/core/src/api/blockManipulation/layoutBlocks/fixLayoutBlock.ts new file mode 100644 index 0000000000..aee015d6d9 --- /dev/null +++ b/packages/core/src/api/blockManipulation/layoutBlocks/fixLayoutBlock.ts @@ -0,0 +1,173 @@ +import { Fragment, type Node, Slice } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; + +import { + isLayoutNode, + isNamedOnly, + minChildren, +} from "../../../schema/blocks/children.js"; +import { getNodeById } from "../../nodeUtil.js"; + +/** + * Whether `node` is a layout block child the user has emptied out: a layout block + * (a column, a cell) holding nothing but one empty paragraph, or one holding + * nothing at all that its content expression requires children. + * @internal + */ +export function isEmptyLayoutChild(node: Node): boolean { + if (node.type.name === "blockContainer") { + const content = node.firstChild; + return ( + node.childCount === 1 && + !!content && + content.type.name === "paragraph" && + content.childCount === 0 + ); + } + if (isLayoutNode(node.type)) { + if (node.childCount === 1) { + return isEmptyLayoutChild(node.firstChild!); + } + // A layout block left with no children at all: its last child was removed + // and no fill happened on the way. A `min: 0` layout block in this state is + // valid and stays; any other is broken structure. + if (node.childCount === 0) { + return minChildren(node.type.spec.blockConfig?.children) >= 1; + } + return false; + } + return false; +} + +/** + * Repairs the layout block at `layoutBlockPos` after children were (re)moved from + * it: drops the ones the user emptied, and dissolves the layout block when too + * few are left for it to mean anything (a column list with one column is just + * that column's blocks). + * + * A layout block that only exists inside another layout block (a column) is left to + * its parent, which is the thing that decides whether it still belongs. + * + * @param layoutBlockPos The position just before the layout block node. + * @internal + */ +export function fixLayoutBlock(tr: Transaction, layoutBlockPos: number) { + const layoutBlock = tr.doc.resolve(layoutBlockPos).nodeAfter; + if (!layoutBlock || !isLayoutNode(layoutBlock.type)) { + throw new Error( + "Invalid layoutBlockPos: does not point to a layout block node.", + ); + } + + // A `namedOnly` layout block (a column) keeps existing: it is its parent's + // decision whether the layout block still belongs, and ProseMirror pads it + // back up to its minimum when children are removed. + if (isNamedOnly(layoutBlock.type)) { + return; + } + + // A layout block that does not dissolve keeps its empty children, and stays + // when too few are left: ProseMirror fills it back up to its minimum with + // empty children (`children.dissolve`). + const children = layoutBlock.type.spec.blockConfig?.children; + if (children && "dissolve" in children && children.dissolve === false) { + return; + } + + const min = minChildren(layoutBlock.type.spec.blockConfig?.children); + const survivors: { node: Node; offset: number }[] = []; + const emptied: { from: number; to: number }[] = []; + layoutBlock.forEach((child, offset) => { + if (!isEmptyLayoutChild(child)) { + survivors.push({ node: child, offset }); + } else if (isLayoutNode(child.type)) { + const from = layoutBlockPos + 1 + offset; + emptied.push({ from, to: from + child.nodeSize }); + } + }); + + if (survivors.length >= min) { + // Keep intentional empty paragraphs. Only remove empty structural children, + // back to front so positions (and selections in surviving children) stay valid. + for (const { from, to } of emptied.reverse()) { + tr.delete(from, to); + } + return; + } + + // Too few children left for the layout block to mean anything, so it is + // replaced by its surviving children. + + // A single survivor supplying the whole replacement is an unwrap, and doing + // it as a `ReplaceAroundStep` leaves that content in place in the document: + // positions inside it - a caret, a collaborator's concurrent edit - map + // through it, where deleting and reinserting the same content would collapse + // them onto the edge of the replaced range. + if (survivors.length === 1) { + const [{ node: survivor, offset }] = survivors; + const survivorPos = layoutBlockPos + 1 + offset; + // A `namedOnly` survivor is lifted out of two wrappers at once - the column + // and the layout block - and any other survivor out of just the layout block. + const gap = isNamedOnly(survivor.type) + ? { from: survivorPos + 1, to: survivorPos + survivor.nodeSize - 1 } + : { from: survivorPos, to: survivorPos + survivor.nodeSize }; + + tr.step( + new ReplaceAroundStep( + layoutBlockPos, + layoutBlockPos + layoutBlock.nodeSize, + gap.from, + gap.to, + Slice.empty, + 0, + // Not a pure unwrap: emptied siblings sit outside the gap and go with + // the layout block, which the `structure` check would refuse. + false, + ), + ); + return; + } + + // Several survivors are not one contiguous range once an emptied child sits + // between them, so this path rebuilds them and positions inside them + // collapse. It takes a layout block with a `min` of 3 or more to reach. + const replacement: Node[] = []; + for (const { node: survivor } of survivors) { + if (isNamedOnly(survivor.type)) { + // The survivor can't stand on its own either (a column only exists + // inside a column list), so what it holds is what's left. + survivor.forEach((grandChild) => replacement.push(grandChild)); + } else { + replacement.push(survivor); + } + } + + tr.replaceWith( + layoutBlockPos, + layoutBlockPos + layoutBlock.nodeSize, + Fragment.from(replacement), + ); +} + +/** + * Runs {@link fixLayoutBlock} on each of the given layout blocks, looked up by ID + * in `tr`'s current doc. Layout blocks are repaired deepest-first so that an + * inner repair (e.g. a column emptying out) is observed by the outer + * layout block's repair (e.g. its columnList unwrapping) in the same pass. + * Layout blocks that no longer exist by the time their turn comes are skipped — + * an earlier repair may have removed them. + */ +export function fixLayoutBlocksById( + tr: Transaction, + layoutBlocks: { id: string; depth: number }[], +) { + [...layoutBlocks] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (target && isLayoutNode(target.node.type)) { + fixLayoutBlock(tr, target.posBeforeNode); + } + }); +} diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.browser.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.browser.test.ts new file mode 100644 index 0000000000..0188dc0f6d --- /dev/null +++ b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.browser.test.ts @@ -0,0 +1,293 @@ +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 { layoutSchema } from "./layoutBlocks.fixture.js"; + +// Keymap tests for layout blocks, split off from the node-environment +// `layoutBlocks.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 = layoutSchema; + +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", () => { + // The previous sibling's children end in a layout block, which Tab used to + // throw on ("Cannot join blockContainer onto callout"). The block now nests + // after the layout block. + it("Tab after a layout block at the end of the previous sibling's children nests after it", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "p-0", + type: "paragraph", + content: "Paragraph 0", + children: [ + { + id: "c-0", + type: "callout", + children: [{ type: "paragraph", content: "In callout" }], + }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Tab", { block: "after", placement: "start" }); + + expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + "c-0", + "after", + ]); + expect(editor.getTextCursorPosition().block.id).toBe("after"); + }); + + it("Enter on an empty last child escapes the layout block", 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-layout-block stays inside", async () => { + // The escape only fires at the end of the layout block. 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 layout block'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 layout block 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 layout block 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 layout block'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"]); + }); +}); + +// HTML round-trips (full, external, clipboard) live with the parse rules in +// `schema/blocks/createSpec.browser.test.ts`. +describe("children conversion", () => { + it("flattens layout blocks 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/layoutBlocks/layoutBlocks.fixture.ts b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.fixture.ts new file mode 100644 index 0000000000..e9270b6c0e --- /dev/null +++ b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.fixture.ts @@ -0,0 +1,145 @@ +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 frameDiv = () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; +}; + +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + layoutOnly: true, + }, + { renderFrame: frameDiv }, +)(); + +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + layoutOnly: true, + children: { + allow: ["gridCell"], + min: 2, + }, + }, + { renderFrame: frameDiv }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + layoutOnly: true, + placeable: "namedOnly", + }, + { renderFrame: frameDiv }, +)(); + +// A second layout block of any blocks, for conversions between layout block types. +const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { renderFrame: frameDiv }, +)(); + +// A layout block that requires two callouts, so dropping below `min` dissolves +// it into the survivor. (`min` needs an `allow` list: a layout block of any +// blocks always holds at least one.) +const Pair = createBlockSpec( + { + type: "pair" as const, + propSchema: {}, + content: "none", + layoutOnly: true, + children: { allow: ["callout"], min: 2 }, + }, + { renderFrame: frameDiv }, +)(); + +// A tab set: its tabs are content, not layout, so it does not dissolve. An +// empty tab stays, and the set stays when too few tabs are left. +const Tabs = createBlockSpec( + { + type: "tabs" as const, + propSchema: {}, + content: "none", + layoutOnly: true, + children: { allow: ["tab"], min: 1, dissolve: false }, + }, + { renderFrame: frameDiv }, +)(); + +const Tab = createBlockSpec( + { + type: "tab" as const, + propSchema: {}, + content: "none", + layoutOnly: true, + placeable: "namedOnly", + }, + { renderFrame: frameDiv }, +)(); + +// A titled block: an ordinary block with inline content (the title) whose +// child blocks are a body that belongs to it. Its `experimental_keyboard` settings keep +// the body together: Enter in the title starts it, its blocks can't be +// outdented, and an empty last block leaves it. The frame draws the box +// around title and body together. +const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + }, + { + experimental_keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, + render: renderDiv, + renderFrame: () => { + const dom = document.createElement("div"); + dom.className = "alert-frame"; + const slot = document.createElement("div"); + slot.className = "alert-slot"; + dom.append(slot); + return { dom, slot }; + }, + }, +)(); + +export const layoutSchema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + alert: Alert, + grid: Grid, + gridCell: GridCell, + pair: Pair, + box: Box, + tabs: Tabs, + tab: Tab, + } as const, +}); diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.test.ts new file mode 100644 index 0000000000..256fccc97f --- /dev/null +++ b/packages/core/src/api/blockManipulation/layoutBlocks/layoutBlocks.test.ts @@ -0,0 +1,783 @@ +// @vitest-environment node +import { TextSelection } from "prosemirror-state"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; +import { layoutSchema } from "./layoutBlocks.fixture.js"; + +type PartialBlock = (typeof layoutSchema)["PartialBlock"]; + +// Document-model behaviour of layout blocks: filling, 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 +// `layoutBlocks.browser.test.ts`. + +const schema = layoutSchema; + +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 & filling", () => { + it.each([ + { block: { type: "callout" }, childType: "paragraph", min: 1 }, + { + block: { type: "callout", children: [] }, + childType: "paragraph", + min: 1, + }, + { block: { type: "pair" }, childType: "callout", min: 2 }, + { block: { type: "grid" }, childType: "gridCell", min: 2 }, + ] satisfies { block: PartialBlock; childType: string; min: number }[])( + "fills $block to its minimum with identifiable $childType children", + ({ block, childType, min }) => { + editor.insertBlocks([{ ...block, id: "layoutBlock" }], "p-1", "after"); + const children = editor.getBlock("layoutBlock")!.children; + expect(children.map((child) => child.type)).toEqual( + Array(min).fill(childType), + ); + for (const child of children) { + expect(child.id).toBeTruthy(); + expect(editor.getBlock(child.id)).toBeDefined(); + } + }, + ); + + it("does not re-fill a layout block 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 fill on top of it. + editor.replaceBlocks([inserted], [inserted]); + + expect(editor.getBlock("c-0")!.children).toHaveLength( + inserted.children.length, + ); + }); + + it("accepts arbitrary block children, including nested layout blocks", () => { + 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 layout block'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: "blocks"` wildcard compiles to the regular blocks plus the + // layout blocks placeable anywhere, so a namedOnly block only fits where a + // parent names it explicitly: not at the root, and not under a wildcard + // layout block. + it("rejects a namedOnly 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("layout block keyboard defaults", () => { + // A layout block's children can't be outdented, so its defaults say so: an + // empty last child leaves the layout block instead. + it("defaults to leaving the layout block instead of outdenting", () => { + editor.replaceBlocks(editor.document, [ + { id: "callout", type: "callout", children: [{ type: "paragraph" }] }, + ]); + const keyboard = editor.schema.blockSpecs.callout.implementation.keyboard( + editor.getBlock("callout")!, + ); + expect(keyboard).toMatchObject({ + emptyChildEnter: "exit-at-end", + childrenCanOutdent: false, + }); + }); + + it("keeps outdenting for other blocks", () => { + const keyboard = editor.schema.blockSpecs.paragraph.implementation.keyboard( + editor.getBlock("p-0")!, + ); + expect(keyboard).toMatchObject({ + emptyChildEnter: "outdent", + childrenCanOutdent: true, + }); + }); +}); + +describe("layout block nodes", () => { + // No layout block is `isolating`. PM only honours that flag while no selection + // spans the edge, and nothing prevents one: given a spanning slice, `Fitter` + // refuses to open into the layout block and wraps the content in a spurious + // `blockGroup`, corrupting the document. + it("leaves every layout block non-isolating", () => { + const nodes = editor.pmSchema.nodes; + for (const type of ["callout", "pair", "grid", "gridCell"]) { + expect(nodes[type].spec.isolating).toBeFalsy(); + } + }); + + // The corruption the line above avoids, pinned end to end: copy a selection + // running from inside a layout block to after it, paste it back over itself, + // and the document must come back unchanged. Marking the layout block + // `isolating` instead re-nests the whole fragment a level too deep. + it.each(["callout"])("round-trips a paste across a %s's edge", (type) => { + editor.replaceBlocks(editor.document, [ + { + id: "c", + type, + children: [ + { id: "c1", type: "paragraph", content: "Inner one" }, + { id: "c2", type: "paragraph", content: "Inner two" }, + ], + }, + { id: "a", type: "paragraph", content: "After" }, + ] as PartialBlock[]); + + const before = JSON.stringify(editor.document); + + editor.transact((tr) => { + let from = 0; + let to = 0; + tr.doc.descendants((node, pos) => { + if (node.isText && node.text === "Inner two") { + from = pos; + } + if (node.isText && node.text === "After") { + to = pos + node.nodeSize; + } + }); + + const selection = TextSelection.create(tr.doc, from, to); + tr.setSelection(selection).replace(from, to, selection.content()); + }); + + expect(JSON.stringify(editor.document)).toBe(before); + }); +}); + +// `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 layout block 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("fills a layout block below `min` and rejects one it can never fill", () => { + // One cell under `min: 2` is padded up to it. + const loaded = createWith([ + { type: "grid", id: "g-0", children: [{ type: "gridCell" }] }, + ] as any); + expect(loaded.getBlock("g-0")!.children).toHaveLength(2); + loaded._tiptapEditor.destroy(); + + // A grid given paragraphs can never be filled: no amount of padding + // turns them into cells. + expect(() => + createWith([ + { + type: "grid", + id: "g-0", + children: [{ type: "paragraph" }, { type: "paragraph" }], + }, + ] as any), + ).toThrow(); + }); +}); + +describe("children repair", () => { + it("dissolves a layout block that can stand anywhere when it drops below `min`", () => { + 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"]); + + // A callout the user emptied gets out of the way: it is replaced by what + // its children held, which is nothing. + expect(editor.getBlock("c-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual(["trailing"]); + }); + + it("unwraps a layout block whose single survivor cannot stand alone", () => { + 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", + ]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + // Dissolving is an unwrap, not a rewrite: the survivor's content is the same + // content, in place, so a caret sitting in it stays where the user put it + // (and a collaborator's concurrent edit still maps onto it). Replacing the + // layout block with freshly built copies of its children would collapse the + // selection onto the edge of the replaced range instead. + it("keeps the caret in the survivor a layout block unwraps to", () => { + editor.replaceBlocks(editor.document, [ + { + type: "pair", + id: "pair-0", + children: [ + { + id: "pair-a", + type: "callout", + children: [{ id: "pair-a-p", type: "paragraph", content: "A" }], + }, + { + id: "pair-b", + type: "callout", + children: [ + { id: "pair-b-p", type: "paragraph", content: "Survivor" }, + ], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.setTextCursorPosition("pair-b-p", "end"); + const offsetBefore = editor.prosemirrorState.selection.$from.parentOffset; + + editor.removeBlocks(["pair-a"]); + + expect(editor.getBlock("pair-0")).toBeUndefined(); + expect(editor.getBlock("pair-b")?.type).toBe("callout"); + expect(editor.getTextCursorPosition().block.id).toBe("pair-b-p"); + expect(editor.prosemirrorState.selection.$from.parentOffset).toBe( + offsetBefore, + ); + }); + + // The same, for a survivor that is lifted out of two wrappers at once. + it("keeps the caret in a named-only survivor's content", () => { + 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.setTextCursorPosition("cell-b-p", "end"); + const offsetBefore = editor.prosemirrorState.selection.$from.parentOffset; + + editor.removeBlocks(["cell-a-p"]); + + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.getTextCursorPosition().block.id).toBe("cell-b-p"); + expect(editor.prosemirrorState.selection.$from.parentOffset).toBe( + offsetBefore, + ); + }); + + // An emptied child of the layout block is dropped even when the layout block + // stays at or above `min`: an emptied column disappears rather than + // lingering. The multicolumn e2e snapshots pin the same behavior from the + // keyboard side. + it("drops emptied layout block children even at or above `min`", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "cell-a", + children: [ + { id: "cell-a-p", type: "paragraph", content: "A" }, + { id: "cell-a-extra", type: "paragraph", content: "A2" }, + ], + }, + { + type: "gridCell", + id: "cell-b", + children: [{ id: "cell-b-p", type: "paragraph", content: "B" }], + }, + { + type: "gridCell", + id: "cell-c", + children: [{ id: "cell-c-p", type: "paragraph", content: "" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // Removing a block inside cell A runs repair on the grid; the emptied + // cell C is dropped, and with cells A and B still meeting `min: 2` the + // grid itself survives. + editor.removeBlocks(["cell-a-extra"]); + + const grid = editor.getBlock("g-0")!; + expect(grid.children.map((cell) => cell.id)).toEqual(["cell-a", "cell-b"]); + }); +}); + +describe("layout blocks that do not dissolve", () => { + // `tabs` sets `dissolve: false`: an empty tab is still a tab. + const tab = (id: string, text: string) => ({ + type: "tab" as const, + id, + children: [{ id: `${id}-p`, type: "paragraph" as const, content: text }], + }); + + it("keeps an emptied tab when another tab changes", () => { + editor.replaceBlocks(editor.document, [ + { + type: "tabs", + id: "tabs", + children: [ + { + type: "tab", + id: "a", + children: [ + { id: "a-p", type: "paragraph", content: "A" }, + { id: "a-extra", type: "paragraph", content: "A2" }, + ], + }, + tab("b", ""), + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["a-extra"]); + + expect(editor.getBlock("tabs")!.children.map((t) => t.id)).toEqual([ + "a", + "b", + ]); + }); + + it("keeps the tab set when the last tab with text is removed", () => { + editor.replaceBlocks(editor.document, [ + { type: "tabs", id: "tabs", children: [tab("a", "A"), tab("b", "")] }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["a"]); + + expect(editor.getBlock("tabs")!.children.map((t) => t.id)).toEqual(["b"]); + }); + + it("fills the tab set with an empty tab when its only tab is removed", () => { + editor.replaceBlocks(editor.document, [ + { type: "tabs", id: "tabs", children: [tab("a", "A")] }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["a"]); + + const tabs = editor.getBlock("tabs")!; + expect(tabs.children).toHaveLength(1); + expect(tabs.children[0].type).toBe("tab"); + expect(tabs.children[0].id).not.toBe("a"); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); +}); + +describe("repair edge cases", () => { + // A layout block that explicitly allows being empty. `min: 0` compiles to a + // `*` content expression, so ProseMirror never pads it and repair leaves + // it alone. (Only an `allow` list can set it: a layout block of any blocks + // always holds at least one.) + const Tray = createBlockSpec( + { + type: "tray" as const, + propSchema: {}, + content: "none" as const, + layoutOnly: true, + children: { allow: ["callout"], min: 0 }, + }, + { + renderFrame: () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + }, + )(); + + it("keeps a `min: 0` layout block with zero children", () => { + const trayEditor = BlockNoteEditor.create({ + schema: layoutSchema.extend({ + blockSpecs: { tray: Tray }, + }), + }); + try { + trayEditor.replaceBlocks(trayEditor.document, [ + { + type: "tray", + id: "t-0", + children: [ + { + id: "t-c-0", + type: "callout", + children: [{ type: "paragraph", content: "" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // Removing its only child leaves zero children, which `min: 0` allows: + // the tray stays instead of dissolving, and nothing is padded back. + trayEditor.removeBlocks(["t-c-0"]); + + expect(trayEditor.getBlock("t-0")).toBeDefined(); + expect(trayEditor.getBlock("t-0")!.children).toHaveLength(0); + expect(() => trayEditor.prosemirrorState.doc.check()).not.toThrow(); + } finally { + trayEditor._tiptapEditor.destroy(); + } + }); + + it("keeps emptied regular blocks when the layout block survives", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-a", type: "paragraph", content: "A" }, + { id: "c-b", type: "paragraph", content: "B" }, + { id: "c-empty", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["c-a"]); + + // Only emptied *layout block* children are dropped. The empty paragraph is + // content the user typed into, not structure, so it stays. + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-b", + "c-empty", + ]); + }); + + it("dissolves a `min: 2` layout block left with one surviving child", () => { + editor.replaceBlocks(editor.document, [ + { + type: "pair", + id: "s-0", + children: [ + { + id: "s-a", + type: "callout", + children: [{ type: "paragraph", content: "A" }], + }, + { + id: "s-b", + type: "callout", + children: [{ type: "paragraph", content: "B" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["s-b"]); + + // One survivor is below `min: 2`, so the pair dissolves into it. The + // padded empty ProseMirror filled back does not count as a survivor. + expect(editor.getBlock("s-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "s-a", + "trailing", + ]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("leaves a titled block intact when its last body block is removed", () => { + editor.replaceBlocks(editor.document, [ + { + type: "alert", + id: "w", + content: "Title", + children: [{ id: "b1", type: "paragraph", content: "" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["b1"]); + + // Titled blocks never enter `fixLayoutBlock`: the alert keeps its title + // with an empty body rather than dissolving. + const alert = editor.getBlock("w")! as any; + expect(alert.type).toBe("alert"); + expect(alert.content[0].text).toBe("Title"); + expect(alert.children).toHaveLength(0); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); +}); + +describe("children selection", () => { + it("getSelectionCutBlocks handles selections reaching into a layout block", () => { + 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 + // layout block (breaking comments/AI selection handling). + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.length).toBeGreaterThanOrEqual(1); + expect(result.blocks.map((block) => block.id)).toContain("before"); + }); +}); + +// Every mutation that can empty a layout block records its ancestors with +// `getAncestorLayoutBlocks` and hands them to `fixLayoutBlocksById`, which repairs +// them deepest-first. Removing a block therefore repairs the whole chain it +// sat in, not just the layout block directly holding it. +describe("ancestor layout block repair", () => { + it("repairs every layout block a single removal emptied", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }], + }, + { + type: "pair", + id: "s-0", + children: [ + { + id: "s-p-0", + type: "callout", + children: [{ type: "paragraph", content: "Kept" }], + }, + { + id: "s-p-1", + type: "callout", + children: [{ type: "paragraph", content: "Removed" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // Blocks from two different layout blocks in one call: each layout block is + // recorded once and repaired on its own terms. + editor.removeBlocks(["c-p-0", "s-p-1"]); + + // Both layout blocks dissolve: the callout held nothing else, and the pair + // is replaced by the one child that still carried content. + expect(editor.getBlock("c-0")).toBeUndefined(); + expect(editor.getBlock("s-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "s-p-0", + "trailing", + ]); + }); + + it("cascades a repair outwards from the deepest layout block", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-outer", + children: [ + { + type: "gridCell", + id: "outer-cell-a", + children: [ + { + type: "grid", + id: "g-inner", + children: [ + { + type: "gridCell", + id: "inner-cell-a", + children: [ + { id: "inner-p", type: "paragraph", content: "X" }, + ], + }, + { + type: "gridCell", + id: "inner-cell-b", + children: [{ type: "paragraph", content: "" }], + }, + ], + }, + ], + }, + { + type: "gridCell", + id: "outer-cell-b", + children: [{ type: "paragraph", content: "" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // The only real content, three layout blocks deep. Removing it empties the + // inner cell, and the emptiness has to travel all the way out: the inner + // grid loses both its cells, the outer cell loses the inner grid, and the + // outer grid loses both of its cells. Recording only the innermost + // layout block would leave a stack of empty grids behind. + editor.removeBlocks(["inner-p"]); + + expect(editor.getBlock("g-inner")).toBeUndefined(); + expect(editor.getBlock("g-outer")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual(["trailing"]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); +}); diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/plainBlocks.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/plainBlocks.test.ts new file mode 100644 index 0000000000..e326885c07 --- /dev/null +++ b/packages/core/src/api/blockManipulation/layoutBlocks/plainBlocks.test.ts @@ -0,0 +1,180 @@ +import { TextSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; +import { layoutSchema } from "./layoutBlocks.fixture.js"; + +const plainNote = createBlockSpec( + { + type: "plainNote", + propSchema: {}, + content: "plain", + }, + { + // A titled block: Enter in its text starts its body, and the body's + // blocks can't be outdented out of it. + experimental_keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, + render() { + const dom = document.createElement("pre"); + return { dom, contentDOM: dom }; + }, + renderFrame() { + const dom = document.createElement("section"); + dom.className = "plain-frame"; + const slot = document.createElement("div"); + dom.append(slot); + return { dom, slot }; + }, + }, +)(); +const schema = layoutSchema.extend({ blockSpecs: { plainNote } }); +const editors = new Set>(); + +function editorWith( + content = "Source", + children: (typeof schema.PartialBlock)[] = [ + { id: "body", type: "paragraph", content: "Explanation" }, + ], +) { + const editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { id: "note", type: "plainNote", content, children }, + { id: "after", type: "paragraph", content: "After" }, + ], + }); + editors.add(editor); + editor.mount(document.createElement("div")); + return editor; +} + +afterEach(() => { + for (const editor of editors) { + editor._tiptapEditor.destroy(); + } + editors.clear(); +}); + +function press( + editor: ReturnType, + key: "Enter" | "Backspace" | "Delete" | "Tab", + shiftKey = false, +) { + const view = editor.prosemirrorView; + return view.someProp("handleKeyDown", (handler) => + handler( + view, + new KeyboardEvent("keydown", { + key, + code: key, + keyCode: { Enter: 13, Backspace: 8, Delete: 46, Tab: 9 }[key], + shiftKey, + }), + ), + ); +} +function text(editor: ReturnType, id: string) { + const block = editor.getBlock(id)!; + if (block.type !== "plainNote" && block.type !== "paragraph") { + throw new Error("Expected a text block"); + } + return block.content + .map((part) => (part.type === "text" ? part.text : "")) + .join(""); +} + +describe("plain blocks whose Enter goes into their children", () => { + it.each(["", "Source"])( + "Enter starts the body and preserves children for %j", + (content) => { + const editor = editorWith(content); + editor.setTextCursorPosition("note", "end"); + press(editor, "Enter"); + expect(editor.document.map((block) => block.id)).toEqual([ + "note", + "after", + ]); + expect( + editor.getBlock("note")!.children.map((block) => block.id), + ).toEqual([expect.any(String), "body"]); + expect(text(editor, "body")).toBe("Explanation"); + expect(editor.getTextCursorPosition().block.id).toBe( + editor.getBlock("note")!.children[0].id, + ); + editor.prosemirrorState.doc.check(); + }, + ); + + it("Enter moves the remaining plain text into the body", () => { + const editor = editorWith("Source"); + editor.setTextCursorPosition("note", "start"); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, tr.selection.from + 3)), + ); + press(editor, "Enter"); + expect(text(editor, "note")).toBe("Sou"); + expect(text(editor, editor.getBlock("note")!.children[0].id)).toBe("rce"); + expect(text(editor, "body")).toBe("Explanation"); + }); + + it("Shift-Enter inserts a newline without moving children", () => { + const editor = editorWith(); + editor.setTextCursorPosition("note", "end"); + press(editor, "Enter", true); + expect(text(editor, "note")).toBe("Source\n"); + expect(editor.getBlock("note")!.children.map((block) => block.id)).toEqual([ + "body", + ]); + editor.prosemirrorState.doc.check(); + }); + + it("Backspace in the block after merges it into the body's last block", () => { + const editor = editorWith(); + editor.setTextCursorPosition("after", "start"); + press(editor, "Backspace"); + + // As after any block with children: the text is appended to the last + // block above it. + expect(text(editor, "body")).toBe("ExplanationAfter"); + expect(editor.document.map((block) => block.id)).toEqual(["note"]); + }); + + it("keeps its children inside when Shift-Tab is pressed", () => { + const editor = editorWith(); + editor.setTextCursorPosition("body", "start"); + press(editor, "Tab", true); + expect(editor.getBlock("note")!.children.map((block) => block.id)).toEqual([ + "body", + ]); + }); + + it("renders and round-trips multiline content with its children", () => { + const editor = editorWith("First\nSecond"); + expect(editor.domElement?.querySelector(".plain-frame")?.textContent).toBe( + "First\nSecondExplanation", + ); + const parsed = editor.tryParseHTMLToBlocks( + editor.blocksToFullHTML(editor.document), + ); + expect(parsed).toEqual(editor.document); + expect(editor.blocksToHTMLLossy(editor.document)).toContain("Explanation"); + }); + + it("preserves text and children when converting between plain, inline, and layout blocks", () => { + const editor = editorWith("First\nSecond"); + editor.updateBlock("note", { type: "alert" }); + expect(editor.getBlock("note")!.children[0].id).toBe("body"); + editor.updateBlock("note", { type: "plainNote" }); + expect(text(editor, "note")).toBe("First\nSecond"); + editor.updateBlock("note", { type: "callout" }); + const layoutBlock = editor.document[0]; + expect(text(editor, layoutBlock.children[0].id)).toBe("First\nSecond"); + expect(layoutBlock.children[1].id).toBe("body"); + editor.prosemirrorState.doc.check(); + }); +}); diff --git a/packages/core/src/api/blockManipulation/layoutBlocks/titledBlocks.test.ts b/packages/core/src/api/blockManipulation/layoutBlocks/titledBlocks.test.ts new file mode 100644 index 0000000000..8b5f6937e8 --- /dev/null +++ b/packages/core/src/api/blockManipulation/layoutBlocks/titledBlocks.test.ts @@ -0,0 +1,365 @@ +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { isLayoutNode } from "../../../schema/blocks/children.js"; +import { getBlockInfoAt } from "../../getBlockInfoFromPos.js"; +import { getNodeById } from "../../nodeUtil.js"; +import { layoutSchema } from "./layoutBlocks.fixture.js"; + +// Behaviour of titled blocks: an ordinary block with inline content +// (the title) whose `children` are a body that belongs to it. The `alert` +// fixture block is the specimen; `callout` (a layout block) is the control. + +const schema = layoutSchema; +const editors = new Set>(); + +afterEach(() => { + for (const editor of editors) { + editor._tiptapEditor.destroy(); + } + editors.clear(); +}); + +function editorWith(initialContent: any[]) { + const editor = BlockNoteEditor.create({ schema, initialContent } as any); + editors.add(editor); + editor.mount(document.createElement("div")); + return editor; +} + +function press(editor: any, key: string, mods: string[] = []) { + const view = editor._tiptapEditor.view; + const codes: Record = { + Enter: 13, + Backspace: 8, + Tab: 9, + Delete: 46, + }; + const event = new KeyboardEvent("keydown", { + key, + code: key, + keyCode: codes[key], + bubbles: true, + shiftKey: mods.includes("Shift"), + } as any); + return !!view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + +function shape(blocks: any[]): string { + return blocks + .map((block) => { + const text = Array.isArray(block.content) + ? block.content.map((c: any) => c.text ?? "").join("") + : ""; + const children = block.children?.length + ? `[${shape(block.children)}]` + : ""; + return `${block.type}"${text}"${children}`; + }) + .join(", "); +} + +const before = { id: "pre", type: "paragraph" as const, content: "Before" }; +const after = { id: "post", type: "paragraph" as const, content: "After" }; +const body = [ + { id: "b1", type: "paragraph" as const, content: "One" }, + { id: "b2", type: "paragraph" as const, content: "Two" }, +]; + +const withAlert = (children: any[] = body) => [ + before, + { id: "w", type: "alert" as const, content: "Title", children }, + after, +]; + +describe("titled-block schema shape", () => { + it("is an ordinary block whose keyboard settings keep its body together", () => { + const editor = editorWith(withAlert()); + + function keyboardOf(id: string) { + const { blockSpecs } = editor.schema as BlockNoteSchema; + const block = editor.getBlock(id)!; + return blockSpecs[block.type].implementation.keyboard(block); + } + + editor.transact((tr) => { + const alert = getNodeById("w", tr.doc)!; + // An ordinary blockContainer: its node holds content, not children. + expect(alert.node.type.name).toBe("blockContainer"); + expect(isLayoutNode(alert.node.type)).toBe(false); + expect(keyboardOf("w")).toMatchObject({ + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }); + expect(alert.node.attrs.id).toBe("w"); + expect(alert.node.firstChild!.attrs).not.toHaveProperty("id"); + + expect(keyboardOf("pre")).toMatchObject({ + enter: "split", + childrenCanOutdent: true, + emptyChildEnter: "outdent", + }); + + // The body is the blockGroup the alert nests, resolved with positions. + const info = getBlockInfoAt(tr.doc, alert.posBeforeNode); + expect(info.children?.node.type.name).toBe("blockGroup"); + expect(info.children?.beforePos).toBe(info.content?.afterPos); + }); + }); + + it("frames the title and the body together in the live DOM", () => { + const editor = editorWith(withAlert()); + + const frame = editor.domElement!.querySelector(".alert-frame"); + expect(frame).not.toBeNull(); + const slot = frame!.querySelector(".alert-slot"); + expect(slot).not.toBeNull(); + // The title's text and both body blocks render inside the slot. + expect(slot!.textContent).toContain("Title"); + expect(slot!.textContent).toContain("One"); + expect(slot!.textContent).toContain("Two"); + }); +}); + +describe("a titled block's keyboard behaviour", () => { + it.each([{ children: [] }, { children: body }])( + "Enter in an empty title preserves its body (%j)", + ({ children }) => { + const editor = editorWith([ + before, + { id: "w", type: "alert", content: "", children }, + after, + ]); + editor.setTextCursorPosition("w", "start"); + expect(press(editor, "Enter")).toBe(true); + expect(editor.document.map((block) => block.id)).toEqual([ + "pre", + "w", + "post", + ]); + expect(shape(editor.document[1].children)).toBe( + [ + 'paragraph""', + ...children.map((block) => `paragraph"${block.content}"`), + ].join(", "), + ); + expect( + editor.document[1].children.slice(1).map((block) => block.id), + ).toEqual(children.map((block) => block.id)); + expect(editor.getTextCursorPosition().block.id).toBe( + editor.document[1].children[0].id, + ); + }, + ); + + it("Enter at the end of the title starts the body, keeping it", () => { + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("w", "end"); + press(editor, "Enter"); + + // The new block belongs to the alert, and the body is still the + // alert's — not carried off by a new sibling. + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Title"[paragraph"", paragraph"One", paragraph"Two"], paragraph"After"', + ); + }); + + it("Enter in the middle of the title keeps the body on the alert", () => { + // The bug behind the toggle-block reports: splitting a block handed its + // children to the new block, so a callout's body ended up under whatever + // the split created. + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("w", "start"); + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, tr.selection.from + 2)), + ); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Ti"[paragraph"tle", paragraph"One", paragraph"Two"], paragraph"After"', + ); + }); + + it("Enter in an empty last body block leaves the alert", () => { + const editor = editorWith( + withAlert([body[0], { id: "b2", type: "paragraph", content: "" }]), + ); + editor.setTextCursorPosition("b2", "start"); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Title"[paragraph"One"], paragraph"", paragraph"After"', + ); + }); + + it("Enter in an empty body block that is the only one stays put", () => { + // Nothing to escape from yet: the block is where a new alert's body + // starts, and leaving would dissolve the alert the user just made. + const editor = editorWith( + withAlert([{ id: "b1", type: "paragraph", content: "" }]), + ); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Title"[paragraph"", paragraph""], paragraph"After"', + ); + }); + + it("Backspace at the start of the first body block merges into the title", () => { + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Backspace"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"TitleOne"[paragraph"Two"], paragraph"After"', + ); + }); + + it("Shift-Tab in the body does not escape the alert", () => { + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Tab", ["Shift"]); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Title"[paragraph"One", paragraph"Two"], paragraph"After"', + ); + }); + + it("Backspace in the block after merges it into the body's last block", () => { + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("post", "start"); + press(editor, "Backspace"); + + // As after any block with children: the text is appended to the last + // block above it. + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Title"[paragraph"One", paragraph"TwoAfter"]', + ); + }); + + it("Tab still nests inside the body", () => { + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("b2", "start"); + press(editor, "Tab"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Title"[paragraph"One"[paragraph"Two"]], paragraph"After"', + ); + }); + + it("Delete at the end of the title merges the first body block into it", () => { + // The mirror of Backspace at the start of the first body block: the + // body's first block is consumed and its text joins the title. + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("w", "end"); + press(editor, "Delete"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"TitleOne"[paragraph"Two"], paragraph"After"', + ); + }); + + it("Enter with a non-collapsed selection in the title takes the generic split path", () => { + // The titled block's Enter handler only fires for a collapsed selection, + // so a range selection falls through to the generic split: the selected + // text is deleted and the title splits, keeping the body on the alert. + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("w", "start"); + editor.transact((tr) => + tr.setSelection( + TextSelection.create( + tr.doc, + tr.selection.from + 1, + tr.selection.from + 3, + ), + ), + ); + press(editor, "Enter"); + + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"T"[paragraph"One", paragraph"Two"], paragraph"le", paragraph"After"', + ); + }); +}); + +describe("converting between a titled block and a layout block", () => { + it("carries the title into the body when an alert becomes a callout", () => { + const editor = editorWith(withAlert()); + editor.updateBlock("w" as any, { type: "callout" } as any); + + // A layout block holds no content of its own, so the title moves into the + // body as its first paragraph; the existing children stay after it, in + // order. + expect(shape(editor.document)).toBe( + 'paragraph"Before", callout""[paragraph"Title", paragraph"One", paragraph"Two"], paragraph"After"', + ); + }); + + it("invents an empty title when a callout becomes an alert", () => { + const editor = editorWith([ + before, + { + id: "w", + type: "callout" as const, + children: [ + { id: "b1", type: "paragraph" as const, content: "One" }, + { id: "b2", type: "paragraph" as const, content: "Two" }, + ], + }, + after, + ]); + editor.updateBlock("w" as any, { type: "alert" } as any); + + // The layout block had no title to carry, so the alert starts empty; its + // children move across untouched. + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert""[paragraph"One", paragraph"Two"], paragraph"After"', + ); + }); +}); + +describe("blocks that declare no children are untouched", () => { + it("keeps ordinary nesting behaviour for a nested paragraph", () => { + const editor = editorWith([ + before, + { id: "w", type: "paragraph", content: "Title", children: body }, + after, + ]); + editor.setTextCursorPosition("b1", "start"); + press(editor, "Tab", ["Shift"]); + + // Shift-Tab lifts it out, as it always has. + expect(shape(editor.document)).toBe( + 'paragraph"Before", paragraph"Title", paragraph"One"[paragraph"Two"], paragraph"After"', + ); + }); + + it("survives the clipboard round-trip that copy and drag use", () => { + // Dragging a block inside the editor re-parses it from the HTML + // ProseMirror serializes the dragged slice to, so a titled block whose + // parse rules don't match that HTML comes back as a paragraph. + const editor = editorWith(withAlert()); + const view = editor._tiptapEditor.view; + + editor.transact((tr) => { + let pos = -1; + tr.doc.descendants((node: any, at: number) => { + if (pos < 0 && node.attrs?.id === "w") { + pos = at; + } + return pos < 0; + }); + tr.setSelection(NodeSelection.create(tr.doc, pos)); + }); + const html = view.serializeForClipboard(view.state.selection.content()).dom + .innerHTML; + + expect(shape(editor.tryParseHTMLToBlocks(html))).toBe( + 'alert"Title"[paragraph"One", paragraph"Two"]', + ); + }); +}); diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index 34591c8c8d..ff60114b79 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -7,6 +7,7 @@ import { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; +import { CHILD_CONTAINER_GROUP } from "../../../schema/blocks/children.js"; import { expandPMRangeToWords } from "../../../util/expandToWords.js"; import { blockEdgePos, @@ -100,7 +101,7 @@ export function getSelection< for (let depth = $startBlockBeforePos.depth; depth > sharedDepth; depth--) { const parentNode = $startBlockBeforePos.node(depth); - if (parentNode.type.isInGroup("childContainer")) { + if (parentNode.type.isInGroup(CHILD_CONTAINER_GROUP)) { const startIndexAtDepth = $startBlockBeforePos.index(depth) + 1; const childCountAtDepth = $startBlockBeforePos.node(depth).childCount; diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index 74af5ba031..9561927f0f 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -109,7 +109,7 @@ export async function handleFileInsertion< // Gets file block corresponding to MIME type. let fileBlockType = "file"; for (const blockSpec of Object.values(editor.schema.blockSpecs)) { - for (const mimeType of blockSpec.implementation.meta?.fileBlockAccept || + for (const mimeType of blockSpec.implementation.meta.fileBlockAccept || []) { const isFileExtension = mimeType.startsWith("."); const file = items[i].getAsFile(); diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index e150af1309..d284d251bb 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -144,10 +144,10 @@ export function selectedFragmentToHTML< // selected text instead of markdown. const { $from, $to } = view.state.selection; const parentBlockType = $from.parent.type.name; - const parentBlockSpec = editor.blockImplementations[parentBlockType as any]; + const parentBlockSpec = + editor.schema.blockSpecs[parentBlockType as keyof BSchema]; const isPurelyInsideCodeBlock = - $from.sameParent($to) && - parentBlockSpec?.implementation.meta?.code === true; + $from.sameParent($to) && parentBlockSpec?.implementation.meta.code === true; const markdown = isPurelyInsideCodeBlock ? view.state.doc.textBetween($from.pos, $to.pos) diff --git a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts index 33376b2835..05338f5f97 100644 --- a/packages/core/src/api/exporters/html/internalHTMLSerializer.ts +++ b/packages/core/src/api/exporters/html/internalHTMLSerializer.ts @@ -60,11 +60,11 @@ const makeCheckListItemsReadOnly = (element: HTMLElement) => { // serializing HTML elements to a string, so the button no longer works if the // HTML string is rendered out. const forceToggleBlocksShow = (element: HTMLElement) => { - const hiddenToggleWrappers = element.querySelectorAll( - '.bn-toggle-wrapper[data-show-children="false"]', + const closedToggleButtons = element.querySelectorAll( + '.bn-toggle-button[aria-expanded="false"]', ); - hiddenToggleWrappers.forEach((toggleWrapper) => { - toggleWrapper.setAttribute("data-show-children", "true"); + closedToggleButtons.forEach((toggleButton) => { + toggleButton.setAttribute("aria-expanded", "true"); }); return element; diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index e2274140f7..0a267d1b43 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -8,6 +8,12 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { isLayoutNode } from "../../../../schema/blocks/children.js"; +import { + FRAME_SLOT_ATTRIBUTE, + renderFrameDOM, +} from "../../../../schema/blocks/frame.js"; +import { isDocumentFragment } from "../../../../schema/blocks/internal.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -176,6 +182,52 @@ export function serializeInlineContentExternalHTML< return fragment; } +/** A block's external HTML, before the serializer post-processes it. */ +type BlockOutput = { + dom: HTMLElement | DocumentFragment; + contentDOM?: HTMLElement; + childrenDOM?: HTMLElement; +}; + +/** + * The block's output for external HTML, and the frame to put it into. The + * block's own `toExternalHTML` replaces the default export, frame included. + * By default, a layout block is its frame, with the children in the slot, and + * any other block is `render`'s output, which the caller puts into the + * block's frame (if it draws one). + */ +function renderBlockOutput( + blockImplementation: BlockImplementation, + block: any, + editor: BlockNoteEditor, + isLayoutBlock: boolean, + nestingLevel: number, +): { output: BlockOutput; frame?: { dom: HTMLElement; slot: HTMLElement } } { + const external = blockImplementation.toExternalHTML?.call({}, block, editor, { + nestingLevel, + }); + if (external) { + return { output: external }; + } + + const frame = renderFrameDOM(editor, block); + if (isLayoutBlock) { + if (!frame) { + throw new Error( + `Layout block "${block.type}" drew no frame. This is a bug in BlockNote.`, + ); + } + return { output: { dom: frame.dom, childrenDOM: frame.slot } }; + } + + if (!blockImplementation.render) { + throw new Error( + `Block "${block.type}" has no \`render\`. This is a bug in BlockNote.`, + ); + } + return { output: blockImplementation.render.call({}, block, editor), frame }; +} + /** * TODO: there's still quite some logic that handles getting and filtering properties, * we should make sure the `toExternalHTML` methods of default blocks actually handle this, @@ -224,27 +276,27 @@ function serializeBlock< const blockImplementation = editor.blockImplementations[block.type as any] .implementation as BlockImplementation; - const ret = - blockImplementation.toExternalHTML?.call( - {}, - { ...block, props } as any, - editor as any, - { - nestingLevel, - }, - ) || - blockImplementation.render.call( - {}, - { ...block, props } as any, - editor as any, - ); + const blockWithDefaults = { ...block, props, children: block.children ?? [] }; + const isLayoutBlock = isLayoutNode(editor.pmSchema.nodes[block.type as any]); + const { output: ret, frame } = renderBlockOutput( + blockImplementation, + blockWithDefaults, + editor, + isLayoutBlock, + nestingLevel, + ); const elementFragment = doc.createDocumentFragment(); - if ((ret.dom as HTMLElement).classList.contains("bn-block-content")) { + let rootElement: HTMLElement | undefined; + if (!isDocumentFragment(ret.dom)) { + rootElement = ret.dom; + } + + 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 +308,40 @@ 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)); + elementFragment.append(...Array.from(rootElement.childNodes)); } else { elementFragment.append(ret.dom); + if (isLayoutBlock) { + // Like a regular block's type marker (`data-content-type`), a + // layout block's markers stay out of external HTML: it parses back only + // through the block's `parse`, like any other block. Its props stay as + // `data-*` attributes. This mirrors the unwrapping of a regular block's + // `.bn-block-content` above: the spec's hooks produce the editor's + // structure with its markers, and this serializer strips them. + // TODO: consider not adding the markers in the first place (only the + // node view and the internal serializer need them), instead of adding + // and then stripping them. If so, change it for regular blocks too + // (`wrapInBlockStructure`), so both kinds of block keep one rule. + rootElement?.removeAttribute("data-node-type"); + rootElement?.removeAttribute("data-id"); + ret.childrenDOM?.removeAttribute(FRAME_SLOT_ATTRIBUTE); + } if (nestingLevel > 0) { - (ret.dom as HTMLElement).setAttribute( - "data-nesting-level", - nestingLevel.toString(), - ); + rootElement?.setAttribute("data-nesting-level", nestingLevel.toString()); } } @@ -301,15 +367,22 @@ 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)); } } + // Where the block's children go: where the block placed them, else in the + // slot of the frame around its content. + let childrenDOM = ret.childrenDOM; + if (frame) { + frame.slot.append(elementFragment); + elementFragment.append(frame.dom); + childrenDOM = frame.slot; + } + let listType = undefined; if (orderedListItemBlockTypes.has(block.type!)) { listType = "OL"; @@ -362,18 +435,20 @@ function serializeBlock< } } - if ("childrenDOM" in ret && ret.childrenDOM) { + if (childrenDOM) { // block specifies where children should go (e.g. toggle blocks - // place children inside
) - ret.childrenDOM.append(childFragment); + // place children inside
, or a frame's slot) + childrenDOM.append(childFragment); } else if ( - editor.pmSchema.nodes[block.type as any].isInGroup("blockContent") + editor.pmSchema.nodes[block.type as any].isInGroup("blockContent") || + !ret.contentDOM ) { - // default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment + // default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment. + // So does a layout block whose own export places its children nowhere. fragment.append(childFragment); } else { // for columns / column lists, do use nesting - ret.contentDOM?.append(childFragment); + ret.contentDOM.append(childFragment); } } } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..3a5ed1e357 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -7,6 +7,7 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { isLayoutNode } from "../../../../schema/blocks/children.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -14,6 +15,11 @@ import { } from "../../../nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js"; +import { setBlockColorAttributes } from "../../../../pm-nodes/BlockContainer.js"; +import { + markFrameSlot, + renderFrameDOM, +} from "../../../../schema/blocks/frame.js"; export function serializeInlineContentInternalHTML< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -150,12 +156,37 @@ function serializeBlock< const children = block.children || []; const impl = editor.blockImplementations[block.type as any].implementation; + const fullBlock = { ...block, props, children } as any; + + if (isLayoutNode(editor.pmSchema.nodes[block.type as any])) { + // A layout block is its frame. `renderFrameDOM` stamps the frame's root with + // the block's attributes and marks the slot, which holds the children, so + // the round-trip parse rule reads only the slot and not the chrome around + // it. + const frame = renderFrameDOM(editor, fullBlock); + if (!frame) { + throw new Error( + `Layout block "${block.type}" drew no frame. This is a bug in BlockNote.`, + ); + } + if (children.length > 0) { + frame.slot.append(serializeBlocks(editor, children, serializer, options)); + } + return frame.dom; + } + + // Every block but a layout block declares `render` (`validateRenderHooks`). + if (!impl.render) { + throw new Error( + `Block "${block.type}" has no \`render\`. This is a bug in BlockNote.`, + ); + } const ret = impl.render.call( { renderType: "dom", props: undefined, }, - { ...block, props, children } as any, + fullBlock, editor as any, ); @@ -170,22 +201,6 @@ function serializeBlock< ret.contentDOM.appendChild(ic); } - const pmType = editor.pmSchema.nodes[block.type as any]; - - if (pmType.isInGroup("bnBlock")) { - if (block.children && block.children.length > 0) { - const fragment = serializeBlocks( - editor, - block.children, - serializer, - options, - ); - - ret.contentDOM?.append(fragment); - } - return ret.dom; - } - // wrap the block in a blockContainer const bc = BC_NODE.spec?.toDOM?.( BC_NODE.create({ @@ -196,12 +211,27 @@ function serializeBlock< dom: HTMLElement; contentDOM?: HTMLElement; }; + if (bc.contentDOM) { + setBlockColorAttributes(bc.contentDOM, props); + } - bc.contentDOM?.appendChild(ret.dom); + // The block's content and children go into the blockContainer, or into the + // slot of the frame around them. The DOM render context lets interactive + // frames export without browser view state. + let contentDOM = bc.contentDOM; + const frame = renderFrameDOM(editor, fullBlock); + if (frame) { + // Marks where the block's content and children are, for the parser: the + // rest of the frame is chrome, not document content. + markFrameSlot(frame.slot); + bc.contentDOM?.appendChild(frame.dom); + contentDOM = frame.slot; + } + contentDOM?.appendChild(ret.dom); - if (block.children && block.children.length > 0) { - bc.contentDOM?.appendChild( - serializeBlocksInternalHTML(editor, block.children, serializer, options), + if (children.length > 0) { + contentDOM?.appendChild( + serializeBlocksInternalHTML(editor, children, serializer, options), ); } return bc.dom; diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts index 3e45c14330..c98bc513c7 100644 --- a/packages/core/src/api/getBlockInfoFromPos.test.ts +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -1,10 +1,13 @@ +import { layoutSchema } from "./blockManipulation/layoutBlocks/layoutBlocks.fixture.js"; +import { getNodeById } from "./nodeUtil.js"; import { Node, Schema } from "prosemirror-model"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { blockToNode } from "./nodeConversions/blockToNode.js"; import { docToBlocks } from "./nodeConversions/nodeToBlock.js"; import { + getAncestorLayoutBlocks, getBlockInfoFromNode, getLastDescendantBlockInfo, getNextBlockInfo, @@ -281,21 +284,6 @@ describe("derived position and content fields", () => { expect(info.contentKind).toBe("plain"); }); - it("rejects malformed wrapper structure at the block-info boundary", () => { - const { blockContainer, paragraph, blockGroup } = getSchema().nodes; - const content = paragraph.create(); - for (const children of [ - [], - [blockGroup.create()], - [content, content], - [content, blockGroup.create(), blockGroup.create()], - ]) { - // Deliberately bypass schema checking, as transaction intermediates can. - const node = blockContainer.create(null, children); - expect(() => getBlockInfoFromNode(node, 0)).toThrow(/blockContainer/); - } - }); - it("rejects a content node that was not built from a block spec", () => { // A node dropped straight into the `blockContent` group of a ProseMirror // schema, with no block spec behind it: nothing declares what its content @@ -522,3 +510,141 @@ describe("docToBlocks round trip with suggested deletions", () => { expect(new Set(ids).size).toBe(ids.length); }); }); + +describe("block info for layout blocks", () => { + let editor: BlockNoteEditor< + typeof layoutSchema.blockSchema, + typeof layoutSchema.inlineContentSchema, + typeof layoutSchema.styleSchema + >; + beforeEach(() => { + editor = BlockNoteEditor.create({ schema: layoutSchema }); + }); + afterEach(() => { + editor._tiptapEditor.destroy(); + }); + it.each(["paragraph", "alert", "callout"] as const)( + "distinguishes %s content from the presence of children", + (type) => { + for (const children of [ + [], + [{ type: "paragraph" as const, content: "Body" }], + ]) { + const node = blockToNode({ type, children }, editor.pmSchema); + const info = getBlockInfoFromNode(node, 10); + expect(info.hasContent).toBe(type !== "callout"); + if (children.length) { + expect(info.children?.node.childCount).toBe(1); + } + } + }, + ); + + it("rejects malformed wrapper structure at the block-info boundary", () => { + const { blockContainer, paragraph, blockGroup } = editor.pmSchema.nodes; + const content = paragraph.create(); + for (const children of [ + [], + [blockGroup.create()], + [content, content], + [content, blockGroup.create(), blockGroup.create()], + ]) { + // Deliberately bypass schema checking, as transaction intermediates can. + const node = blockContainer.create(null, children); + expect(() => getBlockInfoFromNode(node, 0)).toThrow(/blockContainer/); + } + }); + + describe("parent lookups for layout block children", () => { + // Regression: `getParentBlockInfo` used to skip the layout block level for + // layout block children (returning the grid for a block inside a gridCell). + // The parent of a block is the block whose `children` contains it: the + // cell. + it("returns the layout block as the parent of its direct children", () => { + 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.transact((tr) => { + // The block directly containing a cell's paragraph is the cell. + const cellChild = getNodeById("cell-a-p", tr.doc)!; + expect( + getParentBlockInfo(tr.doc, cellChild.posBeforeNode)?.blockNoteType, + ).toBe("gridCell"); + + // The parent of a cell is the grid; the parent of the grid (a + // top-level block) is undefined. + const cell = getNodeById("cell-a", tr.doc)!; + expect( + getParentBlockInfo(tr.doc, cell.posBeforeNode)?.blockNoteType, + ).toBe("grid"); + + const grid = getNodeById("g-0", tr.doc)!; + expect(getParentBlockInfo(tr.doc, grid.posBeforeNode)).toBeUndefined(); + }); + }); + }); + + it("lists the layout block ancestors of a position, innermost first", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "cell-a", + children: [ + { + type: "callout", + id: "c-0", + children: [{ id: "deep-p", type: "paragraph", content: "X" }], + }, + ], + }, + { + type: "gridCell", + id: "cell-b", + children: [{ id: "cell-b-p", type: "paragraph", content: "B" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.transact((tr) => { + const deep = getNodeById("deep-p", tr.doc)!; + const ancestors = getAncestorLayoutBlocks(tr.doc, deep.posBeforeNode); + + // Only the layout block nodes: the `blockGroup`/`blockContainer` levels + // between them are not layout blocks and must not be repaired. + expect(ancestors.map(({ id }) => id)).toEqual(["c-0", "cell-a", "g-0"]); + // Depths shrink outwards, which is what `fixLayoutBlocksById` sorts on. + expect(ancestors.map(({ depth }) => depth)).toEqual( + [...ancestors.map(({ depth }) => depth)].sort((a, b) => b - a), + ); + + // A top-level block has no layout block ancestors at all. + const trailing = getNodeById("trailing", tr.doc)!; + expect(getAncestorLayoutBlocks(tr.doc, trailing.posBeforeNode)).toEqual( + [], + ); + }); + }); +}); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index a9512f0f6a..1532035687 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -7,6 +7,10 @@ import { Transaction, } from "prosemirror-state"; +import { + CHILD_CONTAINER_GROUP, + isLayoutNode, +} from "../schema/blocks/children.js"; import type { BlockConfig } from "../schema/blocks/types.js"; /** @@ -88,10 +92,12 @@ export type BlockInfo = { /** `content.afterPos - 1`: the last position inside the content. */ contentEnd: number; /** - * What the content node holds: its block spec's `content`, which is what - * the node's ProseMirror content expression was generated from (and what - * a hand-written node's expression is checked against when the schema is - * built). + * What the content node holds: its block spec's `content`, read from the + * `blockConfig` stored on the node's spec. For a node `createBlockSpec` + * builds, the node's ProseMirror content expression is generated from + * it. For a hand-written node (`createBlockSpecFromTiptapNode`), it is + * whatever the block spec declares; the node's own content expression is + * not checked against it. */ contentKind: BlockConfig["content"]; /** `content.node.childCount === 0`. */ @@ -319,7 +325,7 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { afterPos: beforePos + node.nodeSize, }; - if (node.type.isInGroup("bnBlock") && node.type.isInGroup("childContainer")) { + if (isLayoutNode(node.type)) { return { hasContent: false, block, @@ -452,7 +458,7 @@ export function getParentBlockInfo( } // A `blockGroup`: its own parent block is the real parent, unless it's the // document root group. - if (parent.type.isInGroup("childContainer") && $pos.depth > 1) { + if (parent.type.isInGroup(CHILD_CONTAINER_GROUP) && $pos.depth > 1) { return getBlockInfoAt(doc, $pos.before($pos.depth - 1)); } return undefined; @@ -583,13 +589,7 @@ export function getInsertionPos( // A restricted container can route insertion into its edge container, // e.g. inserting a paragraph into the last column of a column list. const child = last ? children.node.lastChild : children.node.firstChild; - if ( - !child || - !( - child.type.isInGroup("bnBlock") && - child.type.isInGroup("childContainer") - ) - ) { + if (!child || !isLayoutNode(child.type)) { break; } info = getBlockInfoFromNode( @@ -599,3 +599,80 @@ export function getInsertionPos( } return null; } + +/** + * Resolves a block to its first leaf block: the block itself when it is not a + * layout block, otherwise the first leaf of its first child. Returns `null` for + * an empty layout block. + */ +export function getFirstLeafBlock(info: BlockInfo): BlockInfo | null { + while (!info.hasContent) { + const { node, childrenStart } = info.children; + if (!node.firstChild) { + return null; + } + info = getBlockInfoFromNode(node.firstChild, childrenStart); + } + return info; +} + +/** + * Climbs out of layout blocks until it reaches a position where `nodeType` fits. + * `side` picks which edge of each climbed layout block to land on: `"before"` for + * moves that put a block above the layout blocks it leaves (Backspace move-out), + * `"after"` for moves that put it below them (Enter-exit). + * + * Position-based rather than `BlockInfo`-based (unlike the descend/leaf + * helpers above) because its input is an arbitrary gap position — a point + * between blocks, not a block. + */ +export function ascendToInsertablePos( + doc: Node, + pos: number, + nodeType: NodeType, + side: "before" | "after" = "before", +): number | undefined { + for (;;) { + const $pos = doc.resolve(pos); + const parent = $pos.node(); + if (parent.canReplaceWith($pos.index(), $pos.index(), nodeType)) { + return pos; + } + if ($pos.depth > 0 && isLayoutNode(parent.type)) { + pos = side === "before" ? $pos.before() : $pos.after(); + continue; + } + return undefined; + } +} + +/** + * The layout blocks that a removal at a position can empty, outermost last, + * each with its block id and resolution depth. Used to re-run layout block + * repair (`fixLayoutBlocksById`) after a mutation. Position-based for the same + * reason as `ascendToInsertablePos`: selections and mapped positions are the + * natural inputs. + * + * These are the layout blocks that directly hold the position, and the layout + * blocks that directly hold them. The walk stops at the first ancestor that is + * not a layout block: a removal inside a regular block's children cannot empty + * a layout block above that regular block, so those layout blocks stay as they + * are. + */ +export function getAncestorLayoutBlocks( + doc: Node, + pos: number, +): { id: string; depth: number }[] { + const $pos = doc.resolve(pos); + const layoutBlocks: { id: string; depth: number }[] = []; + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if (!isLayoutNode(ancestor.type)) { + break; + } + if (ancestor.attrs.id) { + layoutBlocks.push({ id: ancestor.attrs.id, depth }); + } + } + return layoutBlocks; +} diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index af5c0ba1b7..7728ecf163 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -16,6 +16,10 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +import { + createBlockGroup, + isLayoutNode, +} 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"; @@ -288,13 +292,17 @@ export function tableContentToNodes< return rowNodes; } +/** + * Converts a block's (or custom inline content element's) `content` field to a + * `blockContent` (or custom inline content) prosemirror node. + */ function blockOrInlineContentToContentNode( block: | PartialBlock | PartialCustomInlineContentFromConfig, schema: Schema, styleSchema: StyleSchema, -) { +): Node { let contentNode: Node; let type = block.type; @@ -334,6 +342,25 @@ function blockOrInlineContentToContentNode( return contentNode; } +// `createAndFill` fills with schema defaults, which leaves `id: null`. Always +// rebuilds: the only inputs are freshly created nodes, so there is no shared +// structure worth preserving. +function withGeneratedIds(node: Node): Node { + if (node.isText) { + return node; + } + + const children: Node[] = []; + node.forEach((child) => children.push(withGeneratedIds(child))); + + const needsId = node.type.isInGroup("bnBlock") && node.attrs.id === null; + return node.type.create( + needsId ? { ...node.attrs, id: UniqueID.options.generateID() } : node.attrs, + Fragment.from(children), + node.marks, + ); +} + /** * Converts a BlockNote block to a Prosemirror node. */ @@ -361,8 +388,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, @@ -370,9 +395,7 @@ export function blockToNode( ); const groupNode = - children.length > 0 - ? schema.nodes["blockGroup"].createChecked({}, children) - : undefined; + children.length > 0 ? createBlockGroup(schema, children) : undefined; return schema.nodes["blockContainer"].createChecked( { @@ -381,16 +404,21 @@ 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 (isLayoutNode(schema.nodes[block.type])) { + const type = schema.nodes[block.type]; + const attrs = { id: id, ...block.props }; + + // Fill missing children up to the configured minimum, including for an + // explicit empty array. Generated descendants need block IDs as well. + const node = type.createAndFill(attrs, children); + if (!node) { + throw new Error( + `Cannot create block "${block.type}": its children don't fit its \`children\` config ` + + `(it accepts \`${type.spec.content}\`).`, + ); + } + + return withGeneratedIds(node); } else { throw new Error( `block type ${block.type} doesn't match blockContent or bnBlock group`, diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..10e011cc72 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,63 +1,48 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + isLayoutNode, + isNamedOnly, + minChildren, +} from "../../schema/blocks/children.js"; import { nodeToBlock } from "./nodeToBlock.js"; -/** - * Converts all Blocks within a fragment to BlockNote blocks. - */ 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[] = []; - 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; - } + function visit(node: Node, root: Node) { + const childrenConfig = node.type.spec.blockConfig?.children; + const incompleteBlock = + node.type.name === "blockContainer" && + node.firstChild?.type.name === "blockGroup"; + const flattenLayoutBlock = + isLayoutNode(node.type) && + (!childrenConfig || + isNamedOnly(node.type) || + node.childCount < minChildren(childrenConfig)); - 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 - return false; + // Open selections and layout blocks that cannot stand alone contribute + // their children. Complete blocks already include their descendants. + if ( + incompleteBlock || + flattenLayoutBlock || + !node.type.isInGroup("bnBlock") + ) { + node.forEach((child) => visit(child, flattenLayoutBlock ? root : child)); + } else { + blocks.push(nodeToBlock(node, root)); } - return true; - }); + } + + fragment.forEach((node) => visit(node, node)); return blocks; } diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 004b7fd44b..f1c098adac 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 { isLayoutNode } from "../../schema/blocks/children.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { BlockSchema, @@ -399,7 +400,7 @@ export function nodeToBlock< const styleSchema = getStyleSchema(schema) as S; const blockCache = getBlockCache(schema); if (!node.type.isInGroup("bnBlock")) { - throw Error("Node should be a bnBlock, but is instead: " + node.type.name); + throw Error("Node should be a block, but is instead: " + node.type.name); } const cachedBlock = blockCache?.get(node); @@ -418,9 +419,9 @@ export function nodeToBlock< id = UniqueID.options.generateID(); } - const blockSpec = blockSchema[blockInfo.blockNoteType]; + const blockConfig = blockSchema[blockInfo.blockNoteType]; - if (!blockSpec) { + if (!blockConfig) { throw Error("Block is of an unrecognized type: " + blockInfo.blockNoteType); } @@ -429,7 +430,7 @@ export function nodeToBlock< ...node.attrs, ...(blockInfo.hasContent ? blockInfo.content.node.attrs : {}), })) { - const propSchema = blockSpec.propSchema; + const propSchema = blockConfig.propSchema; if ( attr in propSchema && @@ -439,8 +440,6 @@ export function nodeToBlock< } } - const blockConfig = blockSchema[blockInfo.blockNoteType]; - const children: Block[] = []; blockInfo.children?.node.forEach((child) => { children.push(nodeToBlock(child, doc)); @@ -560,45 +559,58 @@ export function prosemirrorSliceToSlicedBlocks< blockCutAtStart: string | undefined; blockCutAtEnd: string | undefined; } { - if (node.type.name !== "blockGroup") { + // Both `blockGroup` and layout block nodes (columnList, column, callout, + // ...) hold block children directly, so both can be processed here. + if (node.type.name !== "blockGroup" && !isLayoutNode(node.type)) { throw new Error("unexpected"); } const blocks: Block[] = []; let blockCutAtStart: string | undefined; let blockCutAtEnd: string | undefined; - node.forEach((blockContainer, _offset, index) => { - if (blockContainer.type.name !== "blockContainer") { - throw new Error("unexpected"); - } - if (blockContainer.childCount === 0) { - return; - } - if (blockContainer.childCount === 0 || blockContainer.childCount > 2) { - throw new Error( - "unexpected, blockContainer.childCount: " + blockContainer.childCount, - ); - } - + // Each child is a block's node: a `blockContainer` for a regular block, + // or a layout block node (columnList, column, callout, ...) for a layout block + // block. + node.forEach((blockNode, _offset, index) => { 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.: - // A - // ├── B - // selection starts within B, then this blockContainer is A, but we don't care about A - // so let's descend into B and continue processing - if (!isFirstBlock) { + const isLayoutBlock = isLayoutNode(blockNode.type); + if (!isLayoutBlock) { + if (blockNode.type.name !== "blockContainer") { throw new Error("unexpected"); } + if (blockNode.childCount === 0) { + return; + } + if (blockNode.childCount > 2) { + throw new Error( + "unexpected, blockContainer.childCount: " + blockNode.childCount, + ); + } + } + + const omittedParent = + !isLayoutBlock && blockNode.firstChild!.type.name === "blockGroup"; + if (omittedParent && !isFirstBlock) { + throw new Error("unexpected"); + } + + // Open layout blocks and regular parents whose content was cut away both + // contribute their selected children, without their own wrapper. + if ( + omittedParent || + (isLayoutBlock && + ((isFirstBlock && openStart > 0) || (isLastBlock && openEnd > 0))) + ) { const ret = processNode( - blockContainer.firstChild!, - Math.max(0, openStart - 1), + isLayoutBlock ? blockNode : blockNode.firstChild!, + isFirstBlock ? Math.max(0, openStart - 1) : 0, isLastBlock ? Math.max(0, openEnd - 1) : 0, ); - blockCutAtStart = ret.blockCutAtStart; + if (isFirstBlock) { + blockCutAtStart = ret.blockCutAtStart; + } if (isLastBlock) { blockCutAtEnd = ret.blockCutAtEnd; } @@ -606,9 +618,16 @@ export function prosemirrorSliceToSlicedBlocks< return; } - const block = nodeToBlock(blockContainer, slice.content.firstChild!); + if (isLayoutBlock) { + blocks.push( + nodeToBlock(blockNode, slice.content.firstChild!), + ); + return; + } + + const block = nodeToBlock(blockNode, slice.content.firstChild!); const childGroup = - blockContainer.childCount > 1 ? blockContainer.child(1) : undefined; + blockNode.childCount > 1 ? blockNode.child(1) : undefined; let childBlocks: Block[] = []; if (childGroup) { diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts index 6b14204cc8..572468d29a 100644 --- a/packages/core/src/blocks/Heading/block.ts +++ b/packages/core/src/blocks/Heading/block.ts @@ -7,7 +7,10 @@ import { parseDefaultProps, } from "../defaultProps.js"; import { getDetailsContent } from "../getDetailsContent.js"; -import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js"; +import { + createToggleFrame, + isToggleOpen, +} from "../ToggleWrapper/createToggleFrame.js"; const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const; @@ -18,8 +21,15 @@ export interface HeadingOptions { allowToggleHeadings?: boolean; } +// A regular heading's props. With toggle headings enabled, this sets +// `isToggleable: false`, so that turning a toggle heading into a heading of +// some level also makes it a regular heading (BLO-959). +function regularHeadingProps(level: number, allowToggleHeadings: boolean) { + return allowToggleHeadings ? { level, isToggleable: false } : { level }; +} + const createHeadingKeyboardShortcut = - (level: number) => + (level: number, allowToggleHeadings: boolean) => ({ editor }: { editor: BlockNoteEditor }) => { const cursorPosition = editor.getTextCursorPosition(); @@ -31,7 +41,7 @@ const createHeadingKeyboardShortcut = editor.updateBlock(cursorPosition.block, { type: "heading", - props: { level }, + props: regularHeadingProps(level, allowToggleHeadings), }); return true; @@ -63,7 +73,29 @@ export const createHeadingBlockSpec = createBlockSpec( ({ allowToggleHeadings = true }: HeadingOptions = {}) => ({ meta: { isolating: false, + // A block dragged onto a toggle heading becomes its first child. + dropsIntoChildren: (block) => + allowToggleHeadings && block.props.isToggleable === true, }, + // A toggle heading resets to a regular heading, which in turn resets to a + // paragraph. While a toggle heading is open, Enter in its text starts its + // children, and Enter in an empty child adds another child. + experimental_keyboard: allowToggleHeadings + ? (block) => { + if (!block.props.isToggleable) { + return {}; + } + const open = isToggleOpen(block); + return { + resetsTo: { type: "heading", props: { isToggleable: false } }, + emptyEnterResets: true, + enter: open ? "into-children" : "split", + // A closed toggle keeps its hidden children. + splitKeepsChildren: !open, + emptyChildEnter: open ? "stay" : "outdent", + }; + } + : undefined, parse(e) { if (allowToggleHeadings && e.tagName === "DETAILS") { const summary = e.querySelector(":scope > summary"); @@ -126,19 +158,25 @@ export const createHeadingBlockSpec = createBlockSpec( } : {}), runsBefore: ["toggleListItem"], - render(block, editor) { + render(block) { const dom = document.createElement(`h${block.props.level}`); - - if (allowToggleHeadings) { - const toggleWrapper = createToggleWrapper(block, editor, dom); - return { ...toggleWrapper, contentDOM: dom }; - } - return { dom, contentDOM: dom, }; }, + renderFrame(block, editor) { + if (!allowToggleHeadings || !block.props.isToggleable) { + return undefined; + } + const frame = createToggleFrame(block, editor); + return { + ...frame, + // A heading that is no longer toggleable gets no frame. + update: (updated) => + !!updated.props.isToggleable && frame.update(updated), + }; + }, toExternalHTML(block) { const dom = document.createElement(`h${block.props.level}`); addDefaultPropsExternalHTML(block.props, dom); @@ -163,13 +201,16 @@ export const createHeadingBlockSpec = createBlockSpec( }; }, }), - ({ levels = HEADING_LEVELS }: HeadingOptions = {}) => [ + ({ + levels = HEADING_LEVELS, + allowToggleHeadings = true, + }: HeadingOptions = {}) => [ createExtension({ key: "heading-shortcuts", keyboardShortcuts: Object.fromEntries( levels.map((level) => [ `Mod-Alt-${level}`, - createHeadingKeyboardShortcut(level), + createHeadingKeyboardShortcut(level, allowToggleHeadings), ]) ?? [], ), inputRules: levels.map((level) => ({ @@ -177,9 +218,7 @@ export const createHeadingBlockSpec = createBlockSpec( replace({ match }: { match: RegExpMatchArray }) { return { type: "heading", - props: { - level: match[1].length, - }, + props: regularHeadingProps(match[1].length, allowToggleHeadings), }; }, })), diff --git a/packages/core/src/blocks/ListItem/BulletListItem/block.ts b/packages/core/src/blocks/ListItem/BulletListItem/block.ts index 0a40bdc1ce..6c791299fb 100644 --- a/packages/core/src/blocks/ListItem/BulletListItem/block.ts +++ b/packages/core/src/blocks/ListItem/BulletListItem/block.ts @@ -6,7 +6,6 @@ import { defaultProps, parseDefaultProps, } from "../../defaultProps.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; import { getListItemContent } from "../getListItemContent.js"; export type BulletListItemBlockConfig = ReturnType< @@ -27,6 +26,12 @@ export const createBulletListItemBlockConfig = createBlockConfig( export const createBulletListItemBlockSpec = createBlockSpec( createBulletListItemBlockConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. + experimental_keyboard: { + splitKeepsType: true, + emptyEnterResets: true, + }, meta: { isolating: false, }, @@ -84,9 +89,6 @@ export const createBulletListItemBlockSpec = createBlockSpec( createExtension({ key: "bullet-list-item-shortcuts", keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "bulletListItem"); - }, "Mod-Shift-8": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts index b1da899571..9715bc503e 100644 --- a/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.test.ts @@ -34,7 +34,7 @@ it("renders checkbox as enabled when editor is editable", () => { children: [], }; const spec = editor.schema.blockSpecs.checkListItem; - const view = spec.implementation.render(block, editor); + const view = spec.implementation.render!(block, editor); const checkbox = getCheckboxFromView(view); expect(checkbox.disabled).toBe(false); }); @@ -55,7 +55,7 @@ it("renders checkbox as disabled when editor is not editable", () => { children: [], }; const spec = editor.schema.blockSpecs.checkListItem; - const view = spec.implementation.render(block, editor); + const view = spec.implementation.render!(block, editor); const checkbox = getCheckboxFromView(view); expect(checkbox.disabled).toBe(true); }); diff --git a/packages/core/src/blocks/ListItem/CheckListItem/block.ts b/packages/core/src/blocks/ListItem/CheckListItem/block.ts index 6d514270bf..936efc3be0 100644 --- a/packages/core/src/blocks/ListItem/CheckListItem/block.ts +++ b/packages/core/src/blocks/ListItem/CheckListItem/block.ts @@ -5,7 +5,6 @@ import { defaultProps, parseDefaultProps, } from "../../defaultProps.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; import { getListItemContent } from "../getListItemContent.js"; export type CheckListItemBlockConfig = ReturnType< @@ -27,6 +26,12 @@ export const createCheckListItemConfig = createBlockConfig( export const createCheckListItemBlockSpec = createBlockSpec( createCheckListItemConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. + experimental_keyboard: { + splitKeepsType: true, + emptyEnterResets: true, + }, meta: { isolating: false, }, @@ -133,9 +138,6 @@ export const createCheckListItemBlockSpec = createBlockSpec( createExtension({ key: "check-list-item-shortcuts", keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "checkListItem"); - }, "Mod-Shift-9": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts deleted file mode 100644 index 218618ca1f..0000000000 --- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js"; -import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; - -export const handleEnter = (editor: BlockNoteEditor) => { - const { blockInfo, selectionEmpty } = editor.transact((tr) => { - return { - blockInfo: getBlockInfoFromSelection(tr), - selectionEmpty: tr.selection.anchor === tr.selection.head, - }; - }); - - if (!blockInfo.hasContent) { - return false; - } - const { block: blockContainer, content } = blockInfo; - - if ( - !( - content.node.type.name === "toggleListItem" || - content.node.type.name === "bulletListItem" || - content.node.type.name === "numberedListItem" || - content.node.type.name === "checkListItem" - ) || - !selectionEmpty - ) { - return false; - } - - return editor._tiptapEditor.commands.first(({ state, chain, commands }) => [ - () => - // Changes list item block to a paragraph block if the content is empty. - commands.command(() => { - if (blockInfo.isContentEmpty) { - return commands.command( - updateBlockCommand(blockContainer.beforePos, { - type: "paragraph", - props: {}, - }), - ); - } - - return false; - }), - - () => - // Splits the current block, moving content inside that's after the cursor - // to a new block of the same type below. - commands.command(() => { - if (content.node.childCount > 0) { - chain() - .deleteSelection() - .command(splitBlockCommand(state.selection.from, true)) - .run(); - - return true; - } - - return false; - }), - ]); -}; diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts index fc2537829d..57530d741b 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/block.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/block.ts @@ -6,7 +6,6 @@ import { defaultProps, parseDefaultProps, } from "../../defaultProps.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; import { getListItemContent } from "../getListItemContent.js"; import { NumberedListIndexingDecorationPlugin } from "./IndexingPlugin.js"; @@ -29,6 +28,12 @@ export const createNumberedListItemBlockConfig = createBlockConfig( export const createNumberedListItemBlockSpec = createBlockSpec( createNumberedListItemBlockConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. + experimental_keyboard: { + splitKeepsType: true, + emptyEnterResets: true, + }, meta: { isolating: false, }, @@ -115,9 +120,6 @@ export const createNumberedListItemBlockSpec = createBlockSpec( }, ], keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "numberedListItem"); - }, "Mod-Shift-7": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts index 54a7a39dfe..b516cf9d9e 100644 --- a/packages/core/src/blocks/ListItem/ToggleListItem/block.ts +++ b/packages/core/src/blocks/ListItem/ToggleListItem/block.ts @@ -6,8 +6,10 @@ import { parseDefaultProps, } from "../../defaultProps.js"; import { getDetailsContent } from "../../getDetailsContent.js"; -import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js"; -import { handleEnter } from "../../utils/listItemEnterHandler.js"; +import { + createToggleFrame, + isToggleOpen, +} from "../../ToggleWrapper/createToggleFrame.js"; export type ToggleListItemBlockConfig = ReturnType< typeof createToggleListItemBlockConfig @@ -27,8 +29,24 @@ export const createToggleListItemBlockConfig = createBlockConfig( export const createToggleListItemBlockSpec = createBlockSpec( createToggleListItemBlockConfig, { + // Enter continues the list, and Enter in an empty item ends it: the item + // turns into a paragraph. While the toggle is open, Enter in its text + // starts its children, and Enter in an empty child adds another child. + experimental_keyboard: (block) => { + const open = isToggleOpen(block); + return { + splitKeepsType: true, + emptyEnterResets: true, + enter: open ? "into-children" : "split", + // A closed toggle keeps its hidden children. + splitKeepsChildren: !open, + emptyChildEnter: open ? "stay" : "outdent", + }; + }, meta: { isolating: false, + // A block dragged onto the toggle becomes its first child. + dropsIntoChildren: () => true, }, parse(element) { if (element.tagName === "DETAILS") { @@ -71,15 +89,11 @@ export const createToggleListItemBlockSpec = createBlockSpec( ); }, runsBefore: ["bulletListItem"], - render(block, editor) { + render() { const paragraphEl = document.createElement("p"); - const toggleWrapper = createToggleWrapper( - block as any, - editor, - paragraphEl, - ); - return { ...toggleWrapper, contentDOM: paragraphEl }; + return { dom: paragraphEl, contentDOM: paragraphEl }; }, + renderFrame: createToggleFrame, toExternalHTML(block) { const li = document.createElement("li"); const details = document.createElement("details"); @@ -103,9 +117,6 @@ export const createToggleListItemBlockSpec = createBlockSpec( createExtension({ key: "toggle-list-item-shortcuts", keyboardShortcuts: { - Enter: ({ editor }) => { - return handleEnter(editor, "toggleListItem"); - }, "Mod-Shift-6": ({ editor }) => { const cursorPosition = editor.getTextCursorPosition(); diff --git a/packages/core/src/blocks/Table/block.ts b/packages/core/src/blocks/Table/block.ts index eb464373c0..6f36453048 100644 --- a/packages/core/src/blocks/Table/block.ts +++ b/packages/core/src/blocks/Table/block.ts @@ -5,12 +5,12 @@ import { NodeView } from "prosemirror-view"; import { createExtension } from "../../editor/BlockNoteExtension.js"; import { + applyDataAttributes, BlockConfig, createBlockSpecFromTiptapNode, TableContent, } from "../../schema/index.js"; import { mergeCSSClasses } from "../../util/browser.js"; -import { camelToDataKebab } from "../../util/string.js"; import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { createDefaultBlockDOMOutputSpec } from "../defaultBlockHelpers.js"; import { defaultProps } from "../defaultProps.js"; @@ -277,15 +277,7 @@ const TiptapTableNode = Node.create({ return false; } - for (const [propName, propSpec] of Object.entries(tablePropSchema)) { - const attrName = camelToDataKebab(propName); - const value = updatedNode.attrs[propName]; - if (value !== propSpec.default) { - this.dom.setAttribute(attrName, String(value)); - } else { - this.dom.removeAttribute(attrName); - } - } + applyDataAttributes(this.dom, updatedNode.attrs, tablePropSchema); return true; } diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts new file mode 100644 index 0000000000..f77b837eee --- /dev/null +++ b/packages/core/src/blocks/ToggleWrapper/createToggleFrame.ts @@ -0,0 +1,126 @@ +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { Block } from "../defaultBlocks.js"; + +// Only the block's id is used, so anything with an id will do. +type ToggledState = { + set: (block: Pick, "id">, isToggled: boolean) => void; + get: (block: Pick, "id">) => boolean; +}; + +export const defaultToggledState: ToggledState = { + set: (block, isToggled: boolean) => + window.localStorage.setItem( + `toggle-${block.id}`, + isToggled ? "true" : "false", + ), + get: (block) => window.localStorage.getItem(`toggle-${block.id}`) === "true", +}; + +// https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed +// `aria-hidden`: the icon is decorative, and the button has its own name. +const chevronIcon = + ''; + +/** + * Whether the toggle block is open, as its frame shows it. For keyboard + * settings that differ between an open and a closed toggle. + */ +export function isToggleOpen(block: { id: string }) { + return defaultToggledState.get(block); +} + +/** + * The frame of a toggle block, for `renderFrame`: a chevron that shows or + * hides the block's children, and an "Add block" button while the toggle is + * open and has no children. BlockNote puts the block's content and its + * children in the slot; hiding the children is a CSS rule on the frame. + * + * Whether a toggle is open is the reader's view state, kept in + * `toggledState` (per browser by default), not in the document. + */ +export function createToggleFrame( + block: Block, + editor: BlockNoteEditor, + toggledState = defaultToggledState, +) { + const dom = document.createElement("div"); + dom.className = "bn-toggle-wrapper bn-toggle-frame"; + + // Chrome outside the slot handles its own events. Cancelling `mousedown` + // keeps a click from moving the caret. + const toggleButton = document.createElement("button"); + toggleButton.className = "bn-toggle-button"; + toggleButton.type = "button"; + // A fixed name, with the state in `aria-expanded`, so a screen reader + // announces e.g. "Expand or collapse, button, collapsed" (#2811). The CSS + // reads the open state from `aria-expanded` too. + toggleButton.setAttribute( + "aria-label", + editor.dictionary.toggle_blocks.toggle_button, + ); + toggleButton.innerHTML = chevronIcon; + toggleButton.addEventListener("mousedown", (event) => event.preventDefault()); + + const slot = document.createElement("div"); + slot.className = "bn-toggle-slot"; + + const addBlockButton = document.createElement("button"); + addBlockButton.className = "bn-toggle-add-block-button"; + addBlockButton.type = "button"; + addBlockButton.textContent = editor.dictionary.toggle_blocks.add_block_button; + addBlockButton.addEventListener("mousedown", (event) => + event.preventDefault(), + ); + addBlockButton.addEventListener("click", () => { + editor.transact(() => { + // A single empty block of the default type. + const updated = editor.updateBlock(block.id, { children: [{}] }); + editor.setTextCursorPosition(updated.children[0], "end"); + editor.focus(); + }); + }); + + dom.append(toggleButton, slot); + + let childCount = block.children.length; + let open = toggledState.get(block); + + function show() { + toggleButton.setAttribute("aria-expanded", String(open)); + const showAddBlock = open && childCount === 0 && editor.isEditable; + if (showAddBlock && !addBlockButton.isConnected) { + dom.append(addBlockButton); + } else if (!showAddBlock) { + addBlockButton.remove(); + } + } + + toggleButton.addEventListener("click", () => { + open = !open; + toggledState.set(block, open); + show(); + }); + + show(); + + return { + dom, + slot, + // Keeps the frame, and so its open state, when the block changes. Adding + // a child opens the toggle. Removing the last child keeps it open, showing + // the "Add block" button, as in Notion. + update(updated: Block) { + const newChildCount = updated.children.length; + const wasOpen = open; + if (newChildCount > childCount) { + open = true; + } + if (open !== wasOpen) { + toggledState.set(updated, open); + } + childCount = newChildCount; + show(); + return true; + }, + }; +} diff --git a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts b/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts deleted file mode 100644 index 257fd7ce6f..0000000000 --- a/packages/core/src/blocks/ToggleWrapper/createToggleWrapper.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { ViewMutationRecord } from "@tiptap/pm/view"; - -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; -import { Block } from "../defaultBlocks.js"; - -type ToggledState = { - set: (block: Block, isToggled: boolean) => void; - get: (block: Block) => boolean; -}; - -export const defaultToggledState: ToggledState = { - set: (block, isToggled: boolean) => - window.localStorage.setItem( - `toggle-${block.id}`, - isToggled ? "true" : "false", - ), - get: (block) => window.localStorage.getItem(`toggle-${block.id}`) === "true", -}; - -export const createToggleWrapper = ( - block: Block, - editor: BlockNoteEditor, - renderedElement: HTMLElement, - toggledState: ToggledState = defaultToggledState, -): { - dom: HTMLElement; - contentDOM?: HTMLElement; - ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - destroy?: () => void; -} => { - if ("isToggleable" in block.props && !block.props.isToggleable) { - return { - dom: renderedElement, - }; - } - - const dom = document.createElement("div"); - - const toggleWrapper = document.createElement("div"); - toggleWrapper.className = "bn-toggle-wrapper"; - - const toggleButton = document.createElement("button"); - toggleButton.className = "bn-toggle-button"; - toggleButton.type = "button"; - toggleButton.innerHTML = - // https://fonts.google.com/icons?selected=Material+Symbols+Rounded:chevron_right:FILL@0;wght@700;GRAD@0;opsz@24&icon.query=chevron&icon.style=Rounded&icon.size=24&icon.color=%23e8eaed - ''; - const toggleButtonMouseDown = (event: MouseEvent) => event.preventDefault(); - toggleButton.addEventListener("mousedown", toggleButtonMouseDown); - const toggleButtonOnClick = () => { - // Toggles visibility of child blocks. Also adds/removes the "add block" - // button if there are no child blocks. - const currentBlock = editor.getBlock(block); - if (!currentBlock) { - return; - } - - if (toggleWrapper.getAttribute("data-show-children") === "true") { - toggleWrapper.setAttribute("data-show-children", "false"); - toggledState.set(currentBlock, false); - - if (dom.contains(toggleAddBlockButton)) { - dom.removeChild(toggleAddBlockButton); - } - } else { - toggleWrapper.setAttribute("data-show-children", "true"); - toggledState.set(currentBlock, true); - - if ( - editor.isEditable && - currentBlock.children.length === 0 && - !dom.contains(toggleAddBlockButton) - ) { - dom.appendChild(toggleAddBlockButton); - } - } - }; - toggleButton.addEventListener("click", toggleButtonOnClick); - - toggleWrapper.appendChild(toggleButton); - toggleWrapper.appendChild(renderedElement); - - const toggleAddBlockButton = document.createElement("button"); - toggleAddBlockButton.className = "bn-toggle-add-block-button"; - toggleAddBlockButton.type = "button"; - toggleAddBlockButton.textContent = - editor.dictionary.toggle_blocks.add_block_button; - const toggleAddBlockButtonMouseDown = (event: MouseEvent) => - event.preventDefault(); - toggleAddBlockButton.addEventListener( - "mousedown", - toggleAddBlockButtonMouseDown, - ); - const toggleAddBlockButtonOnClick = () => { - // Adds a single empty child block. - editor.transact(() => { - // dom.removeChild(toggleAddBlockButton); - - const updatedBlock = editor.updateBlock(block, { - // Single empty block with default type. - children: [{}], - }); - editor.setTextCursorPosition(updatedBlock.children[0].id, "end"); - editor.focus(); - }); - }; - toggleAddBlockButton.addEventListener("click", toggleAddBlockButtonOnClick); - - dom.appendChild(toggleWrapper); - - let childCount = block.children.length; - const onEditorChange = editor.onChange(() => { - const newChildCount = editor.getBlock(block)?.children.length ?? 0; - - if (newChildCount > childCount) { - // If a child block is added while children are hidden, show children. - if (toggleWrapper.getAttribute("data-show-children") === "false") { - toggleWrapper.setAttribute("data-show-children", "true"); - const currentBlock = editor.getBlock(block); - if (currentBlock) { - toggledState.set(currentBlock, true); - } - } - - // Remove the "add block" button as we want to show child blocks and - // there is at least one child block. - if (dom.contains(toggleAddBlockButton)) { - dom.removeChild(toggleAddBlockButton); - } - } else if (newChildCount === 0 && newChildCount < childCount) { - // If the last child block is removed while children are shown, hide - // children. - if (toggleWrapper.getAttribute("data-show-children") === "true") { - toggleWrapper.setAttribute("data-show-children", "false"); - const currentBlock = editor.getBlock(block); - if (currentBlock) { - toggledState.set(currentBlock, false); - } - } - - // Remove the "add block" button as we want to hide child blocks, - // regardless of whether there are child blocks or not. - if (dom.contains(toggleAddBlockButton)) { - dom.removeChild(toggleAddBlockButton); - } - } - - childCount = newChildCount; - }); - - if (toggledState.get(block)) { - toggleWrapper.setAttribute("data-show-children", "true"); - - if (editor.isEditable && block.children.length === 0) { - // If the toggle is set to show children, but there are no children, - // we add the "add block" button. - dom.appendChild(toggleAddBlockButton); - } - } else { - toggleWrapper.setAttribute("data-show-children", "false"); - } - - return { - dom, - // Prevents re-renders when the toggle button is clicked. - ignoreMutation: (mutation) => { - if ( - mutation instanceof MutationRecord && - // We want to prevent re-renders when the view changes, so we ignore - // all mutations where the `data-show-children` attribute is changed - // or the "add block" button is added/removed. - ((mutation.type === "attributes" && - mutation.target === toggleWrapper && - mutation.attributeName === "data-show-children") || - (mutation.type === "childList" && - (mutation.addedNodes[0] === toggleAddBlockButton || - mutation.removedNodes[0] === toggleAddBlockButton))) - ) { - return true; - } - return false; - }, - destroy: () => { - toggleButton.removeEventListener("mousedown", toggleButtonMouseDown); - toggleButton.removeEventListener("click", toggleButtonOnClick); - toggleAddBlockButton.removeEventListener( - "mousedown", - toggleAddBlockButtonMouseDown, - ); - toggleAddBlockButton.removeEventListener( - "click", - toggleAddBlockButtonOnClick, - ); - onEditorChange?.(); - }, - }; -}; diff --git a/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts new file mode 100644 index 0000000000..b8d932d189 --- /dev/null +++ b/packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts @@ -0,0 +1,1056 @@ +import { Fragment, Slice } from "prosemirror-model"; +import { TextSelection } from "prosemirror-state"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "../../style.css"; +import { getNodeById } from "../../api/nodeUtil.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { getDefaultSlashMenuItems } from "../../extensions/SuggestionMenu/getDefaultSlashMenuItems.js"; +import type { PartialBlock } from "../defaultBlocks.js"; + +// Behaviour of the built-in toggle blocks (toggle heading and toggle list +// item), including regressions reported under BLO-1018. Every test runs for +// both blocks. The expected behaviour is Notion's (compared on 2026-09-29), +// with one deliberate exception: a toggle heading turned into a regular +// heading keeps its children nested, where Notion moves them out. Tests for +// behaviour BlockNote does not match yet use `it.fails`: they state the +// expected behaviour, so they start failing - and must be switched to `it` - +// once it is implemented. + +const MOD = navigator.platform.includes("Mac") ? "Meta" : "Control"; + +const kinds = [ + { + name: "toggle heading", + toggle: ( + id: string, + content: string, + children: PartialBlock[] = [], + ): PartialBlock => ({ + id, + type: "heading", + props: { level: 2, isToggleable: true }, + content, + children, + }), + newBlockTypeAfterClosedToggle: "paragraph", + }, + { + name: "toggle list item", + toggle: ( + id: string, + content: string, + children: PartialBlock[] = [], + ): PartialBlock => ({ + id, + type: "toggleListItem", + content, + children, + }), + newBlockTypeAfterClosedToggle: "toggleListItem", + }, +]; + +let editor: BlockNoteEditor; +let root: HTMLElement; + +function mount(content: PartialBlock[], options = { editable: true }) { + root = document.createElement("div"); + document.body.appendChild(root); + editor = BlockNoteEditor.create({ initialContent: content }); + editor.isEditable = options.editable; + editor.mount(root); +} + +/** Replaces the editor with a new one, as a page reload would. */ +function remount(content: PartialBlock[], options = { editable: true }) { + editor._tiptapEditor.destroy(); + root.remove(); + mount(content, options); +} + +beforeEach(() => { + // The open state of a toggle is kept in `localStorage`, keyed by block id. + localStorage.clear(); +}); + +afterEach(() => { + editor._tiptapEditor.destroy(); + root.remove(); +}); + +/** The block's type, text and children, e.g. `heading"Title"[paragraph"One"]`. */ +function shape(blocks = editor.document): string { + return blocks + .map((block) => { + const text = Array.isArray(block.content) + ? block.content.map((c) => ("text" in c ? c.text : "")).join("") + : ""; + const children = block.children.length + ? `[${shape(block.children)}]` + : ""; + return `${block.type}"${text}"${children}`; + }) + .join(", "); +} + +function blockElement(id: string) { + const element = root.querySelector(`.bn-block[data-id="${id}"]`); + if (!element) { + throw new Error(`Block "${id}" is not rendered`); + } + return element as HTMLElement; +} + +/** + * The first element matching `selector` that belongs to the block itself, + * not to one of its children. Where it sits inside the block is left open. + */ +function own(id: string, selector: string) { + const block = blockElement(id); + return ( + [...block.querySelectorAll(selector)].find( + (element) => element.closest(".bn-block") === block, + ) ?? null + ); +} + +function toggleButton(id: string) { + return own(id, ".bn-toggle-button"); +} + +function addBlockButton(id: string) { + return own(id, ".bn-toggle-add-block-button"); +} + +/** Whether the toggle is open, as its chevron announces it. */ +function isOpen(id: string) { + const button = toggleButton(id); + if (!button) { + throw new Error(`Block "${id}" is not a toggle`); + } + return button.getAttribute("aria-expanded") === "true"; +} + +function childrenAreVisible(id: string) { + const group = own(id, ".bn-block-group"); + if (!group) { + throw new Error(`Block "${id}" has no children`); + } + return getComputedStyle(group).display !== "none"; +} + +async function open(id: string) { + await userEvent.click(toggleButton(id)!); +} + +/** + * Drags block `id` over the center of `target`, as the side menu starts a + * block drag. The events are synthetic: an emulated mouse drag does not reach + * every target reliably. `drop()` drops the block there. + */ +function dragBlockOver(id: string, target: Element) { + const view = editor.prosemirrorView; + const { node } = getNodeById(id, view.state.doc)!; + view.dragging = { slice: new Slice(Fragment.from(node), 0, 0), move: true }; + // The side menu also puts the blocks in the drag data, which marks the drop + // as a block drop for the editor's drop handlers. + const dataTransfer = new DataTransfer(); + dataTransfer.setData("blocknote/html", ""); + const rect = target.getBoundingClientRect(); + const init: DragEventInit = { + bubbles: true, + cancelable: true, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + dataTransfer, + }; + const element = document.elementFromPoint(init.clientX!, init.clientY!)!; + element.dispatchEvent(new DragEvent("dragenter", init)); + element.dispatchEvent(new DragEvent("dragover", init)); + return { + // At the element under the point at drop time, as the browser does. + drop: () => + document + .elementFromPoint(init.clientX!, init.clientY!)! + .dispatchEvent(new DragEvent("drop", init)), + }; +} + +function dropCursor() { + return document.querySelector('[class*="prosemirror-dropcursor"]'); +} + +/** The id of the block highlighted as the one a drop goes into. */ +function highlightedDropTarget() { + return ( + root + .querySelector('[data-drop-target="true"] > .bn-block') + ?.getAttribute("data-id") ?? null + ); +} + +/** Puts the caret in the block and presses the keys. */ +async function press( + keys: string, + at: { block: string; placement: "start" | "end" }, +) { + editor.setTextCursorPosition(at.block, at.placement); + editor.focus(); + await userEvent.keyboard(keys); +} + +/** + * Puts the caret after the first `offset` characters of the block's text. + * BlockNote's API can only put the caret at the start or end of a block. + */ +function setCaretAt(id: string, offset: number) { + editor.transact((tr) => { + const block = getNodeById(id, tr.doc)!; + // Inside the block, then inside its content. + const textStart = block.posBeforeNode + 2; + tr.setSelection(TextSelection.create(tr.doc, textStart + offset)); + }); + editor.focus(); +} + +describe.each(kinds)("$name", ({ toggle, newBlockTypeAfterClosedToggle }) => { + const withChildren = (): PartialBlock[] => [ + toggle("t", "Title", [ + { id: "c1", type: "paragraph", content: "One" }, + { id: "c2", type: "paragraph", content: "Two" }, + ]), + { id: "after", type: "paragraph", content: "After" }, + ]; + + describe("open state", () => { + it("starts closed, with its children hidden", () => { + mount(withChildren()); + + expect(isOpen("t")).toBe(false); + expect(childrenAreVisible("t")).toBe(false); + }); + + it("shows and hides its children with the chevron, without changing the document", async () => { + mount(withChildren()); + const document = JSON.stringify(editor.document); + + await open("t"); + expect(childrenAreVisible("t")).toBe(true); + + await open("t"); + expect(childrenAreVisible("t")).toBe(false); + expect(JSON.stringify(editor.document)).toBe(document); + }); + + // #2811: a screen reader must find the chevron and hear its state. + it("names the chevron and exposes the open state to assistive technology", async () => { + mount(withChildren()); + const button = page.getByRole("button", { + name: "Expand or collapse", + expanded: false, + }); + await expect.element(button).toBeInTheDocument(); + expect(toggleButton("t")!.querySelector("svg")!.ariaHidden).toBe("true"); + + await open("t"); + await expect + .element( + page.getByRole("button", { + name: "Expand or collapse", + expanded: true, + }), + ) + .toBeInTheDocument(); + }); + + it("keeps the open state for the block when the editor is recreated", async () => { + mount(withChildren()); + await open("t"); + + remount(withChildren()); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + + await open("t"); + remount(withChildren()); + expect(isOpen("t")).toBe(false); + }); + + it("keeps the caret where it is when the chevron is clicked", async () => { + mount(withChildren()); + editor.setTextCursorPosition("after", "end"); + editor.focus(); + + await open("t"); + + expect(editor.getTextCursorPosition().block.id).toBe("after"); + expect(editor.isFocused()).toBe(true); + }); + + it("does not re-render the block when it is opened or closed", async () => { + mount(withChildren()); + const content = own("t", ".bn-block-content")!; + + await open("t"); + await open("t"); + + expect(content.isConnected).toBe(true); + }); + + it("opens when a block is indented into it", async () => { + mount(withChildren()); + + await press("{Tab}", { block: "after", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + "after", + ]); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + + // The toggle opened itself, and that is kept like a click. + remount(editor.document); + expect(isOpen("t")).toBe(true); + }); + + // Notion keeps the toggle open, showing its empty-toggle placeholder. + it("stays open, as an empty toggle, when its last child is removed", async () => { + mount([toggle("t", "Title", [{ id: "c1", type: "paragraph" }])]); + await open("t"); + + editor.removeBlocks(["c1"]); + + expect(editor.getBlock("t")!.children).toHaveLength(0); + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).not.toBeNull(); + }); + + it("stays open when one of several children is removed", async () => { + mount(withChildren()); + await open("t"); + + editor.removeBlocks(["c1"]); + + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + }); + }); + + describe("layout", () => { + // The children span the frame below the title. When they fell into the + // chevron's grid column instead, they widened it and pushed the title far + // to the right. + it("keeps the title next to the chevron and the children below it", async () => { + mount(withChildren()); + await open("t"); + + const frame = own("t", ".bn-toggle-frame")!.getBoundingClientRect(); + const title = own("t", ".bn-inline-content")!.getBoundingClientRect(); + const group = own("t", ".bn-block-group")!.getBoundingClientRect(); + expect(title.left - frame.left).toBeLessThan(40); + expect(group.top).toBeGreaterThanOrEqual(title.bottom - 1); + expect(group.left).toBeLessThanOrEqual(title.left); + }); + }); + + describe("empty toggle", () => { + it('shows an "Add block" button when open, which adds a child and puts the caret in it', async () => { + mount([toggle("t", "Title")]); + expect(addBlockButton("t")).toBeNull(); + + await open("t"); + await userEvent.click(addBlockButton("t")!); + + const child = editor.getBlock("t")!.children; + expect(child).toHaveLength(1); + expect(editor.getTextCursorPosition().block.id).toBe(child[0].id); + expect(addBlockButton("t")).toBeNull(); + }); + + it('removes the "Add block" button when closed', async () => { + mount([toggle("t", "Title")]); + await open("t"); + + await open("t"); + + expect(addBlockButton("t")).toBeNull(); + }); + + it('replaces the "Add block" button when a block is indented into it', async () => { + mount([ + toggle("t", "Title"), + { id: "after", type: "paragraph", content: "After" }, + ]); + await open("t"); + + await press("{Tab}", { block: "after", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + ]); + expect(addBlockButton("t")).toBeNull(); + expect(childrenAreVisible("t")).toBe(true); + }); + + it('shows the "Add block" button when it was left open and is recreated', async () => { + mount([toggle("t", "Title")]); + await open("t"); + + remount([toggle("t", "Title")]); + + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).not.toBeNull(); + }); + + it('shows no "Add block" button in a read-only editor', async () => { + mount([toggle("t", "Title")]); + editor.isEditable = false; + + await open("t"); + + expect(addBlockButton("t")).toBeNull(); + }); + + it('shows no "Add block" button when a read-only editor opens it from its saved state', async () => { + mount([toggle("t", "Title")]); + await open("t"); + + remount([toggle("t", "Title")], { editable: false }); + + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).toBeNull(); + }); + + // BLO-956 (comment): in an open, empty toggle, the caret could not move + // down out of the title. + it("ArrowDown moves the caret to the next block (BLO-956)", async () => { + mount([ + toggle("t", "Title"), + { id: "after", type: "paragraph", content: "After" }, + ]); + await open("t"); + + await press("{ArrowDown}", { block: "t", placement: "end" }); + + expect(editor.getTextCursorPosition().block.id).toBe("after"); + }); + }); + + describe("Enter", () => { + // BLO-929: Enter at the end of an open toggle's title should start the + // toggle's body, as in Notion, not a new block after the toggle. + it("at the end of an open toggle's title adds a first child (BLO-929)", async () => { + mount(withChildren()); + await open("t"); + + await press("{Enter}", { block: "t", placement: "end" }); + + const toggleBlock = editor.getBlock("t")!; + expect(toggleBlock.children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe( + toggleBlock.children[0].id, + ); + }); + + // BLO-998 / #2020: on a closed toggle, the new block after it must not + // take the toggle's children. + it("at the end of a closed toggle's title keeps the children in the toggle (BLO-998)", async () => { + mount(withChildren()); + + await press("{Enter}", { block: "t", placement: "end" }); + + const [first, second] = editor.document; + expect(first.id).toBe("t"); + expect(first.children.map((child) => child.id)).toEqual(["c1", "c2"]); + expect(second.children).toHaveLength(0); + // A toggle list continues as a list; a heading continues with text. + expect(second.type).toBe(newBlockTypeAfterClosedToggle); + expect(editor.getTextCursorPosition().block.id).toBe(second.id); + }); + + // BLO-949: Enter in the title must not break the children apart. As in + // Notion, the text after the caret becomes the toggle's first child. + it("in the middle of an open toggle's title moves the rest of the title into a first child (BLO-949)", async () => { + mount(withChildren()); + await open("t"); + + setCaretAt("t", 2); + await userEvent.keyboard("{Enter}"); + + expect(shape([editor.getBlock("t")!])).toMatch( + /^\w+"Ti"\[paragraph"tle", paragraph"One", paragraph"Two"\]$/, + ); + expect(editor.getTextCursorPosition().block.id).toBe( + editor.getBlock("t")!.children[0].id, + ); + }); + + // On a closed toggle, Enter splits the title as for any block: the rest + // goes into a new block after the toggle, which continues the list for a + // toggle list item, and the children stay with the toggle. + it("in the middle of a closed toggle's title moves the rest into a new block after it", async () => { + mount(withChildren()); + + setCaretAt("t", 2); + await userEvent.keyboard("{Enter}"); + + const [first, second] = editor.document; + expect(shape([first])).toMatch( + /^\w+"Ti"\[paragraph"One", paragraph"Two"\]$/, + ); + expect(second.type).toBe(newBlockTypeAfterClosedToggle); + expect(shape([second])).toMatch(/"tle"$/); + }); + + // As in Notion, Enter on an empty last child stays inside the toggle. + it("on an empty last child adds another child", async () => { + mount([ + toggle("t", "Title", [ + { id: "c1", type: "paragraph", content: "One" }, + { id: "c2", type: "paragraph" }, + ]), + ]); + await open("t"); + + await press("{Enter}", { block: "c2", placement: "start" }); + + const children = editor.getBlock("t")!.children; + expect(children.map((child) => child.id).slice(0, 2)).toEqual([ + "c1", + "c2", + ]); + expect(children).toHaveLength(3); + expect(editor.getTextCursorPosition().block.id).toBe(children[2].id); + }); + + // BLO-949: Enter in a child continues inside the toggle. + it("at the end of a child adds a sibling child (BLO-949)", async () => { + mount(withChildren()); + await open("t"); + + await press("{Enter}", { block: "c1", placement: "end" }); + + const children = editor.getBlock("t")!.children; + expect(children).toHaveLength(3); + expect(children[0].id).toBe("c1"); + expect(editor.getTextCursorPosition().block.id).toBe(children[1].id); + }); + }); + + it("turned into a paragraph shows its children and no chevron", () => { + mount(withChildren()); + + editor.updateBlock("t", { type: "paragraph", props: {} }); + + expect(toggleButton("t")).toBeNull(); + expect(childrenAreVisible("t")).toBe(true); + }); + + describe("Backspace", () => { + // As in Notion, Backspace at the start of the first child merges it into + // the title. + it("at the start of the first child merges it into the title", async () => { + mount(withChildren()); + await open("t"); + + await press("{Backspace}", { block: "c1", placement: "start" }); + + expect(shape([editor.getBlock("t")!])).toMatch( + /^\w+"TitleOne"\[paragraph"Two"\]$/, + ); + expect(editor.document.map((block) => block.id)).toEqual(["t", "after"]); + }); + }); + + // Shift-Mod-ArrowUp/Down, BlockNote's shortcuts for moving blocks. + describe("moving blocks", () => { + const moveUp = `{${MOD}>}{Shift>}{ArrowUp}{/Shift}{/${MOD}}`; + const moveDown = `{${MOD}>}{Shift>}{ArrowDown}{/Shift}{/${MOD}}`; + + it("opens a closed toggle when a block is moved into it", async () => { + mount(withChildren()); + + await press(moveUp, { block: "after", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + "after", + ]); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + }); + + it("keeps a toggle open when its last child is moved out", async () => { + mount([ + toggle("t", "Title", [{ id: "c1", type: "paragraph", content: "One" }]), + ]); + await open("t"); + + await press(moveDown, { block: "c1", placement: "start" }); + + expect(editor.document.map((block) => block.id)).toEqual(["t", "c1"]); + expect(isOpen("t")).toBe(true); + expect(addBlockButton("t")).not.toBeNull(); + }); + + it("keeps an open toggle open, with its children, when it is moved", async () => { + mount([ + { id: "before", type: "paragraph", content: "Before" }, + ...withChildren(), + ]); + await open("t"); + + await press(moveUp, { block: "t", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "t", + "before", + "after", + ]); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + ]); + expect(isOpen("t")).toBe(true); + expect(childrenAreVisible("t")).toBe(true); + }); + + it("keeps a closed toggle closed, with its children, when it is moved", async () => { + mount([ + { id: "before", type: "paragraph", content: "Before" }, + ...withChildren(), + ]); + + await press(moveUp, { block: "t", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "t", + "before", + "after", + ]); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + "c2", + ]); + expect(isOpen("t")).toBe(false); + expect(childrenAreVisible("t")).toBe(false); + }); + }); + + // A block dragged onto a toggle becomes its first child, as in Notion. For + // an empty toggle, this is the only way to drop a block into it (BLO-956). + describe("drop onto the toggle", () => { + it("onto the title: shows the place above the first child, and drops it there", async () => { + mount(withChildren()); + await open("t"); + + const drag = dragBlockOver("after", own("t", ".bn-inline-content")!); + + const cursor = dropCursor()!.getBoundingClientRect(); + expect( + Math.abs(cursor.top - blockElement("c1").getBoundingClientRect().top), + ).toBeLessThan(6); + expect(highlightedDropTarget()).toBe("t"); + + drag.drop(); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + "c1", + "c2", + ]); + await expect.poll(dropCursor).toBeNull(); + expect(highlightedDropTarget()).toBeNull(); + }); + + it("onto the chevron of a closed toggle: drops it as the first child, and opens the toggle", async () => { + mount(withChildren()); + + dragBlockOver("after", toggleButton("t")!).drop(); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + "c1", + "c2", + ]); + expect(isOpen("t")).toBe(true); + }); + + it("onto 'Add block' of an empty toggle: shows the place below the title, and drops it there (BLO-956)", async () => { + mount([ + toggle("t", "Title"), + { id: "after", type: "paragraph", content: "After" }, + ]); + await open("t"); + + const drag = dragBlockOver("after", addBlockButton("t")!); + + const cursor = dropCursor()!.getBoundingClientRect(); + const title = own("t", ".bn-block-content")!.getBoundingClientRect(); + expect(Math.abs(cursor.top - title.bottom)).toBeLessThan(6); + expect(highlightedDropTarget()).toBe("t"); + + drag.drop(); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "after", + ]); + }); + + it("drops it as usual when the toggle is removed during the drag", async () => { + mount(withChildren()); + await open("t"); + + const drag = dragBlockOver("after", own("t", ".bn-inline-content")!); + expect(highlightedDropTarget()).toBe("t"); + // E.g. a collaborator removes the toggle. + editor.removeBlocks(["t"]); + drag.drop(); + + expect(editor.getBlock("t")).toBeUndefined(); + expect(editor.getBlock("after")).toBeDefined(); + expect(highlightedDropTarget()).toBeNull(); + }); + + it("onto a child: drops it between the children, as usual", async () => { + mount(withChildren()); + await open("t"); + + const drag = dragBlockOver("after", own("c2", ".bn-inline-content")!); + expect(highlightedDropTarget()).toBeNull(); + drag.drop(); + + const children = editor.getBlock("t")!.children.map((child) => child.id); + expect(children).toHaveLength(3); + expect(children[0]).toBe("c1"); + }); + }); + + describe("indentation", () => { + it("Shift-Tab moves a child out of the toggle", async () => { + mount(withChildren()); + await open("t"); + + await press("{Shift>}{Tab}{/Shift}", { block: "c2", placement: "start" }); + + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "t", + "c2", + "after", + ]); + }); + }); +}); + +// As in Notion, Enter in an empty toggle title removes the toggle: a toggle +// list item becomes a paragraph, a toggle heading a regular heading. +describe("Enter in an empty toggle title", () => { + it("turns a toggle list item into a paragraph", async () => { + mount([ + { id: "t", type: "toggleListItem", content: "Title" }, + { id: "empty", type: "toggleListItem" }, + ]); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.type).toBe("paragraph"); + expect(editor.document).toHaveLength(2); + }); + + // The same when the toggle is open: an empty title never starts the + // toggle's children. + it("turns an open toggle list item into a paragraph", async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { id: "empty", type: "toggleListItem" }, + ]); + await userEvent.click(toggleButton("empty")!); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.type).toBe("paragraph"); + expect(editor.getBlock("empty")!.children).toHaveLength(0); + expect(editor.document).toHaveLength(2); + }); + + it("turns an open toggle heading into a regular heading", async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { id: "empty", type: "heading", props: { level: 2, isToggleable: true } }, + ]); + await userEvent.click(toggleButton("empty")!); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(editor.getBlock("empty")!.children).toHaveLength(0); + expect(editor.document).toHaveLength(2); + }); + + // As in Notion, the children stay nested under the reset block. + for (const state of ["closed", "open"] as const) { + it(`turns a toggle list item with children into a paragraph, keeping the children (${state})`, async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { + id: "empty", + type: "toggleListItem", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + if (state === "open") { + await userEvent.click(toggleButton("empty")!); + } + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(shape()).toBe('paragraph"Before", paragraph""[paragraph"One"]'); + }); + + it(`turns a toggle heading with children into a regular heading, keeping the children (${state})`, async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { + id: "empty", + type: "heading", + props: { level: 2, isToggleable: true }, + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + if (state === "open") { + await userEvent.click(toggleButton("empty")!); + } + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(shape()).toBe('paragraph"Before", heading""[paragraph"One"]'); + }); + } + + it("turns a nested toggle list item into a paragraph, which stays nested", async () => { + mount([ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "empty", type: "toggleListItem" }], + }, + ]); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.type).toBe("paragraph"); + expect(editor.getParentBlock("empty")!.id).toBe("parent"); + }); + + it("turns a toggle heading into a regular heading", async () => { + mount([ + { id: "t", type: "paragraph", content: "Before" }, + { id: "empty", type: "heading", props: { level: 2, isToggleable: true } }, + ]); + + await press("{Enter}", { block: "empty", placement: "start" }); + + expect(editor.getBlock("empty")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(editor.document).toHaveLength(2); + expect(toggleButton("empty")).toBeNull(); + }); +}); + +// As in Notion, Backspace at the start of a non-empty toggle heading turns it +// into a regular heading. The text and (unlike Notion) the children stay. +describe("Backspace at the start of a non-empty toggle title", () => { + it("turns a toggle heading into a regular heading", async () => { + mount([ + { + id: "t", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Title", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + + await press("{Backspace}", { block: "t", placement: "start" }); + + const block = editor.getBlock("t")!; + expect(block.type).toBe("heading"); + expect(block.props).toMatchObject({ level: 2, isToggleable: false }); + expect(shape([block])).toBe('heading"Title"[paragraph"One"]'); + }); + + // Not compared with Notion: current behaviour, as for the other lists. + it("turns a toggle list item into a paragraph", async () => { + mount([ + { + id: "t", + type: "toggleListItem", + content: "Title", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + + await press("{Backspace}", { block: "t", placement: "start" }); + + expect(shape([editor.getBlock("t")!])).toBe( + 'paragraph"Title"[paragraph"One"]', + ); + }); +}); + +// As in Notion, Enter at the start of a non-empty toggle list item inserts an +// empty toggle list item above it. The item keeps its text and children, open +// or closed, and the caret stays at its start. +describe("Enter at the start of a non-empty toggle list item", () => { + for (const state of ["closed", "open"] as const) { + it(`inserts an empty toggle list item above it (${state})`, async () => { + mount([ + { + id: "t", + type: "toggleListItem", + content: "Title", + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + if (state === "open") { + await userEvent.click(toggleButton("t")!); + } + + await press("{Enter}", { block: "t", placement: "start" }); + + expect(shape()).toBe( + 'toggleListItem"", toggleListItem"Title"[paragraph"One"]', + ); + const item = editor.document[1]; + expect(editor.getTextCursorPosition().block.id).toBe(item.id); + expect(isOpen(item.id)).toBe(state === "open"); + }); + } +}); + +// A toggle heading's frame puts its content one level deeper, so the heading +// rules in Block.css need their own selector for it. +describe("toggle heading appearance", () => { + it.each([1, 2, 3] as const)( + "has the size and weight of a regular heading at level %d", + (level) => { + mount([ + { id: "h", type: "heading", props: { level }, content: "Heading" }, + { + id: "t", + type: "heading", + props: { level, isToggleable: true }, + content: "Toggle", + }, + ]); + + const style = (id: string) => + getComputedStyle(own(id, ".bn-block-content")!); + expect(style("t").fontSize).toBe(style("h").fontSize); + expect(style("t").fontWeight).toBe(style("h").fontWeight); + }, + ); +}); + +// BLO-959: turning a toggle heading into a regular heading must remove the +// toggle behaviour. Each way of turning a block into a heading is covered. +// Unlike Notion, which moves the children out (its headings can't have +// children), the children stay nested under the heading. +describe("toggle heading turned into a regular heading (BLO-959)", () => { + const toggleHeading = () => [ + { + id: "t", + type: "heading" as const, + props: { level: 1 as const, isToggleable: true }, + content: "Title", + children: [{ id: "c1", type: "paragraph" as const, content: "One" }], + }, + ]; + + function expectRegularHeading() { + expect(editor.getBlock("t")!.props).toMatchObject({ + level: 2, + isToggleable: false, + }); + expect(toggleButton("t")).toBeNull(); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "c1", + ]); + expect(childrenAreVisible("t")).toBe(true); + } + + it("with the block type menu's props", () => { + mount(toggleHeading()); + + // What the formatting toolbar's block type select applies for "Heading 2". + editor.updateBlock("t", { + type: "heading", + props: { level: 2, isToggleable: false }, + }); + + expectRegularHeading(); + }); + + // Not compared with Notion: its Cmd-Option-2 could not be automated there. + it("with the heading keyboard shortcut", async () => { + mount(toggleHeading()); + + await press(`{${MOD}>}{Alt>}2{/Alt}{/${MOD}}`, { + block: "t", + placement: "end", + }); + + expectRegularHeading(); + }); + + it("with the markdown shortcut", async () => { + mount(toggleHeading()); + + await press("## ", { block: "t", placement: "start" }); + + expectRegularHeading(); + expect(editor.getBlock("t")!.content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + }); + + // The slash menu updates an empty block in place, as the block type menu + // does. Notion differs here: its "Heading 2" in an empty toggle heading + // keeps the toggle heading and inserts a regular heading after it. + it("with the slash menu's heading item in an empty toggle heading", () => { + mount([ + { + id: "t", + type: "heading", + props: { level: 1, isToggleable: true }, + children: [{ id: "c1", type: "paragraph", content: "One" }], + }, + ]); + editor.setTextCursorPosition("t", "end"); + + getDefaultSlashMenuItems(editor) + .find((item) => item.key === "heading_2")! + .onItemClick(); + + expectRegularHeading(); + expect(editor.getTextCursorPosition().block.id).toBe("t"); + }); +}); diff --git a/packages/core/src/blocks/defaultBlockTypeItems.test.ts b/packages/core/src/blocks/defaultBlockTypeItems.test.ts new file mode 100644 index 0000000000..97a4f23c93 --- /dev/null +++ b/packages/core/src/blocks/defaultBlockTypeItems.test.ts @@ -0,0 +1,70 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from "vite-plus/test"; + +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { BlockNoteSchema } from "./BlockNoteSchema.js"; +import { createHeadingBlockSpec } from "./Heading/block.js"; +import { defaultBlockSpecs } from "./defaultBlocks.js"; +import { getDefaultBlockTypeItems } from "./defaultBlockTypeItems.js"; + +/** The items for a schema whose heading is `heading`. */ +function items(heading = createHeadingBlockSpec()) { + const schema = BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, heading }, + }); + // Only the schema is read. + return getDefaultBlockTypeItems({ schema } as unknown as BlockNoteEditor); +} + +describe("getDefaultBlockTypeItems", () => { + it("offers every default block type, in the block type select's order", () => { + expect(items().map((item) => item.key)).toEqual([ + "paragraph", + "heading", + "heading_2", + "heading_3", + "heading_4", + "heading_5", + "heading_6", + "toggle_heading", + "toggle_heading_2", + "toggle_heading_3", + "quote", + "toggle_list", + "bullet_list", + "numbered_list", + "check_list", + ]); + }); + + it("makes a regular heading turn a toggle heading into a regular one (BLO-959)", () => { + expect(items().find((item) => item.key === "heading_2")?.props).toEqual({ + level: 2, + isToggleable: false, + }); + }); + + it("offers regular headings when toggle headings are disabled (BLO-1236)", () => { + const offered = items( + createHeadingBlockSpec({ allowToggleHeadings: false }), + ); + expect(offered.filter((item) => item.type === "heading")).toEqual([ + { key: "heading", type: "heading", props: { level: 1 } }, + { key: "heading_2", type: "heading", props: { level: 2 } }, + { key: "heading_3", type: "heading", props: { level: 3 } }, + { key: "heading_4", type: "heading", props: { level: 4 } }, + { key: "heading_5", type: "heading", props: { level: 5 } }, + { key: "heading_6", type: "heading", props: { level: 6 } }, + ]); + }); + + it("offers only the configured heading levels (BLO-990)", () => { + expect( + items(createHeadingBlockSpec({ levels: [2, 4] })) + .filter((item) => item.type === "heading") + .map((item) => item.key), + ).toEqual(["heading_2", "heading_4", "toggle_heading_2"]); + }); +}); diff --git a/packages/core/src/blocks/defaultBlockTypeItems.ts b/packages/core/src/blocks/defaultBlockTypeItems.ts new file mode 100644 index 0000000000..6d5e1b3996 --- /dev/null +++ b/packages/core/src/blocks/defaultBlockTypeItems.ts @@ -0,0 +1,99 @@ +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { editorHasBlockWithType } from "./defaultBlockTypeGuards.js"; + +const HEADING_KEYS = { + 1: "heading", + 2: "heading_2", + 3: "heading_3", + 4: "heading_4", + 5: "heading_5", + 6: "heading_6", +} as const; + +const TOGGLE_HEADING_KEYS = { + 1: "toggle_heading", + 2: "toggle_heading_2", + 3: "toggle_heading_3", +} as const; + +/** + * A block type that the default menus (the slash menu and the block type + * select) offer, with the props that choosing it sets. + */ +export type DefaultBlockTypeItem = { + /** The item's key in `dictionary.slash_menu`, which both menus share. */ + key: + | (typeof HEADING_KEYS)[keyof typeof HEADING_KEYS] + | (typeof TOGGLE_HEADING_KEYS)[keyof typeof TOGGLE_HEADING_KEYS] + | "paragraph" + | "quote" + | "toggle_list" + | "bullet_list" + | "numbered_list" + | "check_list"; + type: string; + props?: Record; +}; + +/** + * The block types that the editor's schema supports, out of the ones that the + * default menus offer: paragraph, headings, toggle headings, quote and lists. + * Each menu places them in its own order, by key; this list has the block type + * select's order. + * + * Headings are offered for the heading's configured `levels` (BLO-990). + * Toggle headings are offered only when the heading has `isToggleable`, and a + * regular heading then sets `isToggleable: false`, so that choosing it turns a + * toggle heading into a regular one (BLO-959, BLO-1236). + */ +export function getDefaultBlockTypeItems( + editor: BlockNoteEditor, +): DefaultBlockTypeItem[] { + const items: DefaultBlockTypeItem[] = []; + + if (editorHasBlockWithType(editor, "paragraph")) { + items.push({ key: "paragraph", type: "paragraph" }); + } + + if (editorHasBlockWithType(editor, "heading", { level: "number" })) { + const levels = editor.schema.blockSchema.heading.propSchema.level.values; + const hasToggles = editorHasBlockWithType(editor, "heading", { + level: "number", + isToggleable: "boolean", + }); + for (const level of [1, 2, 3, 4, 5, 6] as const) { + if (levels?.includes(level)) { + items.push({ + key: HEADING_KEYS[level], + type: "heading", + props: hasToggles ? { level, isToggleable: false } : { level }, + }); + } + } + if (hasToggles) { + for (const level of [1, 2, 3] as const) { + if (levels?.includes(level)) { + items.push({ + key: TOGGLE_HEADING_KEYS[level], + type: "heading", + props: { level, isToggleable: true }, + }); + } + } + } + } + + for (const [key, type] of [ + ["quote", "quote"], + ["toggle_list", "toggleListItem"], + ["bullet_list", "bulletListItem"], + ["numbered_list", "numberedListItem"], + ["check_list", "checkListItem"], + ] as const) { + if (editorHasBlockWithType(editor, type)) { + items.push({ key, type }); + } + } + + return items; +} diff --git a/packages/core/src/blocks/index.ts b/packages/core/src/blocks/index.ts index 76fc76d8a7..72cfa6e7a5 100644 --- a/packages/core/src/blocks/index.ts +++ b/packages/core/src/blocks/index.ts @@ -19,11 +19,12 @@ export { EMPTY_CELL_HEIGHT, EMPTY_CELL_WIDTH } from "./Table/TableExtension.js"; export * from "./Code/helpers/parse/parsePreCode.js"; export * from "./Code/helpers/render/createCodeBlock.js"; export * from "./Code/helpers/toExternalHTML/createPreCode.js"; -export * from "./ToggleWrapper/createToggleWrapper.js"; +export * from "./ToggleWrapper/createToggleFrame.js"; export * from "./PageBreak/getPageBreakSlashMenuItems.js"; export * from "./BlockNoteSchema.js"; export * from "./defaultBlockHelpers.js"; export * from "./defaultBlocks.js"; export * from "./defaultBlockTypeGuards.js"; +export * from "./defaultBlockTypeItems.js"; export * from "./defaultProps.js"; diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts deleted file mode 100644 index 6008c1a023..0000000000 --- a/packages/core/src/blocks/utils/listItemEnterHandler.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { splitBlockTr } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js"; -import { updateBlockTr } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js"; -import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; -import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; - -export const handleEnter = ( - editor: BlockNoteEditor, - listItemType: string, -) => { - const { blockInfo, selectionEmpty } = editor.transact((tr) => { - return { - blockInfo: getBlockInfoFromSelection(tr), - selectionEmpty: tr.selection.anchor === tr.selection.head, - }; - }); - - if (!blockInfo.hasContent) { - return false; - } - const { block: blockContainer, content } = blockInfo; - - if (!(content.node.type.name === listItemType) || !selectionEmpty) { - return false; - } - - if (blockInfo.isContentEmpty) { - editor.transact((tr) => { - updateBlockTr(tr, blockContainer.beforePos, { - type: "paragraph", - props: {}, - }); - }); - return true; - } else if (content.node.childCount > 0) { - return editor.transact((tr) => { - tr.deleteSelection(); - tr.scrollIntoView(); - return splitBlockTr(tr, tr.selection.from, true); - }); - } - - return false; -}; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index d18a3ffb76..d12187f19a 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -25,7 +25,9 @@ BASIC STYLES .ProseMirror-selectednode > .bn-block-content > *, /* Case for blocks/inline content where class is set manually */ .bn-block-content .ProseMirror-selectednode, -.bn-inline-content .ProseMirror-selectednode { +.bn-inline-content .ProseMirror-selectednode, +/* The block a dragged block drops into (set by the drop cursor) */ +.bn-block-outer[data-drop-target="true"] { /* Anchor for the `::after` highlight overlay below. */ position: relative; } @@ -35,7 +37,8 @@ BASIC STYLES .bn-block-content.ProseMirror-selectednode > *::after, .ProseMirror-selectednode > .bn-block-content > *::after, .bn-block-content .ProseMirror-selectednode::after, -.bn-inline-content .ProseMirror-selectednode::after { +.bn-inline-content .ProseMirror-selectednode::after, +.bn-block-outer[data-drop-target="true"]::after { content: ""; position: absolute; inset: 0; @@ -194,7 +197,12 @@ NESTED BLOCKS --prev-level: 0.8em; } -.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content { +.bn-block-outer[data-prev-type="heading"] > .bn-block > .bn-block-content, +.bn-block-outer[data-prev-type="heading"] + > .bn-block + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content { font-size: var(--prev-level); font-weight: bold; } @@ -202,6 +210,12 @@ NESTED BLOCKS .bn-block-outer:not([data-prev-type]) > .bn-block > .bn-block-content[data-content-type="heading"], +/* A toggle heading, inside its toggle frame. */ +.bn-block-outer:not([data-prev-type]) + > .bn-block + > .bn-toggle-frame + > .bn-toggle-slot + > .bn-block-content[data-content-type="heading"], .bn-block-outer:not([data-prev-type]) > .bn-block > div[data-type="modification"] @@ -312,25 +326,6 @@ NESTED BLOCKS } /* Toggle */ -.bn-block:has( - > .bn-block-content > div > .bn-toggle-wrapper[data-show-children="false"] - ) - > .bn-block-group, -.bn-block:has( - > .react-renderer - > .bn-block-content - > div - > .bn-toggle-wrapper[data-show-children="false"] - ) - > .bn-block-group { - display: none; -} - -.bn-toggle-wrapper { - display: flex; - align-items: center; -} - .bn-toggle-button { color: var(--bn-colors-editor-text); padding: 3px; @@ -341,10 +336,47 @@ NESTED BLOCKS height: 18px; } -.bn-toggle-wrapper[data-show-children="true"] .bn-toggle-button { +.bn-toggle-button[aria-expanded="true"] { transform: rotate(90deg); } +/* The frame of the built-in toggle blocks (see `createToggleFrame`): the + chevron, then the block's content, and the children below, across both + columns. The slot is `display: contents`, so the content and the child + group BlockNote mounts in it are items of this grid. */ +.bn-toggle-frame { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; +} + +.bn-toggle-slot { + display: contents; +} + +.bn-toggle-slot > .bn-block-group, +.bn-toggle-frame > .bn-toggle-add-block-button { + grid-column: 1 / -1; +} + +.bn-toggle-frame:has(> .bn-toggle-button[aria-expanded="false"]) + > .bn-toggle-slot + > .bn-block-group { + display: none; +} + +/* A heading's top spacing goes on the frame, so the chevron stays centred on + the heading text. */ +.bn-toggle-frame:has( + > .bn-toggle-slot > .bn-block-content[data-content-type="heading"] +) { + padding-top: 18px; +} + +.bn-toggle-slot > .bn-block-content[data-content-type="heading"] { + padding-top: 0; +} + .bn-toggle-add-block-button { font-size: 16px; color: var(--bn-colors-side-menu); @@ -875,112 +907,98 @@ NESTED BLOCKS } /* TEXT COLORS */ +/* A block's color also applies to its children: the block's element carries + its colors too (see `BlockContainer`), and they inherit or paint from there. + Its content can sit inside a frame at any depth, so no selector here walks + down to it. */ [data-style-type="textColor"][data-value="gray"], -[data-text-color="gray"], -.bn-block:has(> .bn-block-content[data-text-color="gray"]) { +[data-text-color="gray"] { color: #9b9a97; } [data-style-type="textColor"][data-value="brown"], -[data-text-color="brown"], -.bn-block:has(> .bn-block-content[data-text-color="brown"]) { +[data-text-color="brown"] { color: #64473a; } [data-style-type="textColor"][data-value="red"], -[data-text-color="red"], -.bn-block:has(> .bn-block-content[data-text-color="red"]) { +[data-text-color="red"] { color: #e03e3e; } [data-style-type="textColor"][data-value="orange"], -[data-text-color="orange"], -.bn-block:has(> .bn-block-content[data-text-color="orange"]) { +[data-text-color="orange"] { color: #d9730d; } [data-style-type="textColor"][data-value="yellow"], -[data-text-color="yellow"], -.bn-block:has(> .bn-block-content[data-text-color="yellow"]) { +[data-text-color="yellow"] { color: #dfab01; } [data-style-type="textColor"][data-value="green"], -[data-text-color="green"], -.bn-block:has(> .bn-block-content[data-text-color="green"]) { +[data-text-color="green"] { color: #4d6461; } [data-style-type="textColor"][data-value="blue"], -[data-text-color="blue"], -.bn-block:has(> .bn-block-content[data-text-color="blue"]) { +[data-text-color="blue"] { color: #0b6e99; } [data-style-type="textColor"][data-value="purple"], -[data-text-color="purple"], -.bn-block:has(> .bn-block-content[data-text-color="purple"]) { +[data-text-color="purple"] { color: #6940a5; } [data-style-type="textColor"][data-value="pink"], -[data-text-color="pink"], -.bn-block:has(> .bn-block-content[data-text-color="pink"]) { +[data-text-color="pink"] { color: #ad1a72; } /* BACKGROUND COLORS */ [data-style-type="backgroundColor"][data-value="gray"], -[data-background-color="gray"], -.bn-block:has(> .bn-block-content[data-background-color="gray"]) { +[data-background-color="gray"] { background-color: #ebeced; } [data-style-type="backgroundColor"][data-value="brown"], -[data-background-color="brown"], -.bn-block:has(> .bn-block-content[data-background-color="brown"]) { +[data-background-color="brown"] { background-color: #e9e5e3; } [data-style-type="backgroundColor"][data-value="red"], -[data-background-color="red"], -.bn-block:has(> .bn-block-content[data-background-color="red"]) { +[data-background-color="red"] { background-color: #fbe4e4; } [data-style-type="backgroundColor"][data-value="orange"], -[data-background-color="orange"], -.bn-block:has(> .bn-block-content[data-background-color="orange"]) { +[data-background-color="orange"] { background-color: #f6e9d9; } [data-style-type="backgroundColor"][data-value="yellow"], -[data-background-color="yellow"], -.bn-block:has(> .bn-block-content[data-background-color="yellow"]) { +[data-background-color="yellow"] { background-color: #fbf3db; } [data-style-type="backgroundColor"][data-value="green"], -[data-background-color="green"], -.bn-block:has(> .bn-block-content[data-background-color="green"]) { +[data-background-color="green"] { background-color: #ddedea; } [data-style-type="backgroundColor"][data-value="blue"], -[data-background-color="blue"], -.bn-block:has(> .bn-block-content[data-background-color="blue"]) { +[data-background-color="blue"] { background-color: #ddebf1; } [data-style-type="backgroundColor"][data-value="purple"], -[data-background-color="purple"], -.bn-block:has(> .bn-block-content[data-background-color="purple"]) { +[data-background-color="purple"] { background-color: #eae4f2; } [data-style-type="backgroundColor"][data-value="pink"], -[data-background-color="pink"], -.bn-block:has(> .bn-block-content[data-background-color="pink"]) { +[data-background-color="pink"] { background-color: #f4dfeb; } diff --git a/packages/core/src/editor/blockColors.browser.test.ts b/packages/core/src/editor/blockColors.browser.test.ts new file mode 100644 index 0000000000..dcc0be6dcd --- /dev/null +++ b/packages/core/src/editor/blockColors.browser.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import "../style.css"; +import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js"; +import { defaultToggledState } from "../blocks/ToggleWrapper/createToggleFrame.js"; +import { defaultProps } from "../blocks/defaultProps.js"; +import { createBlockSpec } from "../schema/blocks/createSpec.js"; +import { BlockNoteEditor } from "./BlockNoteEditor.js"; + +// A block's text and background color also apply to its child blocks. The +// block's content may sit inside a frame (`renderFrame`), at any depth. + +const RED = "rgb(224, 62, 62)"; +const GREEN = "rgb(77, 100, 97)"; +const BLUE_BACKGROUND = "rgb(221, 235, 241)"; + +// A custom block whose frame puts the content two levels down. +const framedNote = createBlockSpec( + { type: "framedNote", propSchema: { ...defaultProps }, content: "inline" }, + { + render() { + const dom = document.createElement("p"); + return { dom, contentDOM: dom }; + }, + renderFrame() { + const dom = document.createElement("section"); + const inner = document.createElement("div"); + const slot = document.createElement("div"); + inner.append(slot); + dom.append(inner); + return { dom, slot }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { framedNote }, +}); + +let editor: BlockNoteEditor | undefined; +let root: HTMLElement | undefined; + +afterEach(() => { + editor?._tiptapEditor.destroy(); + root?.remove(); + editor = undefined; + root = undefined; +}); + +function mount(type: string) { + // Toggles hide their children until opened. + defaultToggledState.set({ id: "parent" }, true); + root = document.createElement("div"); + document.body.appendChild(root); + editor = BlockNoteEditor.create({ + schema, + initialContent: [ + { + id: "parent", + type, + props: { textColor: "red", backgroundColor: "blue" }, + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + }); + editor.mount(root); + return root; +} + +function childContent(root: HTMLElement) { + const child = root.querySelector( + '[data-id="child"] .bn-inline-content', + ); + if (!child) { + throw new Error("child block not rendered"); + } + return child; +} + +/** The background painted behind the child: the nearest one set on it or an ancestor. */ +function backgroundBehind(element: HTMLElement) { + for (let el: Element | null = element; el; el = el.parentElement) { + const background = getComputedStyle(el).backgroundColor; + if (background !== "rgba(0, 0, 0, 0)") { + return background; + } + } + return undefined; +} + +describe.each(["paragraph", "toggleListItem", "framedNote"])( + "a %s's colors", + (type) => { + it("apply to its child blocks", () => { + const root = mount(type); + + expect(getComputedStyle(childContent(root)).color).toBe(RED); + expect(backgroundBehind(childContent(root))).toBe(BLUE_BACKGROUND); + }); + + it("follow changes to the block's props", () => { + const root = mount(type); + + editor!.updateBlock("parent", { + props: { textColor: "green", backgroundColor: "default" }, + }); + + expect(getComputedStyle(childContent(root)).color).toBe(GREEN); + expect(backgroundBehind(childContent(root))).not.toBe(BLUE_BACKGROUND); + }); + + it("apply to its child blocks in the internal HTML", () => { + mount(type); + const html = document.createElement("div"); + html.className = "bn-default-styles"; + html.innerHTML = editor!.blocksToFullHTML(editor!.document); + document.body.appendChild(html); + try { + expect(getComputedStyle(childContent(html)).color).toBe(RED); + expect(backgroundBehind(childContent(html))).toBe(BLUE_BACKGROUND); + } finally { + html.remove(); + } + }); + }, +); diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index 5b16ec0fcc..dbae8b1281 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -1,5 +1,5 @@ +import type { BlockPlacement } from "../../api/getBlockInfoFromPos.js"; import { insertBlocks } from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; -import { BlockPlacement } from "../../api/getBlockInfoFromPos.js"; import { moveBlocksDown, moveBlocksUp, diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 853cca2493..da004dad89 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -62,7 +62,13 @@ 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", + // Only layout blocks own their ID; titled blocks use blockContainer. + ...Object.values(editor.schema.blockSpecs) + .filter((spec) => spec.config.layoutOnly === true) + .map((spec) => spec.config.type), + ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, }), diff --git a/packages/core/src/exporter/Exporter.test.ts b/packages/core/src/exporter/Exporter.test.ts index 98e7b34f1c..60f04768a4 100644 --- a/packages/core/src/exporter/Exporter.test.ts +++ b/packages/core/src/exporter/Exporter.test.ts @@ -83,7 +83,12 @@ describe("Exporter mapping typing", () => { ...defaultBlockSpecs, extraBlock: createBlockSpec( { content: "none", type: "extraBlock", propSchema: {} }, - {} as any, + { + render: () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }, + } as any, )(), }, }); @@ -184,3 +189,67 @@ describe("Exporter missing mappings", () => { ).toThrow('missing a style mapping for style "bold"'); }); }); + +// A layout block (`layoutOnly: true`), and an exporter +// whose mappings return strings. +const box = createBlockSpec( + { + type: "box", + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { + renderFrame: () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + }, +)(); + +class StringExporter extends Exporter { + constructor(blockMapping: Record) { + super( + BlockNoteSchema.create().extend({ blockSpecs: { box } }), + { blockMapping, inlineContentMapping: {}, styleMapping: {} } as any, + { colors: COLORS_DEFAULT }, + ); + } + + public transformStyledText(_styledText: StyledText) { + return undefined; + } +} + +describe("Exporter child placement", () => { + it("passes a `{ withChildren }` mapping the block's rendered children", async () => { + const exporter = new StringExporter({ + box: { + withChildren: (_b: any, _e: any, _n: any, _i: any, c: string[]) => + `[${c.join(",")}]`, + }, + }); + + expect(exporter.placesChildren({ type: "box" })).toBe(true); + await expect( + exporter.mapBlock({ type: "box" } as any, 0, 0, ["a", "b"]), + ).resolves.toBe("[a,b]"); + }); + + it("throws when a layout block has a plain mapping", async () => { + const exporter = new StringExporter({ box: () => "box" }); + + expect(exporter.placesChildren({ type: "box" })).toBe(false); + await expect( + exporter.mapBlock({ type: "box" } as any, 0, 0, []), + ).rejects.toThrow("must be a `{ withChildren }` mapping"); + }); + + it("leaves the children of an unmapped or plainly mapped block to the exporter", () => { + // Block packages (math, diagram, ...) commonly supply only a mapping, + // which reads the block's JSON - their specs need not be in the schema. + expect( + new EmptyMappingsExporter().placesChildren({ type: "mathBlock" }), + ).toBe(false); + }); +}); diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index 6ca0efa222..07d8647c7c 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -3,7 +3,7 @@ import { COLORS_DEFAULT } from "../editor/defaultColors.js"; import type { Dictionary } from "../i18n/dictionary.js"; import { en } from "../i18n/locales/index.js"; import { - BlockFromConfig, + BlockNoDefaults, BlockSchema, InlineContent, InlineContentSchema, @@ -61,7 +61,7 @@ export abstract class Exporter< TS, > { public constructor( - _schema: BlockNoteSchema, // only used for type inference + protected readonly schema: BlockNoteSchema, protected readonly mappings: { blockMapping: BlockMapping; inlineContentMapping: InlineContentMapping; @@ -70,6 +70,15 @@ export abstract class Exporter< public readonly options: ExporterOptions, ) {} + /** + * Whether the block's mapping places its children itself (a `{ withChildren }` + * mapping). Otherwise the exporter places them after the block. + */ + public placesChildren(block: { type: string }): boolean { + const mapping = this.mappings.blockMapping[block.type]; + return typeof mapping === "object" && mapping !== null; + } + /** * The strings this exporter renders into the produced document - the * `exporter` section of the configured dictionary (the `dictionary` @@ -139,7 +148,7 @@ export abstract class Exporter< public abstract transformStyledText(styledText: StyledText): TS; public async mapBlock( - block: BlockFromConfig, + block: BlockNoDefaults, nestingLevel: number, numberedListIndex: number, children?: Array>, @@ -150,6 +159,27 @@ export abstract class Exporter< `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); + if (typeof mapping === "function") { + // A layout block's children belong inside it, which only a + // `{ withChildren }` mapping can do. Fail early rather than export them + // after it. + // TODO: remove once the `BlockMapping` type requires `{ withChildren }` + // for layout blocks (`createBlockSpec` doesn't keep `layoutOnly: true` in the + // config type yet). + const spec = this.schema.blockSpecs[block.type]; + if (spec !== undefined && spec.config.layoutOnly === true) { + throw new Error( + `The mapping for layout block type "${block.type}" must be a \`{ withChildren }\` mapping, which places the block's children.`, + ); + } + return mapping(block, this, nestingLevel, numberedListIndex); + } + return mapping.withChildren( + block, + this, + nestingLevel, + numberedListIndex, + children ?? [], + ); } } diff --git a/packages/core/src/exporter/mapping.ts b/packages/core/src/exporter/mapping.ts index fbe88d693e..b918ed4a1d 100644 --- a/packages/core/src/exporter/mapping.ts +++ b/packages/core/src/exporter/mapping.ts @@ -11,6 +11,13 @@ import type { Exporter } from "./Exporter.js"; /** * Defines a mapping from all block types with a schema to a result type `R`. + * Each block type maps to either: + * - a function that renders the block itself. The exporter places the + * block's children after it, nested as the format does it; or + * - `{ withChildren }`, a function that renders the block *and* its + * children, which it receives already rendered as its last argument. For + * blocks whose children are part of them, like a column or a callout's + * body. Layout blocks must use it. */ export type BlockMapping< B extends BlockSchema, @@ -19,18 +26,42 @@ export type BlockMapping< RB, RI, > = { - [K in keyof B]: ( - block: BlockFromConfigNoChildren, - // we don't know the exact types that are supported by the exporter at this point, - // because the mapping only knows about converting certain types (which might be a subset of the supported types) - // this is why there are many `any` types here (same for types below) - exporter: Exporter, - nestingLevel: number, - numberedListIndex?: number, - children?: Array>, - ) => RB | Promise; + [K in keyof B]: + | BlockMappingFunction + | { withChildren: BlockMappingWithChildrenFunction }; }; +export type BlockMappingFunction< + C extends BlockSchema[string], + I extends InlineContentSchema, + S extends StyleSchema, + RB, + RI, +> = ( + block: BlockFromConfigNoChildren, + // we don't know the exact types that are supported by the exporter at this point, + // because the mapping only knows about converting certain types (which might be a subset of the supported types) + // this is why there are many `any` types here (same for types below) + exporter: Exporter, + nestingLevel: number, + numberedListIndex?: number, +) => RB | Promise; + +/** A `{ withChildren }` mapping: it also receives the block's rendered children. */ +export type BlockMappingWithChildrenFunction< + C extends BlockSchema[string], + I extends InlineContentSchema, + S extends StyleSchema, + RB, + RI, +> = ( + block: BlockFromConfigNoChildren, + exporter: Exporter, + nestingLevel: number, + numberedListIndex: number | undefined, + children: Array>, +) => RB | Promise; + /** * Defines a mapping from all inline content types with a schema to a result type R. */ diff --git a/packages/core/src/extensions/DropCursor/DropCursor.ts b/packages/core/src/extensions/DropCursor/DropCursor.ts index 77d24cc114..01017ca24a 100644 --- a/packages/core/src/extensions/DropCursor/DropCursor.ts +++ b/packages/core/src/extensions/DropCursor/DropCursor.ts @@ -1,3 +1,4 @@ +import { Plugin } from "prosemirror-state"; import { dropPoint } from "prosemirror-transform"; import type { EditorView } from "prosemirror-view"; import { @@ -10,6 +11,7 @@ import { } from "./utils.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createExtension } from "../../editor/BlockNoteExtension.js"; +import { getDropIntoChildren } from "./dropIntoChildren.js"; export const DRAG_EXCLUSION_CLASSNAME = "bn-drag-exclude"; @@ -64,6 +66,11 @@ export const DropCursorExtension = createExtension< let element: HTMLElement | null = null; let timeout = -1; let dragSourceElement: Element | null = null; + // The block the drop goes into, if any, as the cursor shows it. Its + // element is highlighted like a selected block. + let dropInto: + | { blockId: string; draggedBlocks: any[]; element: HTMLElement } + | undefined; const config = { width: options.dropCursor?.width ?? 5, @@ -73,6 +80,14 @@ export const DropCursorExtension = createExtension< } as const; // Helper functions + const setDropInto = (into: typeof dropInto) => { + delete dropInto?.element.dataset.dropTarget; + dropInto = into; + if (dropInto) { + dropInto.element.dataset.dropTarget = "true"; + } + }; + const setCursor = (pos: DropCursorPosition | null) => { if ( pos?.pos === cursorPos?.pos && @@ -83,6 +98,7 @@ export const DropCursorExtension = createExtension< cursorPos = pos; if (pos == null) { + setDropInto(undefined); if (element && element.parentNode) { element.parentNode.removeChild(element); } @@ -189,13 +205,17 @@ export const DropCursorExtension = createExtension< } } - // Compute default position + // Compute default position. A block dragged onto a block with + // `meta.dropsIntoChildren` goes to the start of its children. const $pos = view.state.doc.resolve(target); const isBlock = !$pos.parent.inlineContent; - const defaultPosition: DropCursorPosition = { - pos: target, - orientation: isBlock ? "block-horizontal" : "inline", - }; + const intoChildren = getDropIntoChildren(editor, view, { + left: e.clientX, + top: e.clientY, + }); + const defaultPosition: DropCursorPosition = intoChildren + ? { pos: intoChildren.pos, orientation: "block-horizontal" } + : { pos: target, orientation: isBlock ? "block-horizontal" : "inline" }; // Allow hook to override position let finalPosition = defaultPosition; @@ -215,6 +235,26 @@ export const DropCursorExtension = createExtension< } setCursor(finalPosition); + // The drop goes into the block while the cursor shows the place of its + // children. A hook may show another place instead (e.g. a new column). + if ( + intoChildren && + finalPosition.pos === intoChildren.pos && + finalPosition.orientation === "block-horizontal" + ) { + const { block } = intoChildren.blockInfo; + const element = view.nodeDOM(block.beforePos); + if (!(element instanceof HTMLElement)) { + throw new Error("A block in the document must have an element"); + } + setDropInto({ + blockId: block.node.attrs.id, + draggedBlocks: intoChildren.draggedBlocks, + element, + }); + } else { + setDropInto(undefined); + } scheduleRemoval(5000); } }; @@ -238,8 +278,49 @@ export const DropCursorExtension = createExtension< dragSourceElement = null; }; + // Drops the blocks where the cursor shows them: as the first children of + // the block it goes into. Other drops are ProseMirror's. + const dropIntoChildrenPlugin = new Plugin({ + props: { + handleDrop(_view, _event, _slice, moved) { + const into = dropInto; + // The drop ends the drag, and with it the highlight. + setDropInto(undefined); + // The block may be gone since the last `dragover`, e.g. removed by a + // collaborator. ProseMirror then drops the blocks as usual. + if (!into || !editor.getBlock(into.blockId)) { + return false; + } + const { blockId, draggedBlocks } = into; + editor.transact(() => { + if (moved) { + // A drag from another editor leaves its blocks there. + editor.removeBlocks( + draggedBlocks.filter((block) => editor.getBlock(block.id)), + ); + } + // The dragged blocks never contain the target (see + // `getDropIntoChildren`), so removing them keeps it. + const target = editor.getBlock(blockId); + if (!target) { + throw new Error( + "The drop target was removed with the dragged blocks", + ); + } + if (target.children.length > 0) { + editor.insertBlocks(draggedBlocks, target.children[0], "before"); + } else { + editor.updateBlock(target, { children: draggedBlocks }); + } + }); + return true; + }, + }, + }); + return { key: "dropCursor", + prosemirrorPlugins: [dropIntoChildrenPlugin], mount({ signal, dom, root }) { // Track drag source at document level root.addEventListener("dragstart", onDragStart, { diff --git a/packages/core/src/extensions/DropCursor/dropIntoChildren.ts b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts new file mode 100644 index 0000000000..9407fd6139 --- /dev/null +++ b/packages/core/src/extensions/DropCursor/dropIntoChildren.ts @@ -0,0 +1,80 @@ +import type { EditorView } from "prosemirror-view"; + +import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; +import { getBlockInfoFromNode } from "../../api/getBlockInfoFromPos.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; + +/** + * Where a block drag at `coords` drops, when it is over the content or frame + * chrome of a block with `meta.dropsIntoChildren`: the start of that block's + * children, or its end when it has no children yet. + * + * @returns `undefined` when the drop goes before or after a block, as usual: + * for a text drag, over a block's children, over a block without the + * setting, or over one of the dragged blocks. + */ +export function getDropIntoChildren( + editor: BlockNoteEditor, + view: EditorView, + coords: { left: number; top: number }, +) { + const slice = view.dragging?.slice; + // A block drag holds complete blocks (see `SideMenu.onDragStart`). + if (!slice || slice.openStart > 0) { + return undefined; + } + const draggedBlocks = fragmentToBlocks(slice.content); + const pos = view.posAtCoords(coords); + if (draggedBlocks.length === 0 || !pos || pos.inside < 0) { + return undefined; + } + + // The innermost block around the pointer. Over its children group (e.g. a + // gap between two children), the drop goes between them. + const $inside = view.state.doc.resolve(pos.inside + 1); + let depth = $inside.depth; + while (depth > 0 && !$inside.node(depth).type.isInGroup("bnBlock")) { + if ($inside.node(depth).type.name === "blockGroup") { + return undefined; + } + depth--; + } + if (depth === 0) { + return undefined; + } + const blockInfo = getBlockInfoFromNode( + $inside.node(depth), + $inside.before(depth), + ); + if (!blockInfo.hasContent) { + return undefined; + } + + const dropsIntoChildren = editor.schema.blockSpecs[ + blockInfo.blockNoteType + ]?.implementation.meta.dropsIntoChildren?.( + nodeToBlock(blockInfo.block.node, view.state.doc), + ); + if (!dropsIntoChildren) { + return undefined; + } + + // A block can't be dropped into itself or into one of its descendants. + const targetId = blockInfo.block.node.attrs.id; + const containsTarget = (block: { + id: string; + children: { id: string; children: any[] }[]; + }): boolean => block.id === targetId || block.children.some(containsTarget); + if (draggedBlocks.some(containsTarget)) { + return undefined; + } + + return { + blockInfo, + draggedBlocks, + pos: blockInfo.children + ? blockInfo.children.childrenStart + : blockInfo.block.afterPos - 1, + }; +} diff --git a/packages/core/src/extensions/Placeholder/Placeholder.ts b/packages/core/src/extensions/Placeholder/Placeholder.ts index fedcbadbdc..da3f0c81f3 100644 --- a/packages/core/src/extensions/Placeholder/Placeholder.ts +++ b/packages/core/src/extensions/Placeholder/Placeholder.ts @@ -18,14 +18,13 @@ export const PlaceholderExtension = createExtension( Pick, "placeholders"> >) => { const placeholders = options.placeholders; + const uniqueEditorSelector = `placeholder-selector-${uuidv4()}`; return { key: "placeholder", prosemirrorPlugins: [ new Plugin({ key: PLUGIN_KEY, view: (view) => { - const uniqueEditorSelector = `placeholder-selector-${uuidv4()}`; - view.dom.classList.add(uniqueEditorSelector); const styleEl = document.createElement("style"); const nonce = editor._tiptapEditor.options.injectNonce; @@ -96,6 +95,10 @@ export const PlaceholderExtension = createExtension( }; }, props: { + // Set through ProseMirror, not on `view.dom` directly: ProseMirror + // observes the editor element, and flushes a change made outside + // it later, which can outlive the editor. + attributes: { class: uniqueEditorSelector }, decorations: (state) => { const { doc, selection } = state; diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index ec7904dc35..57cf10d343 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -111,6 +111,10 @@ export const SideMenuExtension = createExtension( | { exit: Point; timer: ReturnType } | undefined; + function isDraggable(type: string) { + return editor.schema.blockSpecs[type].implementation.meta.draggable; + } + function clearIntent() { if (intent) { clearTimeout(intent.timer); @@ -180,6 +184,7 @@ export const SideMenuExtension = createExtension( editor.prosemirrorView, target, point, + isDraggable, editor.isWithinEditor, hoverMargin, ); diff --git a/packages/core/src/extensions/SideMenu/blockDOM.ts b/packages/core/src/extensions/SideMenu/blockDOM.ts deleted file mode 100644 index 2a9132280f..0000000000 --- a/packages/core/src/extensions/SideMenu/blockDOM.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { EditorView } from "prosemirror-view"; - -// FIXME: this file finds blocks through their rendered DOM (selectors, -// `closest`). Resolving through ProseMirror instead would drop the selectors: -// `view.posAtDOM` for the block an element is in, the document for a block's -// children, and `view.nodeDOM` for their boxes. Check `posAtDOM` inside node -// views and nested editors first. The same applies to -// `getDraggableBlockFromElement`. - -// UniqueID and the block renderers put data-id on every block root. -// Ordinary blocks also copy it to blockOuter, which is only rendering chrome. -export const BLOCK_SELECTOR = - '[data-node-type][data-id]:not([data-node-type="blockOuter"])'; - -/** - * Returns the root element of the block of this editor that the element is - * in, if there is one. - */ -export function getBlockElement( - element: Element, - view: Pick, -): Element | undefined { - // Embedded editors own their blocks, even though their DOM is inside ours. - if (element.closest(".bn-editor") !== view.dom) { - return undefined; - } - const block = element.closest(BLOCK_SELECTOR); - return block && block !== view.dom && view.dom.contains(block) - ? block - : undefined; -} - -/** - * Returns the child blocks of a block element: its nested blocks, or the - * children of a container. The blocks of an editor nested in the block are - * not its children: they belong to that editor. - */ -export function getDirectChildBlocks(container: Element): Element[] { - const editor = container.closest(".bn-editor"); - const children: Element[] = []; - for (const child of container.querySelectorAll(BLOCK_SELECTOR)) { - if ( - child.parentElement?.closest(BLOCK_SELECTOR) === container && - child.closest(".bn-editor") === editor - ) { - children.push(child); - } - } - return children; -} diff --git a/packages/core/src/extensions/SideMenu/blockDropHandling.ts b/packages/core/src/extensions/SideMenu/blockDropHandling.ts index 46c38002f1..fc3293a51e 100644 --- a/packages/core/src/extensions/SideMenu/blockDropHandling.ts +++ b/packages/core/src/extensions/SideMenu/blockDropHandling.ts @@ -219,10 +219,15 @@ export function registerBlockDropHandlers( "text/html", ).body; + // Into the type of the blocks' parent, when this editor has it (see + // `dragStart`): a block that only this parent can hold, e.g. a column or + // the step of a stepper, would otherwise be wrapped in a new copy of it. + const { nodes } = view.state.schema; + const parentType = + nodes[event.dataTransfer!.getData("blocknote/parent")] ?? + nodes["blockGroup"]; const parser = DOMParser.fromSchema(view.state.schema); - const node = parser.parse(element, { - topNode: view.state.schema.nodes["blockGroup"].create(), - }); + const node = parser.parse(element, { topNode: parentType.create() }); view.dragging = { slice: new Slice(node.content, 0, 0), diff --git a/packages/core/src/extensions/SideMenu/blockGeometry.ts b/packages/core/src/extensions/SideMenu/blockGeometry.ts index 3891af4812..122c04c1d3 100644 --- a/packages/core/src/extensions/SideMenu/blockGeometry.ts +++ b/packages/core/src/extensions/SideMenu/blockGeometry.ts @@ -1,4 +1,4 @@ -import { BLOCK_SELECTOR, getDirectChildBlocks } from "./blockDOM.js"; +import { BLOCK_SELECTOR, getDirectChildBlocks } from "../blockDOM.js"; export type BlockRect = { top: number; diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index 0848bd3f99..f90f2cf55a 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -61,33 +61,23 @@ function setDragImage(view: EditorView, from: number, to = from) { to += view.state.doc.resolve(from + 1).node().nodeSize; } - // Parent element is cloned to remove all unselected children without affecting the editor content. - const parentClone = view.domAtPos(from).node.cloneNode(true) as Element; - const parent = view.domAtPos(from).node as Element; - - const getElementIndex = (parentElement: Element, targetElement: Element) => - Array.prototype.indexOf.call(parentElement.children, targetElement); - - const firstSelectedBlockIndex = getElementIndex( - parent, - // Expects from position to be just before the first selected block. - view.domAtPos(from + 1).node.parentElement!, - ); - const lastSelectedBlockIndex = getElementIndex( - parent, - // Expects to position to be just after the last selected block. - view.domAtPos(to - 1).node.parentElement!, - ); - - for (let i = parent.childElementCount - 1; i >= 0; i--) { - if (i > lastSelectedBlockIndex || i < firstSelectedBlockIndex) { - parentClone.removeChild(parentClone.children[i]); + // The preview is a copy of the element that holds the dragged blocks, + // without its other children: a copy of each dragged block's element. The + // holding element can have no box of its own (`display: contents`, e.g. the + // slot of a React layout block), but the preview needs one. + const preview = view.domAtPos(from).node.cloneNode(false) as HTMLElement; + preview.style.removeProperty("display"); + for (let pos = from; pos < to; pos += view.state.doc.nodeAt(pos)!.nodeSize) { + const blockDOM = view.nodeDOM(pos); + if (!blockDOM) { + throw new Error(`The dragged block at ${pos} has no DOM.`); } + preview.appendChild(blockDOM.cloneNode(true)); } // dataTransfer.setDragImage(element) only works if element is attached to the DOM. unsetDragImage(view.root); - dragImageElement = parentClone; + dragImageElement = preview; // Browsers may have CORS policies which prevents iframes from being // manipulated, so better to stay on the safe side and remove them from the @@ -195,6 +185,13 @@ export function dragStart< e.dataTransfer.clearData(); e.dataTransfer.setData("blocknote/html", clipboardHTML); + // The type of the dragged blocks' parent. The editors parse the blocks + // into it (see `blockDropHandling.ts`), so that a block that only this + // parent can hold is not wrapped in a new copy of it. + e.dataTransfer.setData( + "blocknote/parent", + view.state.selection.$from.parent.type.name, + ); e.dataTransfer.setData("text/html", externalHTML); e.dataTransfer.setData("text/plain", plainText); e.dataTransfer.effectAllowed = "move"; diff --git a/packages/core/src/extensions/SideMenu/sideMenuHover.ts b/packages/core/src/extensions/SideMenu/sideMenuHover.ts index 6ee14a1710..de623e8788 100644 --- a/packages/core/src/extensions/SideMenu/sideMenuHover.ts +++ b/packages/core/src/extensions/SideMenu/sideMenuHover.ts @@ -1,7 +1,6 @@ import { EditorView } from "@tiptap/pm/view"; -import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; -import { getBlockElement } from "./blockDOM.js"; +import { getBlockElement, getDraggableBlockFromElement } from "../blockDOM.js"; import { getNestedBlockAtCursor } from "./blockGeometry.js"; export type Point = { x: number; y: number }; @@ -78,6 +77,8 @@ export function resolveHoveredBlock( view: EditorView, target: Element | null, pointer: Point, + /** Whether blocks of this type get a drag handle. */ + isDraggable: (type: string) => boolean, /** Whether the element is UI that belongs to this editor (a toolbar, a menu). */ isWithinEditorUI: (element: Element) => boolean, /** How far left or right of the editor element the pointer still counts (px). */ @@ -87,7 +88,7 @@ export function resolveHoveredBlock( // their children do, and their gutter still needs to resolve those children. function draggable(block: Element | undefined): HoverResolution { const resolved = block - ? getDraggableBlockFromElement(block, view) + ? getDraggableBlockFromElement(block, view, isDraggable) : undefined; return resolved ? { kind: "block", block: resolved } : { kind: "none" }; } diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts index c9e8648861..def3509cee 100644 --- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts +++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts @@ -30,7 +30,7 @@ export const SourceBlockWithPreviewExtension = createExtension( // `meta.hasPreview` (read from the spec, like the syntax-highlighting // extension reads `meta.highlight`). const blockHasPreview = (block: Block) => - !!editor.schema.blockSpecs[block.type]?.implementation?.meta?.hasPreview; + !!editor.schema.blockSpecs[block.type]?.implementation.meta.hasPreview; const handleArrow = (direction: "prev" | "next") => @@ -57,7 +57,8 @@ export const SourceBlockWithPreviewExtension = createExtension( key: "sourceBlockWithPreview", store, keyboardShortcuts: { - // Toggles the popup. This may be overridden by `hardBreakShortcut`. + // Toggles the popup, unless Enter inserts line breaks in the block + // (`keyboard.enter: "line-break"`). Enter: ({ editor }) => { const { block } = editor.getTextCursorPosition(); if (!blockHasPreview(block)) { @@ -66,8 +67,8 @@ export const SourceBlockWithPreviewExtension = createExtension( if ( store.state.popupOpen === block.id && - editor.schema.blockSpecs[block.type]?.implementation?.meta - ?.hardBreakShortcut === "enter" + editor.schema.blockSpecs[block.type].implementation.keyboard(block) + .enter === "line-break" ) { const view = editor.prosemirrorView!; view.dispatch(view.state.tr.insertText("\n")); diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 756c2c1c36..30497bb8de 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -1,5 +1,9 @@ import { Block, PartialBlock } from "../../blocks/defaultBlocks.js"; import { editorHasBlockWithType } from "../../blocks/defaultBlockTypeGuards.js"; +import { + type DefaultBlockTypeItem, + getDefaultBlockTypeItems, +} from "../../blocks/defaultBlockTypeItems.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { BlockSchema, @@ -99,103 +103,39 @@ export function getDefaultSlashMenuItems< >(editor: BlockNoteEditor) { const items: DefaultSuggestionItem[] = []; - if (editorHasBlockWithType(editor, "heading", { level: "number" })) { - (editor.schema.blockSchema.heading.propSchema.level.values || []) - .filter((level): level is 1 | 2 | 3 => level <= 3) - .forEach((level) => { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "heading", - props: { level: level }, - }); - }, - badge: formatKeyboardShortcut(`Mod-Alt-${level}`), - key: - level === 1 ? ("heading" as const) : (`heading_${level}` as const), - ...editor.dictionary.slash_menu[ - level === 1 ? ("heading" as const) : (`heading_${level}` as const) - ], - }); - }); - } - - if (editorHasBlockWithType(editor, "quote")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "quote", - }); - }, - key: "quote", - ...editor.dictionary.slash_menu.quote, - }); - } - - if (editorHasBlockWithType(editor, "toggleListItem")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "toggleListItem", - }); - }, - badge: formatKeyboardShortcut("Mod-Shift-6"), - key: "toggle_list", - ...editor.dictionary.slash_menu.toggle_list, - }); - } - - if (editorHasBlockWithType(editor, "numberedListItem")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "numberedListItem", - }); - }, - badge: formatKeyboardShortcut("Mod-Shift-7"), - key: "numbered_list", - ...editor.dictionary.slash_menu.numbered_list, - }); - } - - if (editorHasBlockWithType(editor, "bulletListItem")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "bulletListItem", - }); - }, - badge: formatKeyboardShortcut("Mod-Shift-8"), - key: "bullet_list", - ...editor.dictionary.slash_menu.bullet_list, - }); - } - - if (editorHasBlockWithType(editor, "checkListItem")) { + // The block types come from `getDefaultBlockTypeItems`, which the block + // type select also uses. The slash menu places them in its own order. + const blockTypes = new Map( + getDefaultBlockTypeItems(editor).map((item) => [item.key, item]), + ); + function pushBlockType(key: DefaultBlockTypeItem["key"], badge?: string) { + const blockType = blockTypes.get(key); + if (!blockType) { + return; + } items.push({ onItemClick: () => { + // The schema supports the block type (see `getDefaultBlockTypeItems`). insertOrUpdateBlockForSlashMenu(editor, { - type: "checkListItem", - }); + type: blockType.type, + props: blockType.props, + } as PartialBlock); }, - badge: formatKeyboardShortcut("Mod-Shift-9"), - key: "check_list", - ...editor.dictionary.slash_menu.check_list, + badge, + key, + ...editor.dictionary.slash_menu[key], }); } - if (editorHasBlockWithType(editor, "paragraph")) { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "paragraph", - }); - }, - badge: formatKeyboardShortcut("Mod-Alt-0"), - key: "paragraph", - ...editor.dictionary.slash_menu.paragraph, - }); - } + pushBlockType("heading", formatKeyboardShortcut("Mod-Alt-1")); + pushBlockType("heading_2", formatKeyboardShortcut("Mod-Alt-2")); + pushBlockType("heading_3", formatKeyboardShortcut("Mod-Alt-3")); + pushBlockType("quote"); + pushBlockType("toggle_list", formatKeyboardShortcut("Mod-Shift-6")); + pushBlockType("numbered_list", formatKeyboardShortcut("Mod-Shift-7")); + pushBlockType("bullet_list", formatKeyboardShortcut("Mod-Shift-8")); + pushBlockType("check_list", formatKeyboardShortcut("Mod-Shift-9")); + pushBlockType("paragraph", formatKeyboardShortcut("Mod-Alt-0")); if (editorHasBlockWithType(editor, "codeBlock")) { items.push({ @@ -324,52 +264,12 @@ export function getDefaultSlashMenuItems< }); } - if ( - editorHasBlockWithType(editor, "heading", { - level: "number", - isToggleable: "boolean", - }) - ) { - (editor.schema.blockSchema.heading.propSchema.level.values || []) - .filter((level): level is 1 | 2 | 3 => level <= 3) - .forEach((level) => { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "heading", - props: { level: level, isToggleable: true }, - }); - }, - key: - level === 1 - ? ("toggle_heading" as const) - : (`toggle_heading_${level}` as const), - ...editor.dictionary.slash_menu[ - level === 1 - ? ("toggle_heading" as const) - : (`toggle_heading_${level}` as const) - ], - }); - }); - } - - if (editorHasBlockWithType(editor, "heading", { level: "number" })) { - (editor.schema.blockSchema.heading.propSchema.level.values || []) - .filter((level): level is 4 | 5 | 6 => level > 3) - .forEach((level) => { - items.push({ - onItemClick: () => { - insertOrUpdateBlockForSlashMenu(editor, { - type: "heading", - props: { level: level }, - }); - }, - badge: formatKeyboardShortcut(`Mod-Alt-${level}`), - key: `heading_${level}`, - ...editor.dictionary.slash_menu[`heading_${level}`], - }); - }); - } + pushBlockType("toggle_heading"); + pushBlockType("toggle_heading_2"); + pushBlockType("toggle_heading_3"); + pushBlockType("heading_4", formatKeyboardShortcut("Mod-Alt-4")); + pushBlockType("heading_5", formatKeyboardShortcut("Mod-Alt-5")); + pushBlockType("heading_6", formatKeyboardShortcut("Mod-Alt-6")); items.push({ onItemClick: () => { diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts index dd89b96ad9..d5bb16aa2e 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.test.ts @@ -10,14 +10,24 @@ import { describe("SyntaxHighlightingExtension", () => { // The extension only reads `editor.schema.blockSpecs` and - // `inlineContentSpecs`, so a minimal stub is enough. + // `inlineContentSpecs`, so a minimal stub is enough. A spec in a schema + // always carries its resolved `meta`. const fakeEditor = () => ({ schema: { blockSpecs: { - paragraph: { config: { type: "paragraph", content: "inline" } }, - codeBlock: { config: { type: "codeBlock", content: "plain" } }, - image: { config: { type: "image", content: "none" } }, + paragraph: { + config: { type: "paragraph", content: "inline" }, + implementation: { meta: {} }, + }, + codeBlock: { + config: { type: "codeBlock", content: "plain" }, + implementation: { meta: {} }, + }, + image: { + config: { type: "image", content: "none" }, + implementation: { meta: {} }, + }, }, inlineContentSpecs: {}, }, diff --git a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts index 877dea8428..f5d9a6243e 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/SyntaxHighlighting.ts @@ -45,7 +45,7 @@ export function collectHighlightNodeTypes(schema: { (blockSpec): blockSpec is LooseBlockSpec => typeof (blockSpec as LooseBlockSpec)?.config === "object" && (blockSpec as LooseBlockSpec).config.content === "plain" && - !!(blockSpec as LooseBlockSpec).implementation?.meta?.highlight, + !!(blockSpec as LooseBlockSpec).implementation.meta.highlight, ) .map((blockSpec) => blockSpec.config.type); diff --git a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts index a0e8cec88b..96fa7634dc 100644 --- a/packages/core/src/extensions/SyntaxHighlighting/shiki.ts +++ b/packages/core/src/extensions/SyntaxHighlighting/shiki.ts @@ -94,7 +94,7 @@ export function lazyShikiPlugin( schema.blockSpecs[nodeShape.type] || schema.inlineContentSpecs[nodeShape.type]; - return spec?.implementation?.meta?.highlight?.(nodeShape) ?? undefined; + return spec?.implementation.meta.highlight?.(nodeShape) ?? undefined; }, nodeTypes, }); diff --git a/packages/core/src/extensions/TableHandles/TableHandles.ts b/packages/core/src/extensions/TableHandles/TableHandles.ts index bb396fbdd7..5abdbfab8d 100644 --- a/packages/core/src/extensions/TableHandles/TableHandles.ts +++ b/packages/core/src/extensions/TableHandles/TableHandles.ts @@ -42,7 +42,7 @@ import { BlockFromConfigNoChildren, BlockSchemaWithBlock, } from "../../schema/index.js"; -import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; +import { getBlockFromElement } from "../blockDOM.js"; let dragImageElement: HTMLElement | undefined; @@ -246,8 +246,9 @@ export class TableHandlesView implements PluginView { const tableRect = target.tbodyNode.getBoundingClientRect(); - const blockEl = getDraggableBlockFromElement(target.domNode, this.pmView); + const blockEl = getBlockFromElement(target.domNode, this.pmView); if (!blockEl) { + this.hideHandles(); return; } @@ -256,10 +257,7 @@ export class TableHandlesView implements PluginView { doc: tr.doc, })); - // The hovered cell may belong to a document other than this editor's, as a - // custom block can embed a nested editor which itself contains a table. The - // nested editor's DOM is inside this view's DOM, so its cells still reach - // this handler, but its block IDs are unknown here. + // The DOM target must still correspond to a block in this document. if (!pmNodeInfo) { this.hideHandles(); return; diff --git a/packages/core/src/extensions/SideMenu/blockDOM.browser.test.ts b/packages/core/src/extensions/blockDOM.browser.test.ts similarity index 100% rename from packages/core/src/extensions/SideMenu/blockDOM.browser.test.ts rename to packages/core/src/extensions/blockDOM.browser.test.ts diff --git a/packages/core/src/extensions/blockDOM.test.ts b/packages/core/src/extensions/blockDOM.test.ts new file mode 100644 index 0000000000..dc3281eaa3 --- /dev/null +++ b/packages/core/src/extensions/blockDOM.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + getBlockElementForNode, + getBlockFromElement, + getDraggableBlockFromElement, +} from "./blockDOM.js"; + +function isDraggable(type: string) { + return type !== "lockedBlock" && type !== "column"; +} + +// Identity and ownership need detached DOM trees, not browser layout. + +/** 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"); + outer.setAttribute("data-id", id); + + 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 blockContainer element for a regular block", () => { + const { outer, blockContainer, content } = regularBlock("a", "paragraph"); + + expect( + getDraggableBlockFromElement(content, viewWith(outer), isDraggable), + ).toEqual({ + node: blockContainer, + id: "a", + type: "paragraph", + }); + }); + + it("resolves a locked block's identity but gives it no drag handle", () => { + const { outer, content, blockContainer } = regularBlock("a", "lockedBlock"); + + expect(getBlockFromElement(content, viewWith(outer))).toEqual({ + node: blockContainer, + id: "a", + type: "lockedBlock", + }); + + expect( + getDraggableBlockFromElement(content, viewWith(outer), isDraggable), + ).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), + isDraggable, + ), + ).toEqual({ node: parent.blockContainer, id: "parent", type: "paragraph" }); + }); + + 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), + isDraggable, + ), + ).toBeUndefined(); + }); + + it("returns a layout 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), isDraggable), + ).toBeUndefined(); + + expect( + getDraggableBlockFromElement(column, viewWith(column), () => true), + ).toEqual({ node: column, id: "col", type: "column" }); + }); +}); + +it("does not resolve blocks outside this editor", () => { + const { content } = regularBlock("outside", "paragraph"); + expect( + getBlockFromElement(content, viewWith(document.createElement("div"))), + ).toBeUndefined(); +}); + +it("does not resolve a block owned by an embedded editor", () => { + const nested = regularBlock("nested", "paragraph"); + const nestedView = viewWith(nested.outer); + nestedView.dom.className = "bn-editor"; + const outerView = viewWith(nestedView.dom); + outerView.dom.className = "bn-editor"; + expect(getBlockFromElement(nested.content, outerView)).toBeUndefined(); + expect(getBlockFromElement(nested.content, nestedView)?.id).toBe("nested"); +}); + +describe("getBlockElementForNode", () => { + const viewWith = (dom: Node | null) => ({ nodeDOM: () => dom }); + + it("returns a regular block's element inside its blockOuter", () => { + // `getBlockElement` finds the same element from inside the block. + const { outer, blockContainer } = regularBlock("a", "paragraph"); + expect(getBlockElementForNode(viewWith(outer), 0, "a")).toBe( + blockContainer, + ); + }); + + it("returns the node's DOM when it is the block's element", () => { + const frame = document.createElement("section"); + frame.setAttribute("data-node-type", "panel"); + frame.setAttribute("data-id", "panel"); + expect(getBlockElementForNode(viewWith(frame), 0, "panel")).toBe(frame); + }); + + it("finds the block's element inside wrappers without its id", () => { + // A framework's node view: wrappers around the block's element, which + // holds a child block of its own. + const wrapper = document.createElement("div"); + const inner = document.createElement("div"); + const root = document.createElement("div"); + root.setAttribute("data-node-type", "panel"); + root.setAttribute("data-id", "panel"); + const { outer: child } = regularBlock("child", "paragraph"); + root.append(child); + inner.append(root); + wrapper.append(inner); + expect(getBlockElementForNode(viewWith(wrapper), 0, "panel")).toBe(root); + }); + + it("returns undefined when the node has no element", () => { + expect(getBlockElementForNode(viewWith(null), 0, "a")).toBeUndefined(); + }); +}); diff --git a/packages/core/src/extensions/blockDOM.ts b/packages/core/src/extensions/blockDOM.ts new file mode 100644 index 0000000000..5094c331f8 --- /dev/null +++ b/packages/core/src/extensions/blockDOM.ts @@ -0,0 +1,135 @@ +import type { EditorView } from "prosemirror-view"; + +// FIXME: this file finds blocks through their rendered DOM (selectors, +// `closest`). Resolving through ProseMirror instead would drop the selectors: +// `view.posAtDOM` for the block an element is in, the document for a block's +// children, and `view.nodeDOM` for their boxes. Check `posAtDOM` inside node +// views and nested editors first. + +// UniqueID and the block renderers put data-id on every block root. +// Ordinary blocks also copy it to blockOuter, which is only rendering chrome. +export const BLOCK_SELECTOR = + '[data-node-type][data-id]:not([data-node-type="blockOuter"])'; + +/** + * Returns the root element of the block of this editor that the element is + * in, if there is one. + */ +export function getBlockElement( + element: Element, + view: Pick, +): Element | undefined { + if (!view.dom.contains(element)) { + return undefined; + } + // Embedded editors own their blocks, even though their DOM is inside ours. + const owner = element.closest(".bn-editor"); + if (owner && owner !== view.dom) { + return undefined; + } + const block = element.closest(BLOCK_SELECTOR); + return block && block !== view.dom && view.dom.contains(block) + ? block + : undefined; +} + +/** + * Returns the element of the block with `id`, whose node starts at `pos`: the + * same element as `getBlockElement` finds from inside the block. ProseMirror's + * DOM for the node can be that element (a layout block's frame), or wrap it: a + * regular block's `.bn-block-outer`, or a framework's wrappers that don't + * carry the block's id (React's `display: contents` wrappers). + * + * Caution: a React layout block's frame exists only after React renders its + * node view, which can be after the editor's own render. Until then, this + * returns `undefined` for it, never another element. + */ +export function getBlockElementForNode( + view: Pick, + pos: number, + id: string, +): Element | undefined { + const dom = view.nodeDOM(pos); + if (!(dom instanceof Element)) { + return undefined; + } + // The block's element comes before its child blocks, so it is the first + // block element: the node's DOM, or else the first one inside it (inside + // `.bn-block-outer`, or inside a framework's wrappers). + const element = dom.matches(BLOCK_SELECTOR) + ? dom + : dom.querySelector(BLOCK_SELECTOR); + return element?.getAttribute("data-id") === id ? element : undefined; +} + +/** Resolves block identity independently of whether it gets a drag handle. */ +export function getBlockFromElement( + element: Element, + view: Pick, +): { node: HTMLElement; id: string; type: string } | undefined { + const node = getBlockElement(element, view); + if (!node) { + return undefined; + } + + const id = node.getAttribute("data-id"); + const nodeType = node.getAttribute("data-node-type"); + // The regular block's own content precedes its nested blockGroup, even + // when a renderFrame adds chrome around it. + const type = + nodeType === "blockContainer" + ? node + .querySelector("[data-content-type]") + ?.getAttribute("data-content-type") + : nodeType; + // A matched block root must expose its own identity. Validate this at the + // DOM boundary so callers never need casts or non-null assertions. + const HTMLElementClass = node.ownerDocument.defaultView?.HTMLElement; + if ( + !HTMLElementClass || + !(node instanceof HTMLElementClass) || + !id || + !type + ) { + throw new Error( + "Block root is missing its HTML element, ID, or block type.", + ); + } + return { node, id, type }; +} + +/** A block that opts out hands its drag handle to the nearest eligible ancestor. */ +export function getDraggableBlockFromElement( + element: Element, + view: Pick, + isDraggable: (type: string) => boolean, +) { + let block = getBlockFromElement(element, view); + while (block) { + if (isDraggable(block.type)) { + return block; + } + const parent = block.node.parentElement; + block = parent ? getBlockFromElement(parent, view) : undefined; + } + return undefined; +} + +/** + * Returns the child blocks of a block element: its nested blocks, or the + * children of a layout block. The blocks of an editor nested in the block are + * not its children: they belong to that editor. + */ +export function getDirectChildBlocks(blockElement: Element): Element[] { + const editor = blockElement.closest(".bn-editor"); + const children: Element[] = []; + for (const child of blockElement.querySelectorAll(BLOCK_SELECTOR)) { + if ( + child.parentElement?.closest(BLOCK_SELECTOR) === blockElement && + child.closest(".bn-editor") === editor + ) { + children.push(child); + } + } + return children; +} diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts deleted file mode 100644 index cccbdfa0f0..0000000000 --- a/packages/core/src/extensions/getDraggableBlockFromElement.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { EditorView } from "prosemirror-view"; - -// FIXME: walks the DOM to the block; could resolve through ProseMirror instead -// (see the note in `SideMenu/blockDOM.ts`). -export function getDraggableBlockFromElement( - element: Element, - view: EditorView, -) { - while ( - element && - element.parentElement && - element.parentElement !== view.dom && - element.getAttribute?.("data-node-type") !== "blockContainer" - ) { - element = element.parentElement; - } - if (element.getAttribute?.("data-node-type") !== "blockContainer") { - return undefined; - } - return { node: element as HTMLElement, id: element.getAttribute("data-id")! }; -} diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index 093e4d2728..4315dca9e8 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -16,12 +16,8 @@ import { import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { createBlockSpec } from "../../../schema/index.js"; -// The `hardBreakShortcut` setting lives on the block spec's implementation -// (`schema.blockSpecs[type].implementation.meta`), not on the block config in -// `schema.blockSchema`. These blocks verify that the Enter / Shift-Enter -// handlers read it from the right place — a previous regression read it from -// `blockSchema`, which never contains `meta`, so custom settings were silently -// ignored and every block behaved as "shift+enter". +// Blocks configured with the deprecated `meta.hardBreakShortcut`, which still +// sets the default of `keyboard.enter` and `keyboard.shiftEnter`. const createHardBreakTestBlockSpec = < const T extends string, const S extends "shift+enter" | "enter" | "none", @@ -51,9 +47,73 @@ const createHardBreakTestBlockSpec = < }, )(); +// The same blocks, configured with the `experimental_keyboard` settings that +// replace the +// deprecated `meta.hardBreakShortcut`. +const createKeyboardTestBlockSpec = < + const T extends string, + const C extends "inline" | "plain", +>( + type: T, + keyboard: { + enter?: "split" | "into-children" | "line-break"; + shiftEnter?: "line-break" | "same-as-enter"; + }, + content: C = "inline" as C, +) => + createBlockSpec( + { + type, + propSchema: {}, + content, + }, + { + experimental_keyboard: keyboard, + render: () => { + const dom = document.createElement("p"); + return { + dom, + contentDOM: dom, + }; + }, + }, + )(); + +// A block whose keyboard function leaves settings `undefined` when a prop is +// off. Those settings must keep their defaults. +const conditionalKeyboardBlock = createBlockSpec( + { + type: "conditionalKeyboard", + propSchema: { on: { default: false } }, + content: "inline", + }, + { + experimental_keyboard: (block) => ({ + enter: block.props.on ? "into-children" : undefined, + resetsTo: block.props.on ? { type: "heading" } : undefined, + }), + render: () => { + const dom = document.createElement("p"); + return { dom, contentDOM: dom }; + }, + }, +)(); + const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, + conditionalKeyboard: conditionalKeyboardBlock, + keyboardEnter: createKeyboardTestBlockSpec("keyboardEnter", { + enter: "line-break", + }), + keyboardNone: createKeyboardTestBlockSpec("keyboardNone", { + shiftEnter: "same-as-enter", + }), + keyboardEnterPlain: createKeyboardTestBlockSpec( + "keyboardEnterPlain", + { enter: "line-break" }, + "plain", + ), hardBreakEnter: createHardBreakTestBlockSpec("hardBreakEnter", "enter"), hardBreakNone: createHardBreakTestBlockSpec("hardBreakNone", "none"), // "plain" content (`text*`) can't hold a `hardBreak` node, so these blocks @@ -71,7 +131,10 @@ function createEditor( | "paragraph" | "hardBreakEnter" | "hardBreakNone" - | "hardBreakEnterPlain", + | "hardBreakEnterPlain" + | "keyboardEnter" + | "keyboardNone" + | "keyboardEnterPlain", ) { const editor = BlockNoteEditor.create({ schema, @@ -97,6 +160,22 @@ function pressKeys(editor: BlockNoteEditor, keys: string) { editor._tiptapEditor.commands.keyboardShortcut(keys); } +/** + * Dispatches a keydown event straight to the view's handlers. Unlike + * `pressKeys`, whose command loses the selection and stored marks the handler + * sets, this leaves the state as a real key press does. Use it for tests that + * check the caret or the styles of what is typed next. + */ +function keyDown( + editor: BlockNoteEditor, + init: KeyboardEventInit, +) { + const view = editor._tiptapEditor.view; + view.someProp("handleKeyDown", (handler) => + handler(view, new KeyboardEvent("keydown", init)), + ); +} + function countHardBreaks(editor: BlockNoteEditor) { let count = 0; editor._tiptapEditor.state.doc.descendants((node) => { @@ -255,6 +334,121 @@ describe("KeyboardShortcutsExtension Backspace", () => { `); editor._tiptapEditor.destroy(); }); + // The block above isn't rich text, but its last child is: the text joins + // that child, as after any block with children. + it("merges into the last child of a block above whose own content isn't inline", () => { + const editor = createEditorWithBlocks( + [ + { + id: "code", + type: "codeBlock", + content: "x", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ], + { id: "after", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "text": "ChildAfter", + "type": "paragraph", + }, + ], + "text": "x", + "type": "codeBlock", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("does not merge rich text into a code block above", () => { + const editor = createEditorWithBlocks( + [ + { id: "code", type: "codeBlock", content: "x" }, + { id: "after", type: "paragraph", content: "After" }, + ], + { id: "after", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "x", + "type": "codeBlock", + }, + { + "text": "After", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + // #2566: the caret used to jump to the end of the top-level block above, + // instead of the last block nested under it. + it("in an empty block moves the caret to the end of the deepest last block above", () => { + const editor = createEditorWithBlocks( + [ + { + id: "list", + type: "bulletListItem", + content: "One", + children: [ + { id: "nested", type: "bulletListItem", content: "Two" }, + { id: "last", type: "bulletListItem" }, + ], + }, + { id: "empty", type: "paragraph" }, + ], + { id: "empty", placement: "start" }, + ); + + keyDown(editor, { key: "Backspace", keyCode: 8 }); + + expect(editor.getBlock("empty")).toBeUndefined(); + expect(editor.getTextCursorPosition().block.id).toBe("last"); + editor._tiptapEditor.destroy(); + }); + + // #605: Backspace in an empty block below an image used to delete the image + // too. + it("in an empty block below a block without content deletes only the empty block", () => { + const editor = createEditorWithBlocks( + [ + { id: "image", type: "image" }, + { id: "empty", type: "paragraph" }, + ], + { id: "empty", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(editor.document.map((block) => block.id)).toEqual(["image"]); + editor._tiptapEditor.destroy(); + }); + it("keeps the default of a keyboard setting given as undefined", () => { + const editor = createEditorWithBlocks( + [{ id: "a", type: "conditionalKeyboard", content: "Text" }], + { id: "a", placement: "start" }, + ); + + // `resetsTo` is undefined for this block, so the default applies. + pressKeys(editor, "Backspace"); + + expect(editor.getBlock("a")!.type).toBe("paragraph"); + editor._tiptapEditor.destroy(); + }); }); describe("KeyboardShortcutsExtension Delete", () => { @@ -373,29 +567,6 @@ describe("KeyboardShortcutsExtension Delete", () => { }); describe("KeyboardShortcutsExtension Enter", () => { - it("inserts an empty block above when Enter is pressed at the start", () => { - const editor = createEditorWithBlocks( - [{ id: "a", type: "paragraph", content: "Hello" }], - { id: "a", placement: "start" }, - ); - - pressKeys(editor, "Enter"); - - expect(outline(editor.document)).toMatchInlineSnapshot(` - [ - { - "text": "", - "type": "paragraph", - }, - { - "text": "Hello", - "type": "paragraph", - }, - ] - `); - editor._tiptapEditor.destroy(); - }); - it("lifts an empty nested block on Enter", () => { const editor = createEditorWithBlocks( [ @@ -459,7 +630,7 @@ describe("KeyboardShortcutsExtension Shift-Tab", () => { }); }); -describe("KeyboardShortcutsExtension hardBreakShortcut", () => { +describe("KeyboardShortcutsExtension line breaks", () => { it("inserts a hard break on Shift-Enter by default", () => { const editor = createEditor("paragraph"); @@ -471,6 +642,33 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); + // #1672: text typed after the line break used to lose the styles of the + // text before it. + it("keeps the styles of the text before the line break", () => { + const editor = createEditorWithBlocks( + [ + { + id: "p", + type: "paragraph", + content: [ + { type: "text", text: "Red", styles: { textColor: "red" } }, + ], + }, + ], + { id: "p", placement: "end" }, + ); + + keyDown(editor, { key: "Enter", keyCode: 13, shiftKey: true }); + // Typing: `insertText` takes the stored marks, as the browser input does. + const view = editor._tiptapEditor.view; + view.dispatch(view.state.tr.insertText("x")); + + expect(editor.getBlock("p")!.content).toEqual([ + { type: "text", text: "Red\nx", styles: { textColor: "red" } }, + ]); + editor._tiptapEditor.destroy(); + }); + it("splits the block on Enter by default", () => { const editor = createEditor("paragraph"); @@ -481,73 +679,130 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { editor._tiptapEditor.destroy(); }); +}); + +describe.each([ + { + setting: "meta.hardBreakShortcut (deprecated)", + enter: "hardBreakEnter", + none: "hardBreakNone", + plain: "hardBreakEnterPlain", + }, + { + setting: "keyboard", + enter: "keyboardEnter", + none: "keyboardNone", + plain: "keyboardEnterPlain", + }, +] as const)( + "hard breaks configured with $setting", + ({ enter, none, plain }) => { + it("inserts a hard break on Enter when Enter makes line breaks", () => { + const editor = createEditor(enter); - it('inserts a hard break on Enter when hardBreakShortcut is "enter"', () => { - const editor = createEditor("hardBreakEnter"); + pressKeys(editor, "Enter"); - pressKeys(editor, "Enter"); + expect(countHardBreaks(editor)).toBe(1); + expect(editor.document.length).toBe(1); - expect(countHardBreaks(editor)).toBe(1); - expect(editor.document.length).toBe(1); + editor._tiptapEditor.destroy(); + }); - editor._tiptapEditor.destroy(); - }); + it("inserts a hard break on Shift-Enter when Enter makes line breaks", () => { + const editor = createEditor(enter); - it('inserts a hard break on Shift-Enter when hardBreakShortcut is "enter"', () => { - const editor = createEditor("hardBreakEnter"); + pressKeys(editor, "Shift-Enter"); - pressKeys(editor, "Shift-Enter"); + expect(countHardBreaks(editor)).toBe(1); + expect(editor.document.length).toBe(1); - expect(countHardBreaks(editor)).toBe(1); - expect(editor.document.length).toBe(1); + editor._tiptapEditor.destroy(); + }); - editor._tiptapEditor.destroy(); - }); + it("does not insert a hard break on Shift-Enter when it acts as Enter", () => { + const editor = createEditor(none); - it('does not insert a hard break on Shift-Enter when hardBreakShortcut is "none"', () => { - const editor = createEditor("hardBreakNone"); + pressKeys(editor, "Shift-Enter"); - pressKeys(editor, "Shift-Enter"); + expect(countHardBreaks(editor)).toBe(0); - expect(countHardBreaks(editor)).toBe(0); + editor._tiptapEditor.destroy(); + }); - editor._tiptapEditor.destroy(); - }); + it("splits the block on Enter when Shift-Enter acts as Enter", () => { + const editor = createEditor(none); - it('splits the block on Enter when hardBreakShortcut is "none"', () => { - const editor = createEditor("hardBreakNone"); + pressKeys(editor, "Enter"); - pressKeys(editor, "Enter"); + expect(countHardBreaks(editor)).toBe(0); + expect(editor.document.length).toBe(2); - expect(countHardBreaks(editor)).toBe(0); - expect(editor.document.length).toBe(2); + editor._tiptapEditor.destroy(); + }); - editor._tiptapEditor.destroy(); - }); + it('inserts a newline character on Enter when content is "plain"', () => { + const editor = createEditor(plain); - it('inserts a newline character on Enter when content is "plain"', () => { - const editor = createEditor("hardBreakEnterPlain"); + pressKeys(editor, "Enter"); - pressKeys(editor, "Enter"); + // A "plain" block can't hold a `hardBreak` node, so no node is inserted and + // the block is not split - a literal newline is added to its text instead. + expect(countHardBreaks(editor)).toBe(0); + expect(editor.document.length).toBe(1); + expect(getTextContent(editor)).toBe("Hello world\n"); - // A "plain" block can't hold a `hardBreak` node, so no node is inserted and - // the block is not split - a literal newline is added to its text instead. - expect(countHardBreaks(editor)).toBe(0); - expect(editor.document.length).toBe(1); - expect(getTextContent(editor)).toBe("Hello world\n"); + editor._tiptapEditor.destroy(); + }); - editor._tiptapEditor.destroy(); - }); + it('inserts a newline character on Shift-Enter when content is "plain"', () => { + const editor = createEditor(plain); - it('inserts a newline character on Shift-Enter when content is "plain"', () => { - const editor = createEditor("hardBreakEnterPlain"); + pressKeys(editor, "Shift-Enter"); - pressKeys(editor, "Shift-Enter"); + expect(countHardBreaks(editor)).toBe(0); + expect(editor.document.length).toBe(1); + expect(getTextContent(editor)).toBe("Hello world\n"); - expect(countHardBreaks(editor)).toBe(0); - expect(editor.document.length).toBe(1); - expect(getTextContent(editor)).toBe("Hello world\n"); + editor._tiptapEditor.destroy(); + }); + }, +); + +describe("Delete preserves the caret before appended text", () => { + function paragraph(id: string, children: PartialBlock[] = []): PartialBlock { + return { id, type: "paragraph", content: id, children }; + } + it.each([ + { + name: "sole child", + initialContent: [paragraph("selected", [paragraph("removed")])], + }, + { + name: "following shallower block", + initialContent: [ + paragraph("parent", [paragraph("selected")]), + paragraph("removed"), + ], + }, + ])("$name", ({ initialContent }) => { + const editor = BlockNoteEditor.create({ initialContent }); + editor.mount(document.createElement("div")); + editor.setTextCursorPosition("selected", "end"); + + const view = editor.prosemirrorView; + const event = new KeyboardEvent("keydown", { + key: "Delete", + code: "Delete", + keyCode: 46, + }); + view.someProp("handleKeyDown", (handler) => handler(view, event)); + + expect(editor.getBlock("removed")).toBeUndefined(); + expect(editor.getTextCursorPosition().block.id).toBe("selected"); + expect(editor.prosemirrorState.selection.$from.parentOffset).toBe( + "selected".length, + ); editor._tiptapEditor.destroy(); }); }); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 8cb3cc5fab..843129ada1 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,29 +1,80 @@ -import { Extension } from "@tiptap/core"; -import { Fragment, Node } from "prosemirror-model"; -import { NodeSelection, TextSelection } from "prosemirror-state"; +import { type ChainedCommands, Extension } from "@tiptap/core"; +import { Fragment, type Node } from "prosemirror-model"; +import { NodeSelection, TextSelection, Transaction } from "prosemirror-state"; -import { mergeBlocksCommand } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; +import { + mergeBlocksCommand, + getMergeContent, +} from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; import { liftItem, nestBlock, unnestBlock, } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; -import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +import { fixLayoutBlocksById } from "../../../api/blockManipulation/layoutBlocks/fixLayoutBlock.js"; +import { nodeToBlock } from "../../../api/nodeConversions/nodeToBlock.js"; +import { isLayoutNode } from "../../../schema/blocks/children.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { + type BlockInfo, + ascendToInsertablePos, + getInsertionPos, + getAncestorLayoutBlocks, + getFirstLeafBlock, getBlockInfoAt, + getBlockInfoFromNode, getBlockInfoFromSelection, getLastDescendantBlockInfo, getNextBlockInfo, getParentBlockInfo, getPrevBlockInfo, - tableContentCaretPos, + blockEdgeSelection, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; import { FormattingToolbarExtension } from "../../FormattingToolbar/FormattingToolbar.js"; +// Move a block across a layout block boundary, repair its former ancestors, and +// map the caret through any repairs that change the insertion position. +function moveBlockOutAndPlaceCaret( + tr: Transaction, + block: BlockInfo["block"], + insertAt: number, +) { + const layoutBlocksToFix = getAncestorLayoutBlocks(tr.doc, block.beforePos); + tr.delete(block.beforePos, block.afterPos); + const insertionPos = tr.mapping.map(insertAt); + tr.insert(insertionPos, block.node); + const stepsBeforeFix = tr.steps.length; + fixLayoutBlocksById(tr, layoutBlocksToFix); + tr.setSelection( + TextSelection.near( + tr.doc.resolve(tr.mapping.slice(stepsBeforeFix).map(insertionPos) + 1), + ), + ); +} + +// Delete a following block, retaining its children and any compatible text. +// A sole child also removes its child group instead of leaving an empty body. +function deleteBlockAndAppendContent( + chain: ChainedCommands, + current: Extract, + next: Extract, + remove: Pick = next.block, +) { + return chain + .insertContentAt( + next.block.afterPos, + next.children?.node.content || Fragment.empty, + ) + .deleteRange({ from: remove.beforePos, to: remove.afterPos }) + .insertContentAt(current.contentEnd, getMergeContent(current, next) ?? null) + .setTextSelection(current.contentEnd) + .scrollIntoView() + .run(); +} + export const KeyboardShortcutsExtension = Extension.create<{ editor: BlockNoteEditor; tabBehavior: "prefer-navigate-ui" | "prefer-indent"; @@ -33,6 +84,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ // TODO: The shortcuts need a refactor. Do we want to use a command priority // design as there is now, or clump the logic into a single function? addKeyboardShortcuts() { + const bnEditor = this.options.editor; + // The `keyboard` settings of the block that `node` holds, in `doc`. + function keyboardOf(node: Node, doc: Node) { + const block = nodeToBlock(node, doc); + return bnEditor.schema.blockSpecs[block.type].implementation.keyboard( + block, + ); + } + function canOutdentFrom(doc: Node) { + return (parent: Node) => keyboardOf(parent, doc).childrenCanOutdent; + } // handleBackspace is partially adapted from https://github.com/ueberdosis/tiptap/blob/ed56337470efb4fd277128ab7ef792b37cfae992/packages/core/src/extensions/keymap.ts const handleBackspace = () => this.editor.commands.first(({ chain, commands }) => [ @@ -40,7 +102,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.deleteSelection(), // Undoes an input rule if one was triggered in the last editor state change. () => commands.undoInputRule(), - // Reverts block content type to a paragraph if the selection is at the start of the block. + // Resets the block (`keyboard.resetsTo`, a paragraph by default) if the + // selection is at the start of the block and the block isn't already + // in that form. () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -49,20 +113,28 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === blockInfo.content.beforePos + 1; - const isParagraph = - blockInfo.content.node.type.name === "paragraph"; - - if (selectionAtBlockStart && !isParagraph) { - return commands.command( - updateBlockCommand(blockInfo.block.beforePos, { - type: "paragraph", - props: {}, - }), + state.selection.from === blockInfo.contentStart; + if (!selectionAtBlockStart) { + return false; + } + + const resetsTo = keyboardOf( + blockInfo.block.node, + state.doc, + ).resetsTo; + const content = blockInfo.content.node; + const alreadyReset = + content.type.name === resetsTo.type && + Object.entries(resetsTo.props ?? {}).every( + ([prop, value]) => content.attrs[prop] === value, ); + if (alreadyReset) { + return false; } - return false; + return commands.command( + updateBlockCommand(blockInfo.block.beforePos, resetsTo as any), + ); }), // Merges at the start of the block, into the preceding sibling // (or its deepest descendant) or parent. Both must have inline content. @@ -72,20 +144,26 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { block, content } = blockInfo; + const { block: blockContainer } = blockInfo; - // Crossing a column-list boundary moves the block into the last - // column first; the following handler owns that operation. - const prevBlockInfo = getPrevBlockInfo(state.doc, block.beforePos); - if (prevBlockInfo && !prevBlockInfo.hasContent) { + const prevSibling = getPrevBlockInfo( + state.doc, + blockInfo.block.beforePos, + ); + // A preceding layout block takes the move branch below. Otherwise + // the block merges into the block above (its last descendant), or + // into its parent when it has no previous sibling; + // mergeBlocksCommand decides whether their contents are + // compatible. + if (prevSibling && !prevSibling.hasContent) { return false; } const selectionAtBlockStart = - state.selection.from === content.beforePos + 1; + state.selection.from === blockInfo.contentStart; const selectionEmpty = state.selection.empty; - const posBetweenBlocks = block.beforePos; + const posBetweenBlocks = blockContainer.beforePos; if (selectionAtBlockStart && selectionEmpty) { return chain() @@ -96,93 +174,65 @@ 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. + // Move into the preceding layout block's trailing slot, or out of the + // current layout block when this is its first block. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.hasContent) { - return false; - } - - const selectionAtBlockStart = - state.selection.from === blockInfo.content.beforePos + 1; - if (!selectionAtBlockStart) { - return false; - } - - const prevBlockInfo = getPrevBlockInfo( - state.doc, - blockInfo.block.beforePos, - ); - if (!prevBlockInfo || prevBlockInfo.hasContent) { - return false; - } - - if (dispatch) { - const columnAfterPos = prevBlockInfo.block.afterPos - 1; - const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1); - - tr.delete(blockInfo.block.beforePos, blockInfo.block.afterPos); - tr.insert($blockAfterPos.pos, blockInfo.block.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)), - ); - - return true; - } - - 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. - () => - commands.command(({ state, tr, dispatch }) => { - const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.hasContent) { + if ( + !blockInfo.hasContent || + state.selection.from !== blockInfo.contentStart + ) { return false; } - const selectionAtBlockStart = - tr.selection.from === blockInfo.content.beforePos + 1; - if (!selectionAtBlockStart) { - return false; + const blockType = blockInfo.block.node.type; + let target = getPrevBlockInfo(tr.doc, blockInfo.block.beforePos); + let insertionPos: number | undefined; + if (target) { + if (target.hasContent) { + return false; + } + } else { + const $pos = tr.doc.resolve(blockInfo.block.beforePos); + if (!isLayoutNode($pos.parent.type)) { + return false; + } + const $layoutBlockPos = tr.doc.resolve($pos.before()); + // Between columns, move into the previous column. Outside a + // layout block, move above the closest boundary that accepts us. + const prevSibling = $layoutBlockPos.nodeBefore; + if ( + isLayoutNode($layoutBlockPos.parent.type) && + prevSibling && + isLayoutNode(prevSibling.type) + ) { + target = getBlockInfoFromNode( + prevSibling, + $layoutBlockPos.pos - prevSibling.nodeSize, + ); + } else { + insertionPos = ascendToInsertablePos( + tr.doc, + $layoutBlockPos.pos, + blockType, + ); + } } - - const $pos = tr.doc.resolve(blockInfo.block.beforePos); - - const prevBlock = $pos.nodeBefore; - if (prevBlock) { - return false; + if (target) { + insertionPos = getInsertionPos( + tr.doc, + target, + "last-child", + blockType, + )?.pos; } - - const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (insertionPos === undefined) { return false; } - - const $blockPos = tr.doc.resolve(blockInfo.block.beforePos); - const $columnPos = tr.doc.resolve($blockPos.before()); - const columnListPos = $columnPos.before(); - if (dispatch) { - tr.delete(blockInfo.block.beforePos, blockInfo.block.afterPos); - fixColumnList(tr, columnListPos); - - if ($columnPos.pos === columnListPos + 1) { - tr.insert(columnListPos, blockInfo.block.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve(columnListPos)), - ); - } else { - tr.insert($columnPos.pos - 1, blockInfo.block.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve($columnPos.pos)), - ); - } + moveBlockOutAndPlaceCaret(tr, blockInfo.block, insertionPos); } - return true; }), // Removes an empty inline block when merging is impossible. Its @@ -245,7 +295,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === blockInfo.content.beforePos + 1; + state.selection.from === blockInfo.contentStart; const selectionEmpty = state.selection.empty; const prevBlockInfo = getPrevBlockInfo( @@ -254,6 +304,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ ); if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) { + // An emptied layout block has no content to merge with, so the + // guard below rejects it — the merge branch above only fires + // for a previous block with content of its own. const bottomBlock = getLastDescendantBlockInfo(prevBlockInfo); if (!bottomBlock.hasContent) { @@ -305,6 +358,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ tr, tr.doc.type.schema.nodes["blockContainer"], tr.doc.type.schema.nodes["blockGroup"], + canOutdentFrom(tr.doc), ); }), ]); @@ -324,56 +378,35 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent || !blockInfo.children) { return false; } - const { content, children } = blockInfo; + const { children } = blockInfo; + + // A block can have an empty children node (the `childContainer` + // group), but then there is no first child to pull anything out + // of. + if (children.node.childCount === 0) { + return false; + } const selectionAtBlockEnd = - state.selection.from === content.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; const firstChildBlockInfo = getBlockInfoAt( state.doc, - children.beforePos + 1, + children.childrenStart, ); if (!firstChildBlockInfo.hasContent) { return false; } if (selectionAtBlockEnd && selectionEmpty) { - const firstChildBlockContent = firstChildBlockInfo.content.node; - const firstChildBlockHasInlineContent = - firstChildBlockInfo.contentKind === "inline"; - const blockHasInlineContent = blockInfo.contentKind === "inline"; - - return ( - chain() - // Un-nests child block's children if necessary. - .insertContentAt( - firstChildBlockInfo.block.afterPos, - firstChildBlockInfo.children?.node.content || - Fragment.empty, - ) - .deleteRange( - // Deletes whole child container if there's only one child. - children.node.childCount === 1 - ? { - from: children.beforePos, - to: children.afterPos, - } - : { - from: firstChildBlockInfo.block.beforePos, - to: firstChildBlockInfo.block.afterPos, - }, - ) - // Appends inline content from child block if possible. - .insertContentAt( - state.selection.from, - firstChildBlockHasInlineContent && blockHasInlineContent - ? firstChildBlockContent.content - : null, - ) - .setTextSelection(state.selection.from) - .scrollIntoView() - .run() + return deleteBlockAndAppendContent( + chain(), + blockInfo, + firstChildBlockInfo, + children.node.childCount === 1 + ? children + : firstChildBlockInfo.block, ); } @@ -388,7 +421,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { block, content } = blockInfo; + const { block: blockContainer } = blockInfo; const nextBlockInfo = getNextBlockInfo( state.doc, @@ -399,10 +432,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockEnd = - state.selection.from === content.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; - const posBetweenBlocks = block.afterPos; + const posBetweenBlocks = blockContainer.afterPos; if (selectionAtBlockEnd && selectionEmpty) { return chain() @@ -413,103 +446,49 @@ 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. + // Pull the next leaf across a layout block boundary. It may be inside + // the next sibling layout block, or follow the layout blocks we're leaving. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.hasContent) { - return false; - } - - const selectionAtBlockEnd = - state.selection.from === blockInfo.content.afterPos - 1; - if (!selectionAtBlockEnd) { - return false; - } - - const nextBlockInfo = getNextBlockInfo( - state.doc, - blockInfo.block.beforePos, - ); - if (!nextBlockInfo || nextBlockInfo.hasContent) { + if ( + !blockInfo.hasContent || + state.selection.from !== blockInfo.contentEnd + ) { return false; } - if (dispatch) { - const columnBeforePos = nextBlockInfo.block.beforePos + 1; - const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); - - tr.delete( - $blockBeforePos.pos, - $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, - ); - fixColumnList(tr, nextBlockInfo.block.beforePos); - tr.insert(blockInfo.block.afterPos, $blockBeforePos.nodeAfter!); - tr.setSelection( - TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), - ); - - return true; + let $boundary = tr.doc.resolve(blockInfo.block.afterPos); + while ( + !$boundary.nodeAfter && + $boundary.depth > 0 && + isLayoutNode($boundary.parent.type) + ) { + $boundary = tr.doc.resolve($boundary.after()); } - - 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. - () => - commands.command(({ state, tr, dispatch }) => { - const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.hasContent) { + const nextNode = $boundary.nodeAfter; + if (!nextNode) { return false; } - const selectionAtBlockEnd = - tr.selection.from === blockInfo.content.afterPos - 1; - if (!selectionAtBlockEnd) { + const crossedBoundary = $boundary.pos !== blockInfo.block.afterPos; + if (!crossedBoundary && !isLayoutNode(nextNode.type)) { return false; } - - const $pos = tr.doc.resolve(blockInfo.block.afterPos); - - const nextBlock = $pos.nodeAfter; - if (nextBlock) { - return false; - } - - const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + const target = getFirstLeafBlock( + getBlockInfoFromNode(nextNode, $boundary.pos), + ); + if (!target) { return false; } - const $blockEndPos = tr.doc.resolve(blockInfo.block.afterPos); - const $columnEndPos = tr.doc.resolve($blockEndPos.after()); - const columnListEndPos = $columnEndPos.after(); - 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 = getBlockInfoAt(tr.doc, nextBlockBeforePos); - - tr.delete( - nextBlockInfo.block.beforePos, - nextBlockInfo.block.afterPos, - ); - fixColumnList( + moveBlockOutAndPlaceCaret( tr, - columnListEndPos - $columnEndPos.node().nodeSize, - ); - tr.insert($blockEndPos.pos, nextBlockInfo.block.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), + target.block, + blockInfo.block.afterPos, ); } - return true; }), // Deletes the next block at either the same or lower nesting level, if @@ -523,66 +502,36 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { content } = blockInfo; const selectionAtBlockEnd = - state.selection.from === content.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; if (selectionAtBlockEnd && selectionEmpty) { - const getNextBlockInfoAtAnyLevel = ( - doc: Node, - beforePos: number, - ) => { - const nextBlockInfo = getNextBlockInfo(doc, beforePos); + let nextBlockInfo: BlockInfo | undefined; + let ancestor: BlockInfo | undefined = blockInfo; + while (ancestor) { + nextBlockInfo = getNextBlockInfo( + state.doc, + ancestor.block.beforePos, + ); if (nextBlockInfo) { - return nextBlockInfo; - } - - const parentBlockInfo = getParentBlockInfo(doc, beforePos); - if (!parentBlockInfo) { - return undefined; + break; } - - return getNextBlockInfoAtAnyLevel( - doc, - parentBlockInfo.block.beforePos, + ancestor = getParentBlockInfo( + state.doc, + ancestor.block.beforePos, ); - }; - - const nextBlockInfo = getNextBlockInfoAtAnyLevel( - state.doc, - blockInfo.block.beforePos, - ); + } if (!nextBlockInfo || !nextBlockInfo.hasContent) { return false; } - const nextBlockHasInlineContent = - nextBlockInfo.contentKind === "inline"; - const blockHasInlineContent = blockInfo.contentKind === "inline"; - - return ( - chain() - // Un-nests next block's children if necessary. - .insertContentAt( - nextBlockInfo.block.afterPos, - nextBlockInfo.children?.node.content || Fragment.empty, - ) - .deleteRange({ - from: nextBlockInfo.block.beforePos, - to: nextBlockInfo.block.afterPos, - }) - // Appends inline content from child block if possible. - .insertContentAt( - state.selection.from, - nextBlockHasInlineContent && blockHasInlineContent - ? nextBlockInfo.content.node.content - : null, - ) - .setTextSelection(state.selection.from) - .scrollIntoView() - .run() + return deleteBlockAndAppendContent( + chain(), + blockInfo, + nextBlockInfo, + nextBlockInfo.block, ); } @@ -598,8 +547,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const blockEmpty = - blockInfo.content.node.childCount === 0 && - blockInfo.contentKind === "inline"; + blockInfo.isContentEmpty && blockInfo.contentKind === "inline"; if (blockEmpty) { const nextBlockInfo = getNextBlockInfo( @@ -610,21 +558,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - let chainedCommands = chain(); + const chainedCommands = chain(); - if (nextBlockInfo.contentKind === "table") { - chainedCommands = chainedCommands.setTextSelection( - tableContentCaretPos(nextBlockInfo.content, "start"), - ); - } else if (nextBlockInfo.contentKind === "none") { - chainedCommands = chainedCommands.setNodeSelection( - nextBlockInfo.content.beforePos, - ); - } else { - chainedCommands = chainedCommands.setTextSelection( - nextBlockInfo.content.beforePos + 1, + chainedCommands.command(({ tr }) => { + tr.setSelection( + blockEdgeSelection(tr.doc, nextBlockInfo, "start"), ); - } + return true; + }); return chainedCommands .deleteRange({ @@ -649,7 +590,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockEnd = - state.selection.from === blockInfo.content.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; const nextBlockInfo = getNextBlockInfo( @@ -670,7 +611,6 @@ export const KeyboardShortcutsExtension = Extension.create<{ nextBlockInfo.isContentEmpty); if (nextBlockNotTableAndNoContent) { - const childBlocks = nextBlockInfo.block.node.lastChild!.content; return chain() .deleteRange({ from: nextBlockInfo.block.beforePos, @@ -678,9 +618,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ }) .insertContentAt( blockInfo.block.afterPos, - nextBlockInfo.block.node.childCount === 2 - ? childBlocks - : null, + nextBlockInfo.children?.node.content ?? null, ) .run(); } @@ -692,6 +630,30 @@ export const KeyboardShortcutsExtension = Extension.create<{ const handleEnter = (withShift = false) => { return this.editor.commands.first(({ commands, tr }) => [ + // Resets an empty block (`keyboard.resetsTo`) if it resets on Enter + // (`keyboard.emptyEnterResets`), e.g. an empty list item turns into a + // paragraph. Its children stay. + () => + commands.command(({ state }) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.hasContent || + !state.selection.empty || + !blockInfo.isContentEmpty + ) { + return false; + } + const keyboard = keyboardOf(blockInfo.block.node, state.doc); + if (!keyboard.emptyEnterResets) { + return false; + } + return commands.command( + updateBlockCommand( + blockInfo.block.beforePos, + keyboard.resetsTo as any, + ), + ); + }), // Removes a level of nesting if the block is empty & indented, while the selection is also empty & at the start // of the block. () => @@ -700,15 +662,15 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { block, content } = blockInfo; + const { block: blockContainer } = blockInfo; - const { depth } = state.doc.resolve(block.beforePos); + const { depth } = state.doc.resolve(blockContainer.beforePos); const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; const selectionEmpty = state.selection.anchor === state.selection.head; - const blockEmpty = content.node.childCount === 0; + const blockEmpty = blockInfo.isContentEmpty; const blockIndented = depth > 1; if ( @@ -717,16 +679,32 @@ export const KeyboardShortcutsExtension = Extension.create<{ blockEmpty && blockIndented ) { + // Only outdents where the parent says so + // (`keyboard.emptyChildEnter: "outdent"`); otherwise the block + // leaves at the end, or a new child is added, further down. + const parent = getParentBlockInfo( + state.doc, + blockContainer.beforePos, + ); + if ( + parent && + keyboardOf(parent.block.node, state.doc).emptyChildEnter !== + "outdent" + ) { + return false; + } return liftItem( tr, tr.doc.type.schema.nodes["blockContainer"], tr.doc.type.schema.nodes["blockGroup"], + canOutdentFrom(tr.doc), ); } return false; }), - // Creates a hard break if block is configured to do so. + // Creates a hard break if the block is configured to do so + // (`keyboard.enter` / `keyboard.shiftEnter`). () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -734,29 +712,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ const blockSpec = this.options.editor.schema.blockSpecs[blockInfo.blockNoteType]; - // NOTE: This likely doesn't work as intended - `blockSchema[type]` - // holds the block *config* (type/propSchema/content), which carries - // no `meta`, so `meta?.hardBreakShortcut` is always `undefined` and - // this falls back to the default. It should read from the block - // spec's implementation instead (i.e. - // `editor.schema.blockSpecs[type].implementation.meta`), the way the - // syntax-highlighting extension reads `meta.highlight`. Left as-is - // for a follow-up pass. - const blockHardBreakShortcut = - blockSpec?.implementation?.meta?.hardBreakShortcut ?? - "shift+enter"; - - if (blockHardBreakShortcut === "none") { - return false; - } + const keyboard = keyboardOf(blockInfo.block.node, state.doc); if ( - // If shortcut is not configured, or is configured as "shift+enter", - // create a hard break for shift+enter, but not for enter. - (blockHardBreakShortcut === "shift+enter" && withShift) || - // If shortcut is configured as "enter", create a hard break for - // both enter and shift+enter. - blockHardBreakShortcut === "enter" + // Enter as a line break makes Shift-Enter one too. + keyboard.enter === "line-break" || + (withShift && keyboard.shiftEnter === "line-break") ) { // "plain" blocks (e.g. code/math/diagram source) hold text only // (their content is `text*`), which can't contain a `hardBreak` @@ -786,6 +747,70 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the block is empty and the last child of a block whose empty + // children exit at the end (`keyboard.emptyChildEnter: + // "exit-at-end"`, the default for layout blocks), moves the block out + // (double Enter exits the layout block). 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 layout block, so the cursor could + // never leave a trailing layout block. Shift+Enter still adds spacing + // inside a layout block. The first block of a body stays put: it is + // where the body begins, not a way out of it. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.hasContent) { + return false; + } + + const selectionEmpty = + state.selection.anchor === state.selection.head; + const blockEmpty = blockInfo.isContentEmpty; + if (!selectionEmpty || !blockEmpty) { + return false; + } + + const $pos = tr.doc.resolve(blockInfo.block.beforePos); + // Only fires on the layout block's last child. + if (tr.doc.resolve(blockInfo.block.afterPos).nodeAfter !== null) { + return false; + } + + const owner = getParentBlockInfo(tr.doc, blockInfo.block.beforePos); + if (!owner) { + return false; + } + if ( + keyboardOf(owner.block.node, tr.doc).emptyChildEnter !== + "exit-at-end" + ) { + return false; + } + // The first block of a body stays put: it is where the body + // begins, not a way out of it. (A layout block's own first child has + // no such role, so it may still leave.) + if ($pos.index() === 0 && owner.hasContent) { + return false; + } + + const ownerAfterPos = ascendToInsertablePos( + tr.doc, + owner.block.afterPos, + state.schema.nodes["blockContainer"], + "after", + ); + if (ownerAfterPos === undefined) { + return false; + } + + if (dispatch) { + moveBlockOutAndPlaceCaret(tr, blockInfo.block, ownerAfterPos); + 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. () => @@ -794,16 +819,22 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { block, content } = blockInfo; + const { block: blockContainer } = blockInfo; const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; const selectionEmpty = state.selection.anchor === state.selection.head; - const blockEmpty = content.node.childCount === 0; + const blockEmpty = blockInfo.isContentEmpty; - if (selectionAtBlockStart && selectionEmpty && blockEmpty) { - const newBlockInsertionPos = block.afterPos; + if ( + selectionAtBlockStart && + selectionEmpty && + blockEmpty && + keyboardOf(blockInfo.block.node, state.doc).enter !== + "into-children" + ) { + const newBlockInsertionPos = blockContainer.afterPos; const newBlockContentPos = newBlockInsertionPos + 2; if (dispatch) { @@ -842,6 +873,105 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // Enter at the start of non-empty content inserts an empty block above + // it: the same type when the block's splits keep their type (lists), + // a paragraph otherwise, with default props. The block itself, with + // its id, props (e.g. a checklist item's checked state) and children, + // stays where it is (#550). + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if ( + !blockInfo.hasContent || + blockInfo.contentKind === "table" || + !state.selection.empty || + blockInfo.isContentEmpty || + state.selection.from !== blockInfo.contentStart + ) { + return false; + } + + if (dispatch) { + const contentType = keyboardOf(blockInfo.block.node, state.doc) + .splitKeepsType + ? blockInfo.content.node.type + : state.schema.nodes["paragraph"]; + const newBlock = state.schema.nodes["blockContainer"].create( + undefined, + contentType.create(), + ); + tr.insert(blockInfo.block.beforePos, newBlock).scrollIntoView(); + } + + return true; + }), + // Enter in the content of a block whose Enter goes into its children + // (`keyboard.enter: "into-children"`, e.g. an open toggle) starts its + // children rather than splitting the block in two: whatever follows + // the cursor becomes the first child, and the existing children stay + // where they are. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.hasContent) { + return false; + } + + if ( + keyboardOf(blockInfo.block.node, state.doc).enter !== + "into-children" + ) { + return false; + } + if (!state.selection.empty) { + return false; + } + if ( + state.selection.from < blockInfo.contentStart || + state.selection.from > blockInfo.contentEnd + ) { + return false; + } + + if (dispatch) { + // Everything after the cursor moves into the new block, so + // splitting the title mid-way puts its tail at the top of the + // body instead of handing the body to a new sibling. + const tail = blockInfo.content.node.cut( + state.selection.from - blockInfo.contentStart, + ); + const newBlock = state.schema.nodes["blockContainer"].create( + undefined, + state.schema.nodes["paragraph"].create(undefined, tail.content), + ); + + tr.delete(state.selection.from, blockInfo.contentEnd); + + const body = getBlockInfoAt( + tr.doc, + blockInfo.block.beforePos, + ).children; + // Without a body yet, one is created around the new block. + const insertPos = body + ? body.childrenStart + : tr.mapping.map(blockInfo.content.afterPos); + tr.insert( + insertPos, + body + ? newBlock + : state.schema.nodes["blockGroup"].create( + undefined, + newBlock, + ), + ) + .setSelection( + new TextSelection(tr.doc.resolve(insertPos + (body ? 2 : 3))), + ) + .scrollIntoView(); + } + + return true; + }), // Splits the current block, moving content inside that's after the cursor to a new text block below. Also // deletes the selection beforehand, if it's not empty. () => @@ -850,20 +980,22 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { content } = blockInfo; const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; - const blockEmpty = content.node.childCount === 0; + const blockEmpty = blockInfo.isContentEmpty; if (!blockEmpty) { + const keyboard = keyboardOf(blockInfo.block.node, state.doc); + const keepType = selectionAtBlockStart || keyboard.splitKeepsType; chain() .deleteSelection() .command( splitBlockCommand( state.selection.from, + keepType, selectionAtBlockStart, - selectionAtBlockStart, + keyboard.splitKeepsChildren, ), ) .scrollIntoView() @@ -877,42 +1009,27 @@ export const KeyboardShortcutsExtension = Extension.create<{ ]); }; + const options = this.options; + function handleTab(shift: boolean) { + const { editor, tabBehavior } = options; + if ( + tabBehavior !== "prefer-indent" && + (editor.getExtension(FormattingToolbarExtension)?.store.state || + editor.getExtension(FilePanelExtension)?.store.state !== undefined) + ) { + // Let the browser navigate into and out of an open toolbar. + return false; + } + return shift ? unnestBlock(editor) : nestBlock(editor); + } + return { Backspace: handleBackspace, Delete: handleDelete, Enter: () => handleEnter(), "Shift-Enter": () => handleEnter(true), - // Always returning true for tab key presses ensures they're not captured by the browser. Otherwise, they blur the - // editor since the browser will try to use tab for keyboard navigation. - Tab: () => { - if ( - this.options.tabBehavior !== "prefer-indent" && - (this.options.editor.getExtension(FormattingToolbarExtension)?.store - .state || - this.options.editor.getExtension(FilePanelExtension)?.store - .state !== undefined) - // TODO need to check if the link toolbar is open or another alternative entirely - ) { - // don't handle tabs if a toolbar is shown, so we can tab into / out of it - return false; - } - return nestBlock(this.options.editor); - }, - "Shift-Tab": () => { - if ( - this.options.tabBehavior !== "prefer-indent" && - (this.options.editor.getExtension(FormattingToolbarExtension)?.store - .state || - this.options.editor.getExtension(FilePanelExtension)?.store - .state !== undefined) - // TODO need to check if the link toolbar is open or another alternative entirely - // other menu types? - ) { - // don't handle tabs if a toolbar is shown, so we can tab into / out of it - return false; - } - return unnestBlock(this.options.editor); - }, + Tab: () => handleTab(false), + "Shift-Tab": () => handleTab(true), "Shift-Mod-ArrowUp": () => { this.options.editor.moveBlocksUp(); return true; diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts new file mode 100644 index 0000000000..b0366f9f7a --- /dev/null +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/blockIdentity.browser.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import type { PartialBlock } from "../../../blocks/defaultBlocks.js"; + +// Editing at the start of a block keeps the block's identity (#550): Enter +// keeps its id and props, such as a checklist item's checked state. Backspace +// below an empty block of the same type and props moves the block up, keeping +// its id and children. Below an empty block of another type, the text moves +// into that block, which keeps its id, type and props, as in Notion (#3124). + +let editor: BlockNoteEditor; +let root: HTMLElement; + +function mount(content: PartialBlock[]) { + root = document.createElement("div"); + document.body.appendChild(root); + editor = BlockNoteEditor.create({ initialContent: content }); + editor.mount(root); +} + +afterEach(() => { + editor._tiptapEditor.destroy(); + root.remove(); +}); + +async function press( + key: string, + at: { block: string; placement?: "start" | "end" }, +) { + editor.setTextCursorPosition(at.block, at.placement ?? "start"); + editor.focus(); + await userEvent.keyboard(`{${key}}`); +} + +describe("Enter at the start of a non-empty block", () => { + it("inserts an empty paragraph above a paragraph, which keeps its id", async () => { + mount([{ id: "p", type: "paragraph", content: "Text" }]); + + await press("Enter", { block: "p" }); + + const [inserted, block] = editor.document; + expect(inserted.type).toBe("paragraph"); + expect(inserted.content).toEqual([]); + expect(block.id).toBe("p"); + expect(editor.getTextCursorPosition().block.id).toBe("p"); + }); + + it("keeps a checklist item's checked state with its text", async () => { + mount([ + { + id: "c", + type: "checkListItem", + props: { checked: true }, + content: "Done", + }, + ]); + + await press("Enter", { block: "c" }); + + const [inserted, block] = editor.document; + expect(inserted.type).toBe("checkListItem"); + expect(inserted.props).toMatchObject({ checked: false }); + expect(block.id).toBe("c"); + expect(block.props).toMatchObject({ checked: true }); + }); + + it("inserts an empty paragraph above a heading, which keeps its id and level", async () => { + mount([ + { id: "h", type: "heading", props: { level: 3 }, content: "Heading" }, + ]); + + await press("Enter", { block: "h" }); + + const [inserted, block] = editor.document; + expect(inserted.type).toBe("paragraph"); + expect(block.id).toBe("h"); + expect(block.props).toMatchObject({ level: 3 }); + }); + + it("keeps the block's children with it", async () => { + mount([ + { + id: "p", + type: "paragraph", + content: "Parent", + children: [{ id: "c1", type: "paragraph", content: "Child" }], + }, + ]); + + await press("Enter", { block: "p" }); + + const [inserted, block] = editor.document; + expect(inserted.children).toHaveLength(0); + expect(block.id).toBe("p"); + expect(block.children.map((child) => child.id)).toEqual(["c1"]); + }); +}); + +describe("Backspace at the start of a block after an empty block", () => { + it("moves the block up when the empty block has the same type and props, keeping its id and children", async () => { + mount([ + { id: "empty", type: "paragraph" }, + { + id: "p", + type: "paragraph", + content: "Text", + children: [{ id: "c", type: "paragraph", content: "Child" }], + }, + ]); + + await press("Backspace", { block: "p" }); + + expect(editor.document.map((block) => block.id)).toEqual(["p"]); + expect(editor.getBlock("p")!.children.map((child) => child.id)).toEqual([ + "c", + ]); + expect(editor.getTextCursorPosition().block.id).toBe("p"); + }); + + it("moves the text into an empty block of another type, which keeps its id, type and props", async () => { + mount([ + { id: "empty", type: "heading", props: { level: 2 } }, + { id: "p", type: "paragraph", content: "Text" }, + ]); + + await press("Backspace", { block: "p" }); + + const [block] = editor.document; + expect(editor.document).toHaveLength(1); + expect(block.id).toBe("empty"); + expect(block.type).toBe("heading"); + expect(block.props).toMatchObject({ level: 2 }); + expect(block.content).toEqual([{ type: "text", text: "Text", styles: {} }]); + expect(editor.getTextCursorPosition().block.id).toBe("empty"); + }); +}); + +describe("Delete in an empty block before a block", () => { + it("moves the next block up when it has the same type and props, keeping its id", async () => { + mount([ + { id: "empty", type: "paragraph" }, + { id: "p", type: "paragraph", content: "Text" }, + ]); + + await press("Delete", { block: "empty", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual(["p"]); + expect(editor.getTextCursorPosition().block.id).toBe("p"); + }); +}); diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index 1c19b810dd..f275cbb081 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -184,6 +184,7 @@ export const ar: Dictionary = { }, toggle_blocks: { add_block_button: "تبديل فارغ. انقر لإضافة كتلة.", + toggle_button: "توسيع أو طي", }, code_block: { add_source_button_text: "إضافة كود المصدر", diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 45ff9341d8..a844a3bb87 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -220,6 +220,7 @@ export const de: Dictionary = { toggle_blocks: { add_block_button: "Leerer aufklappbarer Bereich. Klicken, um einen Block hinzuzufügen.", + toggle_button: "Aufklappen oder zuklappen", }, code_block: { add_source_button_text: "Quellcode hinzufügen", diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index 307ba90c22..3093145710 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -199,6 +199,7 @@ export const en = { }, toggle_blocks: { add_block_button: "Empty toggle. Click to add a block.", + toggle_button: "Expand or collapse", }, code_block: { add_source_button_text: "Add source code", diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index b2c05ca6b2..64b77f59a7 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -199,6 +199,7 @@ export const es: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle vacío. Haz clic para añadir un bloque.", + toggle_button: "Expandir o contraer", }, code_block: { add_source_button_text: "Agregar código fuente", diff --git a/packages/core/src/i18n/locales/fa.ts b/packages/core/src/i18n/locales/fa.ts index 405cf87ddf..f50e0c9dbb 100644 --- a/packages/core/src/i18n/locales/fa.ts +++ b/packages/core/src/i18n/locales/fa.ts @@ -167,6 +167,7 @@ export const fa = { }, toggle_blocks: { add_block_button: "تاشوی خالی. برای افزودن بلوک کلیک کنید.", + toggle_button: "باز یا بسته کردن", }, code_block: { add_source_button_text: "افزودن کد منبع", diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 4807927655..ac2e5ddb14 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -245,6 +245,7 @@ export const fr: Dictionary = { }, toggle_blocks: { add_block_button: "Liste repliable vide. Cliquez pour ajouter un bloc.", + toggle_button: "Déplier ou replier", }, code_block: { add_source_button_text: "Ajouter le code source", diff --git a/packages/core/src/i18n/locales/he.ts b/packages/core/src/i18n/locales/he.ts index 1b9338b77b..ef2d86f869 100644 --- a/packages/core/src/i18n/locales/he.ts +++ b/packages/core/src/i18n/locales/he.ts @@ -201,6 +201,7 @@ export const he: Dictionary = { }, toggle_blocks: { add_block_button: "מתג ריק. לחץ כדי להוסיף בלוק.", + toggle_button: "הרחבה או כיווץ", }, code_block: { add_source_button_text: "הוסף קוד מקור", diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index 998a245f20..1cabe84f5e 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -212,6 +212,7 @@ export const hr: Dictionary = { }, toggle_blocks: { add_block_button: "Prazan sklopivi blok. Klikni da dodaš sadržaj.", + toggle_button: "Proširi ili sažmi", }, code_block: { add_source_button_text: "Dodaj izvorni kôd", diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index e7effe3827..419d72043a 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -213,6 +213,7 @@ export const is: Dictionary = { }, toggle_blocks: { add_block_button: "Tóm fellilína. Smelltu til að bæta við blokk.", + toggle_button: "Fella út eða inn", }, code_block: { add_source_button_text: "Bæta við frumkóða", diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts index 782a3c7fc4..6e2fa9c46f 100644 --- a/packages/core/src/i18n/locales/it.ts +++ b/packages/core/src/i18n/locales/it.ts @@ -221,6 +221,7 @@ export const it: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle vuoto. Clicca per aggiungere un blocco.", + toggle_button: "Espandi o comprimi", }, code_block: { add_source_button_text: "Aggiungi codice sorgente", diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index 8bac14021d..521e77ae3f 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -239,6 +239,7 @@ export const ja: Dictionary = { }, toggle_blocks: { add_block_button: "空のトグルです。クリックしてブロックを追加。", + toggle_button: "展開または折りたたみ", }, code_block: { add_source_button_text: "ソースコードを追加", diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index de94329b19..9bdd466fbc 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -212,6 +212,7 @@ export const ko: Dictionary = { }, toggle_blocks: { add_block_button: "비어 있는 토글입니다. 클릭하여 블록을 추가하세요.", + toggle_button: "펼치기 또는 접기", }, code_block: { add_source_button_text: "소스 코드 추가", diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index a90210b572..7d515e0750 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -200,6 +200,7 @@ export const nl: Dictionary = { }, toggle_blocks: { add_block_button: "Lege uitklapper. Klik om een blok toe te voegen.", + toggle_button: "Uitklappen of inklappen", }, code_block: { add_source_button_text: "Broncode toevoegen", diff --git a/packages/core/src/i18n/locales/no.ts b/packages/core/src/i18n/locales/no.ts index 9ed6388dc7..34534d07f8 100644 --- a/packages/core/src/i18n/locales/no.ts +++ b/packages/core/src/i18n/locales/no.ts @@ -218,6 +218,7 @@ export const no: Dictionary = { }, toggle_blocks: { add_block_button: "Tomt toggle. Klikk for å legge til en blokk.", + toggle_button: "Utvid eller skjul", }, code_block: { add_source_button_text: "Legg til kildekode", diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index 95751640b9..da6055e1d0 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -191,6 +191,7 @@ export const pl: Dictionary = { toggle_blocks: { add_block_button: "Brak bloków do rozwinięcia. Kliknij, aby dodać pierwszego.", + toggle_button: "Rozwiń lub zwiń", }, code_block: { add_source_button_text: "Dodaj kod źródłowy", diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index 6914de9d2c..c47c3b4f74 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -191,6 +191,7 @@ export const pt: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle vazio. Clique para adicionar um bloco.", + toggle_button: "Expandir ou recolher", }, code_block: { add_source_button_text: "Adicionar código-fonte", diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index db116a3c4c..3bb8cbf251 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -242,6 +242,7 @@ export const ru: Dictionary = { }, toggle_blocks: { add_block_button: "Пустой переключатель. Нажмите, чтобы добавить блок.", + toggle_button: "Развернуть или свернуть", }, code_block: { add_source_button_text: "Добавить исходный код", diff --git a/packages/core/src/i18n/locales/sk.ts b/packages/core/src/i18n/locales/sk.ts index f53c4c39d1..1aa1c1d8ad 100644 --- a/packages/core/src/i18n/locales/sk.ts +++ b/packages/core/src/i18n/locales/sk.ts @@ -199,6 +199,7 @@ export const sk = { }, toggle_blocks: { add_block_button: "Prázdne prepínanie. Kliknite pre pridanie bloku.", + toggle_button: "Rozbaliť alebo zbaliť", }, code_block: { add_source_button_text: "Pridať zdrojový kód", diff --git a/packages/core/src/i18n/locales/uk.ts b/packages/core/src/i18n/locales/uk.ts index e6101c8f69..7f0b80ae38 100644 --- a/packages/core/src/i18n/locales/uk.ts +++ b/packages/core/src/i18n/locales/uk.ts @@ -224,6 +224,7 @@ export const uk: Dictionary = { }, toggle_blocks: { add_block_button: "Порожній перемикач. Натисніть, щоб додати блок.", + toggle_button: "Розгорнути або згорнути", }, code_block: { add_source_button_text: "Додати вихідний код", diff --git a/packages/core/src/i18n/locales/uz.ts b/packages/core/src/i18n/locales/uz.ts index 23b0f4f1a7..826e64bfcf 100644 --- a/packages/core/src/i18n/locales/uz.ts +++ b/packages/core/src/i18n/locales/uz.ts @@ -260,6 +260,7 @@ export const uz: Dictionary = { toggle_blocks: { add_block_button: "Bo‘sh toggle. Blok qo‘shish uchun bosing.", + toggle_button: "Yoyish yoki yig‘ish", }, code_block: { diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index d52db4d48d..3373d7be16 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -198,6 +198,7 @@ export const vi: Dictionary = { }, toggle_blocks: { add_block_button: "Toggle trống. Nhấp để thêm khối.", + toggle_button: "Mở rộng hoặc thu gọn", }, code_block: { add_source_button_text: "Thêm mã nguồn", diff --git a/packages/core/src/i18n/locales/zh-tw.ts b/packages/core/src/i18n/locales/zh-tw.ts index 0aba71ead4..333e9a2563 100644 --- a/packages/core/src/i18n/locales/zh-tw.ts +++ b/packages/core/src/i18n/locales/zh-tw.ts @@ -240,6 +240,7 @@ export const zhTW: Dictionary = { }, toggle_blocks: { add_block_button: "空的切換區。點擊新增區塊。", + toggle_button: "展開或收合", }, code_block: { add_source_button_text: "新增原始碼", diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 0017c86672..22dbc5b19a 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -240,6 +240,7 @@ export const zh: Dictionary = { }, toggle_blocks: { add_block_button: "空的切换区。点击添加区块。", + toggle_button: "展开或收起", }, code_block: { add_source_button_text: "添加源代码", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index de5faa2fc7..4f4fea61c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,12 @@ 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"; +export { isLayoutNode } from "./schema/blocks/children.js"; +export { getBlockElementForNode } from "./extensions/blockDOM.js"; +export { applyLayoutBlockAttributes } from "./schema/blocks/layoutBlockAttributes.js"; +export { + fixLayoutBlock, + isEmptyLayoutChild, +} from "./api/blockManipulation/layoutBlocks/fixLayoutBlock.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/pm-nodes/BlockContainer.test.ts b/packages/core/src/pm-nodes/BlockContainer.test.ts new file mode 100644 index 0000000000..061c6552b3 --- /dev/null +++ b/packages/core/src/pm-nodes/BlockContainer.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; + +// `Block.css` applies a block's colors to its children from the block's own +// element, so the node view keeps them on `.bn-block`, framed or not. +describe("block colors on the block's element", () => { + const blockElement = (editor: BlockNoteEditor, id: string) => + editor.domElement!.querySelector( + `[data-node-type="blockContainer"][data-id="${id}"]`, + )!; + + for (const [type, props] of [ + ["paragraph", {}], + ["heading", {}], + ["toggleListItem", {}], + ["heading", { isToggleable: true }], + ] as const) { + it(`puts them on a ${type} ${JSON.stringify(props)} and follows changes`, () => { + const editor = BlockNoteEditor.create({ + initialContent: [ + { + id: "parent", + type, + props: { ...props, textColor: "red" }, + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + } as any, + ], + }); + editor.mount(document.createElement("div")); + try { + const element = () => blockElement(editor, "parent"); + expect(element().getAttribute("data-text-color")).toBe("red"); + expect(element().hasAttribute("data-background-color")).toBe(false); + + editor.updateBlock("parent", { + props: { textColor: "default", backgroundColor: "blue" }, + } as any); + expect(element().hasAttribute("data-text-color")).toBe(false); + expect(element().getAttribute("data-background-color")).toBe("blue"); + } finally { + editor._tiptapEditor.destroy(); + } + }); + } +}); + +// As with ProseMirror's default, a change of the block's type keeps its view +// (and its children's), unless the new type draws a frame or the old one did. +describe("block type changes", () => { + function editorWithParent() { + const editor = BlockNoteEditor.create({ + initialContent: [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [{ id: "child", type: "paragraph", content: "Child" }], + }, + ], + }); + editor.mount(document.createElement("div")); + const element = (id: string) => + editor.domElement!.querySelector( + `[data-node-type="blockContainer"][data-id="${id}"]`, + ); + return { editor, element }; + } + + it("keeps the block and its children between types without a frame", () => { + const { editor, element } = editorWithParent(); + try { + const [parent, child] = [element("parent"), element("child")]; + editor.updateBlock("parent", { type: "heading" }); + editor.updateBlock("parent", { type: "bulletListItem" }); + expect(element("parent")).toBe(parent); + expect(element("child")).toBe(child); + } finally { + editor._tiptapEditor.destroy(); + } + }); + + it("draws the block again when the frame comes or goes", () => { + const { editor, element } = editorWithParent(); + try { + const parent = element("parent"); + editor.updateBlock("parent", { type: "toggleListItem" }); + const toggled = element("parent")!; + expect(toggled).not.toBe(parent); + expect(toggled.querySelector(".bn-toggle-frame")).not.toBeNull(); + + editor.updateBlock("parent", { type: "paragraph" }); + expect(element("parent")).not.toBe(toggled); + expect(element("parent")!.querySelector(".bn-toggle-frame")).toBeNull(); + expect(element("parent")!.textContent).toContain("Child"); + } finally { + editor._tiptapEditor.destroy(); + } + }); +}); diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index 86bd2ccb15..aad3429a0b 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -1,10 +1,54 @@ -import { Node } from "@tiptap/core"; +import { + Node, + type NodeViewRenderer, + type NodeViewRendererProps, +} from "@tiptap/core"; +import type { NodeView } from "@tiptap/pm/view"; import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { + blockFrameView, + findFrameSlot, + frameNodeViewHooks, +} from "../schema/blocks/frame.js"; import { BlockNoteDOMAttributes } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; import { suggestionMarks } from "./suggestionMarks.js"; +/** The block props whose colors `Block.css` applies to a block's children. */ +const BLOCK_COLORS = [ + ["textColor", "data-text-color"], + ["backgroundColor", "data-background-color"], +] as const; + +function isColorSet(value: unknown): value is string { + return typeof value === "string" && value !== "default"; +} + +/** Whether a block has a text or background color other than the default. */ +function hasBlockColors(props: Record) { + return BLOCK_COLORS.some(([prop]) => isColorSet(props[prop])); +} + +/** + * Puts a block's text and background color on its own element (`.bn-block`), + * so they also apply to its children however deep a frame puts its content. + * Only these two props, which `Block.css` applies to a block's children. + */ +export function setBlockColorAttributes( + element: HTMLElement, + props: Record | undefined, +) { + for (const [prop, attribute] of BLOCK_COLORS) { + const value = props?.[prop]; + if (isColorSet(value)) { + element.setAttribute(attribute, value); + } else { + element.removeAttribute(attribute); + } + } +} + // Object containing all possible block attributes. const BlockAttributes: Record = { blockColor: "data-block-color", @@ -49,6 +93,10 @@ export const BlockContainer = Node.create<{ return attrs; }, + // The frame's slot: the rest of a frame is chrome, not document + // content. + contentElement: (element) => + findFrameSlot(element as HTMLElement) ?? (element as HTMLElement), }, // Ignore `blockOuter` divs, but parse the `blockContainer` divs inside them. { @@ -88,4 +136,65 @@ export const BlockContainer = Node.create<{ contentDOM: block, }; }, + + addNodeView() { + // Every block gets this node view: it draws the block's frame (or none), + // keeps it current, and keeps the block's colors on `.bn-block`. Without + // a frame, it is as fast as ProseMirror's default (`performance.test.ts`). + // Cast: this returns a plain ProseMirror node view, which tiptap's + // `NodeViewRenderer` type doesn't model. + return ((props: NodeViewRendererProps) => { + const { dom, contentDOM: block } = this.type.spec.toDOM!(props.node) as { + dom: HTMLElement; + contentDOM: HTMLElement; + }; + const editor = this.options.editor; + // The block's frame: React draws its own, otherwise the block's + // vanilla `renderFrame`. A drawn frame goes inside `.bn-block`; a block + // without one keeps its content and children in `.bn-block` itself. + const frame = blockFrameView({ + props, + editor, + blockNode: (node) => node.firstChild!, + unframed: block, + }); + if (frame.dom !== block) { + block.appendChild(frame.dom); + } + // A new `.bn-block` has no color attributes, so a block with the default + // colors needs no DOM calls. Skipping them keeps typing in large + // documents fast (`performance.test.ts`). + const attrs = props.node.firstChild!.attrs; + if (hasBlockColors(attrs)) { + setBlockColorAttributes(block, attrs); + } + + const nodeView: NodeView = { + dom, + contentDOM: frame.slot, + update(node, decorations, innerDecorations) { + const before = frame.node; + // Like ProseMirror's default, the view is rebuilt when the block's + // own attributes change (another block), and otherwise kept while + // ProseMirror updates the content and children. It is also rebuilt + // when the frame no longer fits, for example after a change of the + // block's type or props. + if ( + !node.sameMarkup(before) || + !frame.update(node, decorations, innerDecorations) + ) { + return false; + } + // `Block.css` applies a block's colors to its children too, from + // its element. They are props, so only a prop change moves them. + if (!node.firstChild!.sameMarkup(before.firstChild!)) { + setBlockColorAttributes(block, node.firstChild!.attrs); + } + return true; + }, + ...frameNodeViewHooks(frame), + }; + return nodeView; + }) as unknown as NodeViewRenderer; + }, }); diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts index 9fe644a5db..bba1d84f4d 100644 --- a/packages/core/src/pm-nodes/BlockGroup.ts +++ b/packages/core/src/pm-nodes/BlockGroup.ts @@ -1,4 +1,5 @@ import { Node } from "@tiptap/core"; +import { CHILD_CONTAINER_GROUP } from "../schema/blocks/children.js"; import { BlockNoteDOMAttributes } from "../schema/index.js"; import { mergeCSSClasses } from "../util/browser.js"; import { suggestionMarks } from "./suggestionMarks.js"; @@ -7,7 +8,7 @@ export const BlockGroup = Node.create<{ domAttributes?: BlockNoteDOMAttributes; }>({ name: "blockGroup", - group: "childContainer", + group: CHILD_CONTAINER_GROUP, content: "blockGroupChild+", marks() { return suggestionMarks(this.editor); 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..f999014a5a --- /dev/null +++ b/packages/core/src/schema/blocks/children.test.ts @@ -0,0 +1,288 @@ +// @vitest-environment node +import { describe, expect, it } from "vite-plus/test"; + +import { anyBlocksExpression, childrenContentExpression } from "./children.js"; +import type { ChildrenAllow, ChildrenConfig } from "./types.js"; +import { validateChildrenConfigs } from "./validateChildren.js"; + +// All enforcement happens through the content expression. If this table is +// right, `allow`/`min` are enforced by ProseMirror itself. +const CASES: [string, ChildrenConfig, string][] = [ + [ + "any block or placeable layout block, at least one (the minimal config)", + { allow: "blocks" }, + "blockGroupChild+", + ], + ["one layout block type only", { allow: ["column"], min: 2 }, "column{2,}"], + [ + "one layout block type, possibly none", + { allow: ["column"], min: 0 }, + "column*", + ], + [ + "several layout block types", + { allow: ["column", "card"] }, + "(column | card)+", + ], +]; + +describe("childrenContentExpression", () => { + it.each(CASES)("%s", (_name, config, expected) => { + expect(childrenContentExpression(config)).toBe(expected); + }); + + // `validateChildrenConfigs` never builds the content expression — it only + // resolves `allow`/`min` — so an `allow` that permits nothing is caught + // here, at expression build, rather than by `validate` below. + it("accepts any blocks when `children` or `allow` is left out", () => { + expect(childrenContentExpression()).toBe("blockGroupChild+"); + expect(childrenContentExpression({ allow: "blocks" })).toBe( + "blockGroupChild+", + ); + }); + + it("throws for an allow array that permits nothing", () => { + expect(() => childrenContentExpression({ allow: [] })).toThrow( + /permits nothing/, + ); + }); +}); + +type BlockFixture = { + content?: "none" | "inline" | "plain" | "table"; + layoutOnly?: true; + // Loose on purpose: the validator tests pass shapes the type rejects. + children?: { + allow?: ChildrenAllow; + min?: number; + exclude?: string[]; + dissolve?: boolean; + }; + placeable?: "anywhere" | "namedOnly"; +}; + +function specsWith(blocks: Record) { + return { + paragraph: { config: { content: "inline" as const } }, + heading: { config: { content: "inline" as const } }, + ...Object.fromEntries( + Object.entries(blocks).map(([type, config]) => [ + type, + { config: { ...config, content: config.content ?? ("none" as const) } }, + ]), + ), + }; +} + +// Typed loosely on purpose: the validator is what catches the combinations the +// types reject, for JS callers. +const validate = (blocks: Record) => () => + validateChildrenConfigs(specsWith(blocks) as any); + +// `exclude` is resolved against the schema: the "any blocks" group is replaced +// by its other members, and `childrenContentExpression` takes the result. +describe("anyBlocksExpression", () => { + const specs = specsWith({ + callout: { layoutOnly: true }, + columnList: { layoutOnly: true, children: { allow: ["column"], min: 2 } }, + column: { layoutOnly: true, placeable: "namedOnly" }, + }) as any; + + it("is the group when nothing is excluded", () => { + expect(anyBlocksExpression([], specs)).toBe("blockGroupChild"); + }); + + it("spells out the other members otherwise", () => { + expect(anyBlocksExpression(["columnList"], specs)).toBe( + "(blockContainer | callout)", + ); + expect(anyBlocksExpression(["columnList", "callout"], specs)).toBe( + "blockContainer", + ); + expect( + childrenContentExpression( + { allow: "blocks", exclude: ["columnList"] }, + anyBlocksExpression(["columnList"], specs), + ), + ).toBe("(blockContainer | callout)+"); + }); + + it("rejects `exclude` on an allow list at the type level", () => { + const config: ChildrenConfig = { + allow: ["column"], + // @ts-expect-error `exclude` is only for a layout block of any blocks + exclude: ["callout"], + }; + expect(config.allow).toEqual(["column"]); + }); +}); + +describe("validateChildrenConfigs", () => { + it("accepts `exclude` of a layout block that can stand anywhere", () => { + expect( + validate({ + box: { layoutOnly: true, children: { exclude: ["callout"] } }, + callout: { layoutOnly: true }, + }), + ).not.toThrow(); + }); + + it("rejects `exclude` where it can't apply", () => { + expect( + validate({ + box: { + layoutOnly: true, + children: { allow: ["callout"], exclude: ["callout"] }, + }, + callout: { layoutOnly: true }, + }), + ).toThrow(/only for a layout block that accepts any blocks/); + expect( + validate({ + box: { content: "inline", children: { exclude: ["callout"] } }, + callout: { layoutOnly: true }, + }), + ).toThrow(/requires `layoutOnly: true`/); + expect( + validate({ box: { layoutOnly: true, children: { exclude: ["typo"] } } }), + ).toThrow(/not a configured block type/); + expect( + validate({ + box: { layoutOnly: true, children: { exclude: ["paragraph"] } }, + }), + ).toThrow(/is not a layout block/); + expect( + validate({ + box: { layoutOnly: true, children: { exclude: ["column"] } }, + column: { layoutOnly: true, placeable: "namedOnly" }, + }), + ).toThrow(/namedOnly/); + }); + + it("accepts recursive layout blocks and named-only children", () => { + expect(validate({ callout: { layoutOnly: true } })).not.toThrow(); + expect( + validate({ + // gridCell is a terminating alternative to the recursive grid. + grid: { + layoutOnly: true, + children: { allow: ["gridCell", "grid"], min: 2 }, + }, + gridCell: { layoutOnly: true, placeable: "namedOnly" }, + }), + ).not.toThrow(); + }); + + // `min` only means something for a set of named layout block types. A + // layout block that accepts any blocks always holds at least one, and + // dissolves when it loses it. + it("rejects `min` on a layout block that accepts any blocks", () => { + for (const children of [{ allow: "blocks", min: 2 }, { min: 2 }] as const) { + expect(validate({ panel: { layoutOnly: true, children } })).toThrow( + /`children.min` needs an `allow` list/, + ); + } + // The type rejects it too. + // @ts-expect-error + const invalid: ChildrenConfig = { allow: "blocks", min: 2 }; + expect(invalid).toBeDefined(); + }); + + it.each(["inline", "plain", "table"] as const)( + "rejects `layoutOnly` on a block with %s content", + (content) => { + expect(validate({ alert: { content, layoutOnly: true } })).toThrow( + /only for blocks without content/, + ); + }, + ); + + // Any block can have any child blocks, so writing out the default is fine + // everywhere. Restricting them needs the block's own node. + it.each(["inline", "plain", "table", "none"] as const)( + "only accepts the default children on a %s block that isn't a layout block", + (content) => { + expect( + validate({ alert: { content, children: { allow: "blocks" } } }), + ).not.toThrow(); + for (const children of [ + { allow: "blocks", min: 2 }, + { allow: ["cell"] }, + ] as const) { + expect( + validate({ + alert: { content, children }, + cell: { layoutOnly: true }, + }), + ).toThrow(/requires `layoutOnly: true`/); + } + }, + ); + + it("does not treat a block with content as an allowed layout block", () => { + expect( + validate({ + box: { layoutOnly: true, children: { allow: ["alert"] } }, + alert: { content: "inline" }, + }), + ).toThrow(/is not a layout block/); + }); + + it("rejects `dissolve` without an allow list", () => { + expect( + validate({ box: { layoutOnly: true, children: { dissolve: false } } }), + ).toThrow(/`children.dissolve` needs an `allow` list/); + }); + + it.each([-1, 1.5, Number.NaN])("rejects `min: %s`", (min) => { + expect( + validate({ + columnList: { + layoutOnly: true, + children: { allow: ["column"], min }, + }, + column: { layoutOnly: true, placeable: "namedOnly" }, + }), + ).toThrow(/`children.min` must be a whole number that is 0 or more/); + }); + + it("rejects `dissolve` on a block that is not a layout block", () => { + expect( + validate({ alert: { content: "inline", children: { dissolve: false } } }), + ).toThrow(/requires `layoutOnly: true`/); + }); + + it("rejects named-only placement on a block that is not a layout block", () => { + expect( + validate({ alert: { content: "inline", placeable: "namedOnly" } }), + ).toThrow(/only for layout blocks/); + }); + + it("rejects a named-only block that no allow list names", () => { + // The editor could not insert it anywhere. + expect( + validate({ step: { layoutOnly: true, placeable: "namedOnly" } }), + ).toThrow(/no block names it/); + expect( + validate({ + stepper: { layoutOnly: true, children: { allow: ["step"] } }, + step: { layoutOnly: true, placeable: "namedOnly" }, + }), + ).not.toThrow(); + }); + + it.each(["typo", "blockGroupChild", "toString"])( + "rejects an allow entry that is not a configured block: %s", + (allowed) => { + expect( + validate({ box: { layoutOnly: true, children: { allow: [allowed] } } }), + ).toThrow(/not a configured block type/); + }, + ); + + it("rejects a regular block type in the allow array", () => { + expect( + validate({ box: { layoutOnly: true, children: { allow: ["heading"] } } }), + ).toThrow(/not supported yet/); + }); +}); diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts new file mode 100644 index 0000000000..ef8bd38995 --- /dev/null +++ b/packages/core/src/schema/blocks/children.ts @@ -0,0 +1,136 @@ +import type { Node, NodeType, Schema } from "prosemirror-model"; + +import type { BlockConfig, ChildrenConfig } from "./types.js"; + +export const CHILD_CONTAINER_GROUP = "childContainer"; + +export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; + +// Naming: a "layout block" is a block declared `layoutOnly: true`, whose own +// node holds its child blocks. Two older names with "container" mean +// something else: +// - `blockContainer` is the node around every other block (its content node +// plus an optional `blockGroup` of children). Its name is kept because it +// is stored in documents. +// - `childContainer` (`CHILD_CONTAINER_GROUP`) is the node group of every +// node that holds a block's children: `blockGroup` and the nodes of layout +// blocks. +// +// Whether `type` is the node of a layout block: a node that holds child +// blocks directly and is itself a block. `blockGroup` also holds children but +// is not a block (it's regular blocks' nesting machinery), so the `bnBlock` +// check excludes it. +export function isLayoutNode(type: NodeType): boolean { + return type.isInGroup(CHILD_CONTAINER_GROUP) && type.isInGroup("bnBlock"); +} + +// Builds the `blockGroup` node that holds a block's children when converting +// blocks to nodes. Transaction-level nesting (`sinkItem`, `findWrapping` in the +// keyboard shortcuts) wraps existing nodes in a `blockGroup` instead, and the +// document's root `blockGroup` is created by the parsers and `y`/`yjs` utils. +export function createBlockGroup( + schema: Schema, + children: readonly Node[], +): Node { + return schema.nodes["blockGroup"].createChecked({}, children as Node[]); +} + +/** + * Whether `type` is a layout block declared `placeable: "namedOnly"`: one + * defined only in terms of the layout block that holds it (a `column`), so it can + * never stand where a regular block goes. + * + * The schema encodes this by keeping such types out of + * `BLOCK_GROUP_CHILD_GROUP`, which is how ProseMirror enforces it while + * matching content. This answers the same question from the declaration + * itself, for code reasoning about the block rather than about what PM will + * match. + */ +export function isNamedOnly(type: NodeType): boolean { + return isLayoutNode(type) && type.spec.blockConfig?.placeable === "namedOnly"; +} + +// Below `blockContainer`'s priority (50) so PM's `fillBefore` picks +// `blockContainer` first, avoiding recursion through nested layout blocks. +export const LAYOUT_NODE_PRIORITY = 40; + +const LAYOUT_PRIORITY_BAND = { min: 30, max: 49 }; +const DEFAULT_SPEC_PRIORITY = 101; + +// Maps `sortByDependencies` priority into the layout block band (30–49). +// Preserves relative order but keeps all layout blocks below regular blocks. +export function layoutNodePriority(priority: number | undefined): number { + if (priority === undefined) { + return LAYOUT_NODE_PRIORITY; + } + + const steps = Math.round((priority - DEFAULT_SPEC_PRIORITY) / 10); + + return Math.min( + LAYOUT_PRIORITY_BAND.max, + Math.max(LAYOUT_PRIORITY_BAND.min, LAYOUT_NODE_PRIORITY + steps), + ); +} + +/** How few children a layout block must hold: its `min` (an `allow` list only), else 1. */ +export function minChildren(children: ChildrenConfig | undefined): number { + return children !== undefined && "min" in children ? (children.min ?? 1) : 1; +} + +/** + * The content expression for "any blocks": the `BLOCK_GROUP_CHILD_GROUP` group + * itself, or, for a layout block that excludes some layout block types, the group's + * other members spelled out: `blockContainer` (the node every regular block + * shares) and the layout block types placeable anywhere. That takes the whole + * schema, which is why only a layout block with `exclude` needs it. + */ +export function anyBlocksExpression( + exclude: readonly string[], + blockSpecs: Record< + string, + { config: Pick } + >, +): string { + if (exclude.length === 0) { + return BLOCK_GROUP_CHILD_GROUP; + } + const layoutBlocks = Object.entries(blockSpecs) + .filter( + ([type, { config }]) => + config.layoutOnly === true && + config.placeable !== "namedOnly" && + !exclude.includes(type), + ) + .map(([type]) => type); + const members = ["blockContainer", ...layoutBlocks]; + return members.length === 1 ? members[0] : `(${members.join(" | ")})`; +} + +/** + * Compiles a layout block's `children` config into its node's ProseMirror content + * expression: which types may be its children (`allow`), followed by how few + * of them it takes (`min`). `anyBlocks` is what `allow: "blocks"` stands for: + * the group, unless the layout block excludes types (see `anyBlocksExpression`). + */ +export function childrenContentExpression( + children: ChildrenConfig = {}, + anyBlocks: string = BLOCK_GROUP_CHILD_GROUP, +): string { + const { allow = "blocks" } = children; + const min = minChildren(children); + + let allowed: string; + if (allow === "blocks") { + allowed = anyBlocks; + } else { + if (allow.length === 0) { + throw new Error( + "Layout block `allow` permits nothing. A layout block must accept at least one block or layout block type; drop `children` entirely for a block that holds none.", + ); + } + + allowed = allow.length === 1 ? allow[0] : `(${allow.join(" | ")})`; + } + + return allowed + (min === 0 ? "*" : min === 1 ? "+" : `{${min},}`); +} diff --git a/packages/core/src/schema/blocks/createSpec.browser.test.ts b/packages/core/src/schema/blocks/createSpec.browser.test.ts new file mode 100644 index 0000000000..dec7d60445 --- /dev/null +++ b/packages/core/src/schema/blocks/createSpec.browser.test.ts @@ -0,0 +1,176 @@ +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 frameDiv = () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; +}; + +// A layout block that recognizes its own external HTML. Before layout blocks +// 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", + layoutOnly: true, + }, + { + renderFrame: frameDiv, + 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", + layoutOnly: true, + }, + { + renderFrame: frameDiv, + parse: (el) => (el.tagName === "BLOCKQUOTE" ? {} : undefined), + // Returns inline nodes, the natural thing to build from an element, and + // relies on ProseMirror's parser to wrap them into child blocks. + parseContent: ({ el, schema }) => + Fragment.from(schema.text(el.textContent?.trim() || "empty")), + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + card: Card, + quote: Quote, + } 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("layout block `parse`", () => { + it("parses an external element into a layout block, 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("layout block `runsBefore`", () => { + const ambiguous = (type: string) => + createBlockSpec( + { + type, + propSchema: {}, + content: "none", + layoutOnly: true, + } as any, + { + renderFrame: frameDiv, + 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 layout block's parse rules before another layout block'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(); + } + } + }); +}); diff --git a/packages/core/src/schema/blocks/createSpec.test.ts b/packages/core/src/schema/blocks/createSpec.test.ts index c42a9151ad..7bb1d06173 100644 --- a/packages/core/src/schema/blocks/createSpec.test.ts +++ b/packages/core/src/schema/blocks/createSpec.test.ts @@ -1,3 +1,5 @@ +import { Node as TiptapNode } from "@tiptap/core"; +import { DOMParser } from "@tiptap/pm/model"; import { describe, expect, it } from "vite-plus/test"; import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; @@ -5,6 +7,7 @@ import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { YAttributionMarksExtension } from "../../y/extensions/YAttributionMarks.js"; import { createBlockConfig, createBlockSpec } from "../index.js"; +import { createBlockSpecFromTiptapNode } from "./internal.js"; // A minimal "plain" content block WITHOUT a custom `parseContent`, so parsing // its HTML exercises the generic plain branch in `getParseRules`' `getContent`. @@ -121,3 +124,231 @@ describe("plain content parsing", () => { editor._tiptapEditor.destroy(); }); }); + +describe("block spec and node agreement", () => { + it("leaves a hand-written node's content expression authoritative", () => { + expect(() => + BlockNoteSchema.create().extend({ + blockSpecs: { + holder: createBlockSpecFromTiptapNode( + { + node: TiptapNode.create({ + name: "holder", + group: "blockContent", + content: "paragraph+", + }), + type: "holder", + content: "none", + }, + {}, + ), + }, + }), + ).not.toThrow(); + }); + + it("rejects a hand-written node whose name contradicts its config", () => { + expect(() => + BlockNoteSchema.create().extend({ + blockSpecs: { + holder: createBlockSpecFromTiptapNode( + { + node: TiptapNode.create({ + name: "notHolder", + group: "block", + content: "block+", + }), + type: "holder", + content: "none", + }, + {}, + ), + }, + }), + ).toThrow(/Node name does not match block type/); + }); +}); + +describe("layout block children parsing", () => { + const frameDiv = () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }; + + const MixedBox = createBlockSpec( + { + type: "mixedBox" as const, + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { renderFrame: frameDiv }, + )(); + + const createEditor = () => + BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + mixedBox: MixedBox, + }, + }), + }); + + it("wraps loose text around blocks into child blocks without parseContent", () => { + const editor = createEditor(); + + const blocks = editor.tryParseHTMLToBlocks( + `

First

Loose text
`, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("mixedBox"); + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + "paragraph", + ]); + expect( + blocks[0].children.map((child: any) => child.content?.[0]?.text), + ).toEqual(["First", "Loose text"]); + + editor._tiptapEditor.destroy(); + }); +}); + +describe("render hooks contract", () => { + const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }; + const frameDiv = () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }; + + it("rejects a block declaring neither render nor renderFrame", () => { + expect(() => + createBlockSpec( + { + type: "probed" as const, + propSchema: {}, + content: "none" as const, + layoutOnly: true, + }, + {}, + )(), + ).toThrow(/must declare `renderFrame`/); + }); + + it("rejects renderFrame alone on a block that is not a layout block", () => { + // A block with content draws it in `render`. + expect(() => + createBlockSpec( + { + type: "probed" as const, + propSchema: {}, + content: "inline" as const, + }, + { renderFrame: frameDiv }, + )(), + ).toThrow(/must declare `render`/); + }); + + it("rejects render on a layout block, which has no content to draw", () => { + expect(() => + createBlockSpec( + { + type: "probed", + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { render: renderDiv, renderFrame: frameDiv }, + )(), + ).toThrow(/has no content for `render` to draw/); + }); + + it("rejects a layout block frame that declines", () => { + const declining = createBlockSpec( + { + type: "probed", + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { renderFrame: () => undefined }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, probed: declining }, + }), + }); + expect(() => editor.blocksToHTMLLossy([{ type: "probed" } as any])).toThrow( + /must draw its frame/, + ); + editor._tiptapEditor.destroy(); + }); +}); + +it("exports a layout block like a regular block, parsing back through its own parse", () => { + const box = createBlockSpec( + { + type: "box", + propSchema: { tone: { default: "plain" } }, + content: "none", + layoutOnly: true, + }, + { + renderFrame() { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + toExternalHTML() { + const dom = document.createElement("div"); + dom.className = "box"; + const label = document.createElement("button"); + label.textContent = "Control label"; + const childrenDOM = document.createElement("div"); + childrenDOM.className = "box-children"; + dom.append(label, childrenDOM); + return { dom, childrenDOM }; + }, + // External HTML has no BlockNote markers, so the block recognizes its + // own export, and reads its children from where it placed them. + parse: (el) => (el.classList.contains("box") ? {} : undefined), + parseContent: ({ el, schema }) => + DOMParser.fromSchema(schema).parse(el.querySelector(".box-children")!, { + topNode: schema.nodes["blockGroup"].create(), + }).content, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, box }, + }), + initialContent: [ + { + type: "box", + props: { tone: "loud" }, + children: [{ type: "paragraph", content: "Body" }], + }, + ], + }); + try { + const html = editor.blocksToHTMLLossy(editor.document); + expect(html).not.toContain("data-node-type"); + expect(html).not.toContain("data-frame-slot"); + expect(html).not.toContain("data-id"); + expect(html).toContain('data-tone="loud"'); + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("box"); + expect(parsed[0].props).toEqual({ tone: "loud" }); + expect(parsed[0].children).toHaveLength(1); + expect(parsed[0].children[0].content).toEqual([ + { type: "text", text: "Body", styles: {} }, + ]); + } finally { + editor._tiptapEditor.destroy(); + } +}); diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 935998aa32..2faed2e760 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -1,4 +1,4 @@ -import { Editor, Node } from "@tiptap/core"; +import { Editor, Node, NodeConfig, NodeViewRendererProps } from "@tiptap/core"; import { DOMParser, Fragment, @@ -7,25 +7,42 @@ import { } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { Extension, ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { nonFormattingMarks } from "../markGroups.js"; import { ignoreDarkReaderMutations } from "../nodeViewMutations.js"; import { PropSchema } from "../propTypes.js"; import { + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + anyBlocksExpression, + childrenContentExpression, + layoutNodePriority, +} from "./children.js"; +import { applyDataAttributes } from "../dataAttributes.js"; +import { blockFrameView, findFrameSlot, frameNodeViewHooks } from "./frame.js"; +import { + applyDOMAttributes, getBlockFromNodeView, + isDocumentFragment, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; +import { createBlockKeyboard } from "./keyboard.js"; +import { resolveBlockMeta } from "./meta.js"; import { BlockConfig, BlockConfigOrCreator, BlockImplementation, BlockImplementationOrCreator, BlockSpec, + ChildrenConfig, LooseBlockSpec, + ResolvedBlockConfigMeta, } from "./types.js"; // Function that causes events within non-selectable blocks to be handled by the @@ -45,9 +62,88 @@ 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. +// Custom parsing, followed by the default parser for the block's content kind. +function blockContentParser< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + config: BlockConfig, + implementation: BlockImplementation, +): TagParseRule["getContent"] | undefined { + const isLayoutBlock = config.layoutOnly === true; + if ( + config.content === "table" || + (isLayoutBlock && !implementation.parseContent) + ) { + // Tables use their own rules. Layout blocks without parseContent use PM's + // normal child parsing, including wrapping inline runs as paragraphs. + return undefined; + } + + return (node, schema) => { + if (implementation.parseContent) { + const result = implementation.parseContent({ + el: node as HTMLElement, + schema, + }); + // parseContent may return undefined to fall through to the default + // inline content parsing below. + if (result !== undefined) { + return result; + } + } + + if (isLayoutBlock) { + return DOMParser.fromSchema(schema).parse(node as HTMLElement, { + topNode: schema.nodes["blockGroup"].create(), + preserveWhitespace: true, + }).content; + } + + if (config.content === "none") { + return Fragment.empty; + } + + // Cloned so merging doesn't modify the element being parsed. + const clone = (node as HTMLElement).cloneNode(true) as HTMLElement; + // Merge multiple paragraphs into one with line breaks + mergeParagraphs( + clone, + config.content === "plain" || implementation.meta?.code ? "\n" : "
", + ); + + // Parsed as a paragraph, to extract the inline content by itself. + const parsed = DOMParser.fromSchema(schema).parse(clone, { + topNode: schema.nodes.paragraph.create(), + preserveWhitespace: config.content === "plain" ? "full" : true, + }); + + if (config.content === "inline") { + return parsed.content; + } + + // Plain blocks hold text only, so non-text inline nodes are flattened: + // line breaks become newline characters and other nodes (e.g. mentions) + // are kept as their text. + const textNodes: PMNode[] = []; + parsed.content.forEach((child) => { + if (child.isText) { + textNodes.push(child); + return; + } + const text = + child.type === schema.linebreakReplacement ? "\n" : child.textContent; + if (text) { + textNodes.push(schema.text(text, child.marks)); + } + }); + + return Fragment.fromArray(textNodes); + }; +} + +// Creates `parseHTML` rules for clipboard parsing. export function getParseRules< TName extends string, TProps extends PropSchema, @@ -56,11 +152,24 @@ export function getParseRules< config: BlockConfig, implementation: BlockImplementation, ) { + const isLayoutBlock = config.layoutOnly === true; + const rules: TagParseRule[] = [ - { - tag: "[data-content-type=" + config.type + "]", - contentElement: ".bn-inline-content", - }, + isLayoutBlock + ? { + tag: `[data-node-type=${config.type}]`, + // Scope the round-trip parse to the frame's slot, so chrome the + // frame draws around it (button labels, captions, ...) doesn't + // parse back as document content. HTML without the marker + // (hand-written) falls back to the whole element. + contentElement: (el) => + findFrameSlot(el as HTMLElement) ?? (el as HTMLElement), + } + : { + tag: "[data-content-type=" + config.type + "]", + contentElement: ".bn-inline-content", + preserveWhitespace: config.content === "plain" ? "full" : undefined, + }, ]; if (implementation.parse) { @@ -81,279 +190,278 @@ 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" - ? (node, schema) => { - if (implementation.parseContent) { - const result = implementation.parseContent({ - el: node as HTMLElement, - schema, - }); - // parseContent may return undefined to fall through to - // the default inline content parsing below. - if (result !== undefined) { - return result; - } - } - - if (config.content === "inline" || config.content === "plain") { - // Parse the inline content if it exists - const element = node as HTMLElement; - - // Clone to avoid modifying the original - const clone = element.cloneNode(true) as HTMLElement; - - // Merge multiple paragraphs into one with line breaks - mergeParagraphs( - clone, - config.content === "plain" || implementation.meta?.code - ? "\n" - : "
", - ); - - // Parse the content directly as a paragraph to extract inline content - const parser = DOMParser.fromSchema(schema); - const parsed = parser.parse(clone, { - topNode: schema.nodes.paragraph.create(), - preserveWhitespace: true, - }); - - if (config.content === "plain") { - // Plain blocks hold text only, so non-text inline nodes are - // flattened: line breaks become newline characters and other - // nodes (e.g. mentions) are kept as their text. - const textNodes: PMNode[] = []; - parsed.content.forEach((child) => { - if (child.isText) { - textNodes.push(child); - } else { - const text = - child.type === schema.linebreakReplacement - ? "\n" - : child.textContent; - if (text) { - textNodes.push(schema.text(text, child.marks)); - } - } - }); - - return Fragment.fromArray(textNodes); - } - return parsed.content; - } - return Fragment.empty; - } - : undefined, + getContent: blockContentParser(config, implementation), }); } - // getContent(node, schema) { - // const block = blockConfig.parse?.(node as HTMLElement); - // - // if (block !== undefined && block.content !== undefined) { - // return Fragment.from( - // typeof block.content === "string" - // ? schema.text(block.content) - // : inlineContentToNodes(block.content, schema) - // ); - // } - // - // return Fragment.empty; - // }, - // }); - // } return rules; } -// What the generated node's content expression is for each `content` kind. -const CONTENT_EXPRESSIONS: Record = { - inline: "inline*", - plain: "text*", - none: "", - table: "tableRow+", -}; - /** - * Content expressions that are spelled differently can still mean the same - * thing, e.g. `"(text)*"` and `"text*"`. Unwraps a parenthesized single - * term, with or without a trailing quantifier, so equivalent spellings - * compare equal. Anything with real structure (sequences, alternation) is - * left as-is: unwrapping those would change the expression's meaning. + * Checks that a block declares the render hooks its config calls for: a + * layout block (`layoutOnly: true`) has no content, so it declares only + * `renderFrame`; every other block declares `render`. + * + * FIXME: return the checked hooks as a discriminated union + * (`{ kind: "render", render } | { kind: "frame", renderFrame }`), so callers + * don't need `!` on `render`/`renderFrame` afterwards. */ -function normalizeContentExpression(expression: string): string { - const trimmed = expression.trim(); - const match = trimmed.match(/^\(([A-Za-z_][A-Za-z0-9_]*)\)([*+?])?$/); - return match ? `${match[1]}${match[2] ?? ""}` : trimmed; -} - -/** - * Checks a hand-written node against its config: that the node name matches - * the block type, and that the node's content expression matches the - * `content` the spec declares — the one `getBlockInfoFromPos` reports as the - * block's `contentKind`, without looking at the node. A generated node's name - * and expression come from that same config, so this only bites on a - * hand-written one (`createBlockSpecFromTiptapNode`). - */ -function checkNodeMatchesConfig(node: Node, blockConfig: BlockConfig) { - if (node.name !== blockConfig.type) { +export function validateRenderHooks( + blockConfig: Pick, + implementation: { render?: unknown; renderFrame?: unknown }, +) { + if (blockConfig.layoutOnly !== true) { + if (!implementation.render) { + throw new Error(`Block "${blockConfig.type}" must declare \`render\`.`); + } + return; + } + if (implementation.render) { throw new Error( - "Node name does not match block type. This is a bug in BlockNote.", + `Layout block "${blockConfig.type}" draws its box in \`renderFrame\`, and has no content for \`render\` to draw.`, ); } + if (!implementation.renderFrame) { + throw new Error( + `Layout block "${blockConfig.type}" must declare \`renderFrame\`.`, + ); + } +} - // A wrapper node that holds child blocks directly (e.g. a hand-written - // `column`) has no block content expression to compare against. - const groups = typeof node.config.group === "string" ? node.config.group : ""; - if (groups.split(" ").includes("bnBlock")) { - return; +/** A block implementation with its `meta` resolved (see `resolveBlockMeta`). */ +type ResolvedBlockImplementation< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +> = Omit, "meta"> & { + meta: ResolvedBlockConfigMeta; +}; + +/** A layout block's node view: its frame. */ +function layoutNodeView< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockImplementation: ResolvedBlockImplementation, + props: NodeViewRendererProps, +): NodeView { + // React draws its own frame and stamps it; core stamps a vanilla one. + const frame = blockFrameView({ + props, + editor: props.extension.options.editor, + blockNode: (node) => node, + }); + const nodeView: NodeView = { + dom: frame.dom, + contentDOM: frame.slot, + update(node, decorations, innerDecorations) { + if (node.attrs.id !== frame.node.attrs.id) { + // ProseMirror offers a view whose node is gone to the next node of + // the same type. Return `false`: the frame shows another block, so + // draw this block's own frame. + return false; + } + // The same block. Return the frame's answer: `true` keeps it. + return frame.update(node, decorations, innerDecorations); + }, + ...frameNodeViewHooks(frame), + }; + if (!blockImplementation.meta.selectable) { + applyNonSelectableBlockFix(nodeView, props.editor); } + return nodeView; +} - // tiptap allows the expression to be a function of the editor, in which case - // there is nothing to compare yet. - const content = node.config.content; - if (content !== undefined && typeof content !== "string") { - return; +function blockNodeView< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: ResolvedBlockImplementation, + props: NodeViewRendererProps, +): NodeView { + if (blockConfig.layoutOnly === true) { + return layoutNodeView(blockImplementation, props); + } + const block = getBlockFromNodeView( + props.getPos, + props.node, + props.view.state.doc, + ); + const nodeView = blockImplementation.render!.call( + { + blockContentDOMAttributes: + props.extension.options.domAttributes?.blockContent || {}, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + props.extension.options.editor, + ) as unknown as NodeView; + + if (!blockImplementation.meta.selectable) { + applyNonSelectableBlockFix(nodeView, props.editor); } - const expected = CONTENT_EXPRESSIONS[blockConfig.content]; - if ( - normalizeContentExpression(content ?? "") !== - normalizeContentExpression(expected) - ) { - throw new Error( - `Block "${blockConfig.type}" declares \`content: "${blockConfig.content}"\`, ` + - `but its node holds "${content ?? ""}" rather than "${expected}".`, - ); + // Ignores Dark Reader's rewrites of the block's DOM, which would otherwise + // trigger an infinite re-render loop that freezes the tab. + ignoreDarkReaderMutations(nodeView); + + return nodeView; +} + +/** + * A layout block node's content expression. A layout block that excludes types + * needs the schema's other layout block types spelled out, which only the + * editor's schema knows: tiptap accepts a function here and calls it when + * the editor builds its ProseMirror schema, with the configured options. + */ +function layoutNodeContent( + children: ChildrenConfig | undefined, +): NodeConfig<{ editor: BlockNoteEditor }>["content"] { + const exclude = + children !== undefined && "exclude" in children ? children.exclude : []; + if (exclude === undefined || exclude.length === 0) { + return childrenContentExpression(children); } + return function () { + return childrenContentExpression( + children, + anyBlocksExpression(exclude, this.options.editor.schema.blockSpecs), + ); + }; } -// 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 buildNode< TName extends string, TProps extends PropSchema, TContent extends "inline" | "none" | "table" | "plain", >( blockConfig: BlockConfig, - blockImplementation: BlockImplementation, - extensions?: (ExtensionFactoryInstance | Extension)[], + blockImplementation: ResolvedBlockImplementation, priority?: number, -): LooseBlockSpec { - const builtNode = - ((blockImplementation as any).node as Node) || - Node.create({ - name: blockConfig.type, - content: (blockConfig.content === "inline" +) { + const isLayoutBlock = blockConfig.layoutOnly === true; + return Node.create({ + name: blockConfig.type, + content: isLayoutBlock + ? layoutNodeContent(blockConfig.children) + : 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" + : blockConfig.content, + // "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 isLayoutBlock + ? suggestionMarks(this.editor) + : 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); - }, - - 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, - ); - }, + }, + group: isLayoutBlock + ? [ + "bnBlock", + CHILD_CONTAINER_GROUP, + ...(blockConfig.placeable === "namedOnly" + ? [] + : [BLOCK_GROUP_CHILD_GROUP]), + ].join(" ") + : "blockContent", + // The node flags come resolved, layout block rules included (see + // `resolveBlockMeta`). + selectable: blockImplementation.meta.selectable, + isolating: blockImplementation.meta.isolating, + code: blockImplementation.meta.code, + defining: blockImplementation.meta.defining, + priority: isLayoutBlock ? layoutNodePriority(priority) : priority, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, - 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 || {}; + parseHTML() { + return getParseRules(blockConfig, blockImplementation); + }, - const nodeView = blockImplementation.render.call( - { - blockContentDOMAttributes, - props, - renderType: "nodeView", - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ); + renderHTML({ HTMLAttributes }) { + if (isLayoutBlock) { + 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 }; + } + + // 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, + ); + }, - // Cast needed because render returns `dom: HTMLElement | DocumentFragment` - // but tiptap's NodeView expects `dom: HTMLElement` - const typedNodeView = nodeView as unknown as NodeView; + addNodeView() { + return (props) => blockNodeView(blockConfig, blockImplementation, props); + }, + }); +} - if (blockImplementation.meta?.selectable === false) { - applyNonSelectableBlockFix(typedNodeView, this.editor); - } +// A function to create custom block for API consumers +// we want to hide the tiptap node from API consumers and provide a simpler API surface instead +export function addNodeAndExtensionsToSpec< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + extensions?: (ExtensionFactoryInstance | Extension)[], + priority?: number, +): LooseBlockSpec { + // Only a block declared `layoutOnly: true` builds a layout block node, which + // holds its children. Every other block keeps its ordinary shape. + const isLayoutBlock = blockConfig.layoutOnly === true; + const implementation: ResolvedBlockImplementation = { + ...blockImplementation, + meta: resolveBlockMeta(blockImplementation.meta, blockConfig), + }; - // Ignores Dark Reader's rewrites of the block's DOM, which would - // otherwise trigger an infinite re-render loop that freezes the tab. - ignoreDarkReaderMutations(typedNodeView); + const builtNode: Node = + (implementation as any).node ?? + buildNode(blockConfig, implementation, priority); - // 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; - }; - }, - }); + if (builtNode.name !== blockConfig.type) { + throw new Error( + "Node name does not match block type. This is a bug in BlockNote.", + ); + } - checkNodeMatchesConfig(builtNode, blockConfig as BlockConfig); + validateRenderHooks(blockConfig, implementation); // The block's config is stored on its node's PM spec // (`NodeSpec.blockConfig`), so code holding a bare `Node` can consult it @@ -365,51 +473,70 @@ export function addNodeAndExtensionsToSpec< }, }); + // The spec holds the resolved settings, not the declared option. + const { experimental_keyboard, render, toExternalHTML, ...rest } = + implementation; return { config: blockConfig, implementation: { - ...blockImplementation, + ...rest, + keyboard: createBlockKeyboard(experimental_keyboard, { + isLayoutBlock, + hardBreakShortcut: implementation.meta.hardBreakShortcut, + }), node, - render(block, editor) { - const blockContentDOMAttributes = - node.options.domAttributes?.blockContent || {}; - - return blockImplementation.render.call( - { - blockContentDOMAttributes, - props: undefined, - renderType: "dom", - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ); - }, - // TODO: this should not have wrapInBlockStructure and generally be a lot simpler - // post-processing in externalHTMLExporter should not be necessary - toExternalHTML: (block, editor, context) => { - const blockContentDOMAttributes = - node.options.domAttributes?.blockContent || {}; + // The block's content outside the editor. A layout block has no content, + // so no `render`: it is its frame. A frame is drawn by whoever + // assembles the block (the serializers), never by `render`. + ...(render && { + render(block: any, editor: BlockNoteEditor) { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; - return ( - blockImplementation.toExternalHTML?.call( - { blockContentDOMAttributes, propSchema: blockConfig.propSchema }, - block as any, - editor as any, - context, - ) ?? - blockImplementation.render.call( + return render.call( { blockContentDOMAttributes, - renderType: "dom", props: undefined, + renderType: "dom", propSchema: blockConfig.propSchema, }, block as any, editor as any, - ) - ); - }, + ); + }, + }), + // The block's own external HTML, when it declares one. It replaces the + // default export (`render` in its frame), which the external serializer + // makes for a block without one. + // TODO: this should not have wrapInBlockStructure and generally be a lot simpler + // post-processing in externalHTMLExporter should not be necessary + ...(toExternalHTML && { + toExternalHTML: ( + block: any, + editor: BlockNoteEditor, + context: { nestingLevel: number }, + ) => { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + + const external = toExternalHTML.call( + { blockContentDOMAttributes, propSchema: blockConfig.propSchema }, + block as any, + editor as any, + context, + ); + // A layout block's export carries its props as `data-*`, as a regular + // block's does. + if (external && isLayoutBlock && !isDocumentFragment(external.dom)) { + applyDataAttributes( + external.dom, + block.props, + blockConfig.propSchema, + ); + } + return external; + }, + }), }, extensions, }; @@ -519,54 +646,85 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + // Only a block declared `layoutOnly: true` is a layout block here. Every + // other block keeps its ordinary shape. + const isLayoutBlock = blockConfig.layoutOnly === true; + + // Reports a misdeclared block where it is created, including for JS + // callers. + validateRenderHooks(blockConfig, blockImplementation); + const render = blockImplementation.render; + const toExternalHTML = blockImplementation.toExternalHTML; + return { config: blockConfig, implementation: { ...blockImplementation, // TODO: this should not have wrapInBlockStructure and generally be a lot simpler // post-processing in externalHTMLExporter should not be necessary - toExternalHTML(block, editor, context) { - const output = blockImplementation.toExternalHTML?.call( - { blockContentDOMAttributes: this.blockContentDOMAttributes }, - block as any, - editor as any, - context, - ); - - if (output === undefined) { - return undefined; - } - - return wrapInBlockStructure( - output, - block.type, - block.props, - this.propSchema ?? blockConfig.propSchema, - blockImplementation.meta?.fileBlockAccept !== undefined, - ); - }, - render(block, editor) { - const output = blockImplementation.render.call( - { - blockContentDOMAttributes: this.blockContentDOMAttributes, - renderType: this.renderType, - props: this.props as any, - }, - block as any, - editor as any, - ); + // Only a block with its own export has `toExternalHTML`. Without + // one, the external HTML serializer makes the default export. + ...(toExternalHTML && { + toExternalHTML( + this: ThisParameterType, + block: Parameters[0], + editor: Parameters[1], + context: Parameters[2], + ) { + const output = toExternalHTML.call( + { blockContentDOMAttributes: this.blockContentDOMAttributes }, + block as any, + editor as any, + context, + ); + + if (output === undefined) { + return undefined; + } - const nodeView = wrapInBlockStructure( - output, - block.type, - block.props, - this.propSchema ?? blockConfig.propSchema, - blockImplementation.meta?.fileBlockAccept !== undefined, - this.blockContentDOMAttributes, - ) satisfies NodeView; + if (isLayoutBlock) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } - return nodeView; - }, + return wrapInBlockStructure( + output, + block.type, + block.props, + this.propSchema ?? blockConfig.propSchema, + blockImplementation.meta?.fileBlockAccept !== undefined, + ); + }, + }), + // A layout block has no `render`: it draws only its frame. + ...(render && { + render( + this: ThisParameterType, + block: Parameters[0], + editor: Parameters[1], + ) { + const output = render.call( + { + blockContentDOMAttributes: this.blockContentDOMAttributes, + renderType: this.renderType, + props: this.props as any, + }, + block as any, + editor as any, + ); + + const nodeView = wrapInBlockStructure( + output, + block.type, + block.props, + this.propSchema ?? blockConfig.propSchema, + blockImplementation.meta?.fileBlockAccept !== undefined, + this.blockContentDOMAttributes, + ) satisfies NodeView; + + return nodeView; + }, + }), }, extensions: extensions, }; diff --git a/packages/core/src/schema/blocks/frame.test.ts b/packages/core/src/schema/blocks/frame.test.ts new file mode 100644 index 0000000000..58294ef5ef --- /dev/null +++ b/packages/core/src/schema/blocks/frame.test.ts @@ -0,0 +1,112 @@ +import type { ViewMutationRecord } from "prosemirror-view"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isFrameChromeEvent, + isFrameRenderingMutation, + type FrameView, +} from "./frame.js"; + +// A frame with chrome (a button) next to its slot, which holds the content. +function frameWithChrome() { + const dom = document.createElement("section"); + const chrome = document.createElement("button"); + const slot = document.createElement("div"); + const content = document.createElement("p"); + slot.append(content); + dom.append(chrome, slot); + const frame: Pick = { dom, slot }; + return { frame, chrome, slot, content }; +} + +function eventOn(type: string, target: Node) { + const event = new Event(type); + Object.defineProperty(event, "target", { value: target }); + return event; +} + +function mutation(record: Partial) { + return record as ViewMutationRecord; +} + +describe("isFrameChromeEvent", () => { + it("leaves events on the chrome to the frame", () => { + const { frame, chrome } = frameWithChrome(); + expect(isFrameChromeEvent(frame, eventOn("mousedown", chrome))).toBe(true); + }); + + it("gives events in the slot to ProseMirror", () => { + const { frame, content } = frameWithChrome(); + expect(isFrameChromeEvent(frame, eventOn("mousedown", content))).toBe( + false, + ); + }); + + it("gives drag-and-drop events on the chrome to ProseMirror", () => { + const { frame, chrome } = frameWithChrome(); + expect(isFrameChromeEvent(frame, eventOn("drop", chrome))).toBe(false); + }); + + it("gives clipboard events on the chrome to ProseMirror", () => { + const { frame, chrome } = frameWithChrome(); + for (const type of ["copy", "cut", "paste"]) { + expect(isFrameChromeEvent(frame, eventOn(type, chrome))).toBe(false); + } + }); +}); + +describe("isFrameRenderingMutation", () => { + it("ignores changes to the chrome", () => { + const { frame, chrome } = frameWithChrome(); + expect( + isFrameRenderingMutation( + frame, + mutation({ type: "childList", target: chrome }), + ), + ).toBe(true); + }); + + it("ignores attribute changes on the slot element itself", () => { + const { frame, slot } = frameWithChrome(); + expect( + isFrameRenderingMutation( + frame, + mutation({ type: "attributes", attributeName: "class", target: slot }), + ), + ).toBe(true); + }); + + it("reads changes inside the slot", () => { + const { frame, content } = frameWithChrome(); + expect( + isFrameRenderingMutation( + frame, + mutation({ type: "characterData", target: content }), + ), + ).toBe(false); + }); + + it("reads selection changes", () => { + const { frame, chrome } = frameWithChrome(); + expect( + isFrameRenderingMutation( + frame, + mutation({ type: "selection", target: chrome }), + ), + ).toBe(false); + }); + + it("ignores Dark Reader's rewrites inside the slot", () => { + const { frame, content } = frameWithChrome(); + expect( + isFrameRenderingMutation( + frame, + mutation({ + type: "attributes", + attributeName: "data-darkreader-inline-color", + target: content, + }), + ), + ).toBe(true); + }); +}); diff --git a/packages/core/src/schema/blocks/frame.ts b/packages/core/src/schema/blocks/frame.ts new file mode 100644 index 0000000000..12ed92ae90 --- /dev/null +++ b/packages/core/src/schema/blocks/frame.ts @@ -0,0 +1,337 @@ +import type { Node as PMNode } from "prosemirror-model"; +import type { NodeViewRendererProps } from "@tiptap/core"; +import type { + Decoration, + DecorationSource, + ViewMutationRecord, +} from "prosemirror-view"; + +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; +import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { isDarkReaderMutation } from "../nodeViewMutations.js"; +import { applyLayoutBlockAttributes } from "./layoutBlockAttributes.js"; +import type { BlockConfig, BlockImplementation } from "./types.js"; + +/** What a vanilla `renderFrame` returns when it draws a frame. */ +type VanillaFrame = NonNullable< + ReturnType> +>; + +/** + * Marks the element of a frame (see `renderFrame`) where BlockNote mounts the + * block's content and children. The rest of the frame is chrome, not document + * content, so the parse rules read only the marked element. + */ +export const FRAME_SLOT_ATTRIBUTE = "data-frame-slot"; + +export function markFrameSlot(slot: HTMLElement) { + slot.setAttribute(FRAME_SLOT_ATTRIBUTE, ""); +} + +/** + * The frame slot that belongs to `blockElement` (an element with + * `data-node-type`), or `undefined` when its HTML has no marked slot. The + * slots of nested blocks are skipped: a slot belongs to the nearest block + * element around it, and a slot may be that block element itself. + */ +export function findFrameSlot( + blockElement: HTMLElement, +): HTMLElement | undefined { + if (blockElement.hasAttribute(FRAME_SLOT_ATTRIBUTE)) { + return blockElement; + } + for (const slot of blockElement.querySelectorAll( + `[${FRAME_SLOT_ATTRIBUTE}]`, + )) { + if (slot.closest("[data-node-type]") === blockElement) { + return slot; + } + } + return undefined; +} + +/** + * Stamps a layout block's frame with what its round-trip parse reads: the + * block's attributes on the frame's root (the block's element), and the slot + * marker. Whoever draws a layout block's frame stamps it. + */ +export function stampLayoutFrame( + blockConfig: BlockConfig, + frame: { dom: HTMLElement; slot: HTMLElement }, + block: any, +) { + applyLayoutBlockAttributes( + frame.dom, + blockConfig.type, + block.props, + blockConfig.propSchema, + block.id, + ); + markFrameSlot(frame.slot); +} + +/** + * Draws a block's frame outside the editor (`renderType: "dom"`), as both + * HTML serializers do. A layout block's frame comes stamped (see + * `stampLayoutFrame`). + */ +export function renderFrameDOM( + editor: BlockNoteEditor, + block: { type: string } & Record, +) { + const spec = editor.blockImplementations[block.type]; + const frame = spec.implementation.renderFrame?.call( + { + renderType: "dom", + props: undefined, + propSchema: spec.config.propSchema, + }, + block, + editor, + ); + if (spec.config.layoutOnly === true) { + if (!frame) { + // A layout block is its frame: without one, nothing of it is left. + throw new Error( + `Layout block "${block.type}" must draw its frame: \`renderFrame\` returned undefined.`, + ); + } + stampLayoutFrame(spec.config, frame, block); + } + return frame; +} + +/** + * A block's frame in the editor (the frame's view, like ProseMirror's + * `NodeView` for a node): what a block's node view needs from it. React + * supplies one through the internal `frameView` hook; a vanilla frame + * (`renderFrame`) gets one from `vanillaFrameView` (see `blockFrameView`). + */ +export type FrameView = { + dom: HTMLElement; + /** Holds the block's content and children. */ + slot: HTMLElement; + /** + * The node the view shows. It changes only when `update` keeps the view: + * ProseMirror can offer a view that refused a node to the next node. + */ + readonly node: PMNode; + /** Updates the frame for the changed node. `false` rebuilds the view. */ + update( + node: PMNode, + decorations: readonly Decoration[], + innerDecorations: DecorationSource, + ): boolean; + selectNode?(): void; + deselectNode?(): void; + destroy?(): void; +}; + +type FrameViewArgs = { + props: NodeViewRendererProps; + editor: BlockNoteEditor; + /** + * The node that holds the block's type and props: a `blockContainer`'s + * content node, or a layout block's own node. + */ + blockNode: (node: PMNode) => PMNode; + /** + * The element that holds the content and children when the block draws no + * frame (a block's `.bn-block`). Without it, the block must draw a frame. + */ + unframed?: HTMLElement; +}; + +/** + * The frame view of a block: React's (the internal `frameView` hook), or else + * the view of its vanilla `renderFrame`. + */ +export function blockFrameView(args: FrameViewArgs): FrameView { + const { props, editor, blockNode } = args; + return ( + editor.blockImplementations[ + blockNode(props.node).type.name + ].implementation.frameView?.(props) ?? vanillaFrameView(args) + ); +} + +/** + * The frame view of a block's vanilla `renderFrame`: draws the frame in the + * editor and keeps it current. A frame belongs to one block type and depends + * only on the block's props; a frame that shows more needs an update hook. + */ +function vanillaFrameView(args: FrameViewArgs): FrameView { + const { props, editor, blockNode, unframed } = args; + const config = editor.blockImplementations[blockNode(props.node).type.name] + .config as BlockConfig; + + // A layout block's frame is stamped after it is drawn or patched (see + // `stampLayoutFrame`). + function stamp(frame: VanillaFrame, block: any) { + if (config.layoutOnly === true) { + stampLayoutFrame(config, frame, block); + } + } + + // The block's `renderFrame`, with the editor's render context. + function draw(node: PMNode) { + const spec = editor.blockImplementations[blockNode(node).type.name]; + const renderFrame = spec.implementation.renderFrame; + if (!renderFrame) { + // The block type has no frame. Return `undefined`: no frame. + return undefined; + } + const block = nodeToBlock(node, props.view.state.doc); + const frame = renderFrame.call( + { renderType: "nodeView", props, propSchema: spec.config.propSchema }, + block, + editor, + ); + if (!frame) { + // The block declined its frame. Return `undefined`: no frame. + return undefined; + } + // The block drew its frame. Return it, stamped when it is a layout block. + stamp(frame, block); + return frame; + } + + const frame = draw(props.node); + let dom: HTMLElement; + let slot: HTMLElement; + if (frame) { + dom = frame.dom; + slot = frame.slot; + } else if (unframed) { + dom = unframed; + slot = unframed; + } else { + throw new Error( + `Block "${blockNode(props.node).type.name}" must draw its frame: \`renderFrame\` returned undefined.`, + ); + } + + let current = props.node; + + return { + dom, + slot, + get node() { + return current; + }, + update(node) { + const before = blockNode(current); + const after = blockNode(node); + if (!frame) { + // The block drew no frame. When its type or props change, ask again. + const asked = after.sameMarkup(before) ? undefined : draw(node); + if (asked) { + // The block now draws a frame. Return `false`: draw the block + // again. + asked.destroy?.(); + return false; + } + // The block still draws no frame. Return `true`: keep the block. + // Its content and children stay, also when its type changes. + current = node; + return true; + } + if (after.type !== before.type) { + // A frame belongs to one block type. Return `false`: draw the block + // again. + return false; + } + if (frame.update) { + // The frame patches itself. + const block = nodeToBlock(node, props.view.state.doc); + if (frame.update(block) === false) { + // The frame cannot show the block. Return `false`: draw the block + // again. + return false; + } + stamp(frame, block); + } else if (!after.sameMarkup(before)) { + // The props changed, and the frame cannot patch itself. Return + // `false`: draw the block again. + return false; + } + // The frame shows the block. Return `true`: keep the frame. + current = node; + return true; + }, + destroy: () => frame?.destroy?.(), + }; +} + +// Events on the chrome that ProseMirror still handles: drag-and-drop, so a +// block dropped on the chrome drops where the drop cursor shows it, and the +// clipboard, so a copy that starts in the chrome copies the selection. +const editorEvents = new Set([ + "dragenter", + "dragover", + "dragleave", + "drop", + "copy", + "cut", + "paste", +]); + +/** + * Whether ProseMirror leaves an event alone: it happened on the frame's + * chrome (inside the frame, outside its slot), which handles its own events. + * Drag-and-drop and clipboard events still go to ProseMirror (see + * `editorEvents`). + */ +export function isFrameChromeEvent( + frame: Pick, + event: Event, +): boolean { + const target = event.target; + return ( + target instanceof globalThis.Node && + frame.dom.contains(target) && + !frame.slot.contains(target) && + !editorEvents.has(event.type) + ); +} + +/** + * Whether ProseMirror ignores a DOM change in a framed block: a change to the + * frame's chrome or to the slot element's own attributes is the frame's + * rendering, and Dark Reader's rewrites are not edits either. Changes inside + * the slot (the block's content and children) and selection changes are + * read. + */ +export function isFrameRenderingMutation( + frame: Pick, + mutation: ViewMutationRecord, +): boolean { + if (mutation.type === "selection") { + return false; + } + if (isDarkReaderMutation(mutation)) { + return true; + } + if (mutation.target === frame.slot && mutation.type === "attributes") { + return true; + } + return ( + frame.dom.contains(mutation.target) && !frame.slot.contains(mutation.target) + ); +} + +/** + * The node view hooks that come from a block's frame alone: its chrome rules + * (`isFrameChromeEvent`, `isFrameRenderingMutation`), and its selection and + * cleanup. + */ +export function frameNodeViewHooks(frame: FrameView) { + return { + stopEvent: (event: Event) => isFrameChromeEvent(frame, event), + ignoreMutation: (mutation: ViewMutationRecord) => + isFrameRenderingMutation(frame, mutation), + selectNode: frame.selectNode?.bind(frame), + deselectNode: frame.deselectNode?.bind(frame), + destroy: frame.destroy?.bind(frame), + }; +} diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index cfd17b9d11..542f4408be 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -5,7 +5,10 @@ import { defaultBlockToHTML } from "../../blocks/defaultBlockHelpers.js"; import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.js"; import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; +import { propsToDataAttributes, setDataAttributes } from "../dataAttributes.js"; import { PropSchema, Props } from "../propTypes.js"; +import { createBlockKeyboard } from "./keyboard.js"; +import { resolveBlockMeta } from "./meta.js"; import { LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap @@ -60,14 +63,8 @@ export function propsToAttributes(propSchema: PropSchema): Attributes { return value; }, - renderHTML: (attributes) => { - // don't render to html if the value is the same as the default - return attributes[name] !== spec.default - ? { - [camelToDataKebab(name)]: attributes[name], - } - : {}; - }, + renderHTML: (attributes) => + propsToDataAttributes({ [name]: attributes[name] }, { [name]: spec }), }; }); @@ -157,6 +154,39 @@ export function getBlockFromNodeView( } } +/** + * `Node.DOCUMENT_FRAGMENT_NODE`, inlined. Server-side rendering shims only + * `document` and `window` onto the global scope, so `Node` and + * `DocumentFragment` are undefined there and `instanceof` throws. + */ +const DOCUMENT_FRAGMENT_NODE = 11; + +export function isDocumentFragment( + node: HTMLElement | DocumentFragment, +): node is DocumentFragment { + return node.nodeType === DOCUMENT_FRAGMENT_NODE; +} + +/** + * 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 || isDocumentFragment(dom)) { + 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 @@ -198,15 +228,13 @@ export function wrapInBlockStructure< ); // Sets content type attribute blockContent.setAttribute("data-content-type", blockType); - // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props - // which are already added as HTML attributes to the parent `blockContent` - // element (inheritedProps) and props set to their default values. - for (const [prop, value] of Object.entries(blockProps)) { - const spec = propSchema[prop]; - if (value !== spec?.default) { - blockContent.setAttribute(camelToDataKebab(prop), value); - } - } + // Adds the props as `data-*` attributes, leaving out those at their default. + // Set only: the tiptap `renderHTML` path passes the props through + // `domAttributes` above and empty `blockProps`. + setDataAttributes( + blockContent, + propsToDataAttributes(blockProps, propSchema), + ); // Adds file block attribute if (isFileBlock) { blockContent.setAttribute("data-file-block", ""); @@ -247,6 +275,11 @@ export function createBlockSpecFromTiptapNode< }, implementation: { node: config.node, + meta: resolveBlockMeta(undefined, {}), + keyboard: createBlockKeyboard(undefined, { + isLayoutBlock: false, + hardBreakShortcut: undefined, + }), render: defaultBlockToHTML, toExternalHTML: defaultBlockToHTML, }, diff --git a/packages/core/src/schema/blocks/keyboard.ts b/packages/core/src/schema/blocks/keyboard.ts new file mode 100644 index 0000000000..31a09c8ff6 --- /dev/null +++ b/packages/core/src/schema/blocks/keyboard.ts @@ -0,0 +1,154 @@ +/** + * How the keyboard treats a block, as far as it differs from an ordinary + * paragraph. + * + * When settings meet, they are applied in this order: + * - `enter: "line-break"` first: Enter then never splits or resets the block. + * - `emptyEnterResets` before `enter: "into-children"`: Enter in an empty + * block resets it, even when its Enter otherwise goes into its children. + * - A child's `emptyEnterResets` before its parent's `emptyChildEnter`. + * - `enter: "into-children"` before `splitKeepsType`: the new first child is a + * paragraph. + * - Enter at the start of non-empty content always inserts an empty block + * above it, so the block keeps its id, type and props. + */ +export type BlockKeyboard = { + /** + * What Enter does in the block's content. + * - `"split"`: splits the block. The text after the caret goes into a new + * block after it. + * - `"into-children"`: the text after the caret goes into a new first child. + * Backspace at the start of the first child merges it back into the block + * if both have inline content. A block with plain-text content never takes + * merged text, so there, Backspace un-nests the child instead (or does + * nothing when `childrenCanOutdent` is `false`). + * - `"line-break"`: inserts a line break (a `"\n"` in `content: "plain"` + * blocks). Shift-Enter then does the same. + * @default "split" + */ + enter: "split" | "into-children" | "line-break"; + /** + * What Shift-Enter does in the block's content. + * @default "line-break" + */ + shiftEnter: "line-break" | "same-as-enter"; + /** + * Whether a block created by splitting this one with Enter has the same type, + * as in lists. Also applies to the empty block Enter inserts above the + * block's content. New blocks always get default props. + * @default false + */ + splitKeepsType: boolean; + /** + * Whether the block's children stay with it when Enter splits it. Otherwise + * they follow the text after the caret into the new block, as for nested + * blocks and list items, so the new block takes the block's place above + * them. A block whose Enter starts its children (`enter: + * "into-children"`) owns them, so it keeps them when a split happens + * anyway (Enter with a selection); a closed toggle sets this to keep its + * hidden children. Enter at the start of the content always keeps them, as + * the block itself moves down. + * @default `true` when `enter` is `"into-children"`, otherwise `false` + */ + splitKeepsChildren: boolean; + /** + * What the block turns into when it is reset: by Backspace at the start of + * its content, and by Enter in an empty block when `emptyEnterResets` is set. + * Its content and children are kept. `props` are merged into the block's + * props, so return the block's own type to only change props. + * @default { type: "paragraph" } + */ + resetsTo: { type: string; props?: Record }; + /** + * Whether Enter in the empty block resets it (see `resetsTo`), as when an + * empty list item turns into a paragraph. + * @default false + */ + emptyEnterResets: boolean; + /** + * What Enter does in an empty child of this block. + * - `"outdent"`: any empty child is outdented, as for nested blocks + * (needs `childrenCanOutdent`). + * - `"exit-at-end"`: an empty last child moves out to after this block, as + * for layout blocks. An empty child elsewhere gets a new child after it. + * - `"stay"`: an empty child always gets a new child after it. + * @default "outdent", or "exit-at-end" for layout blocks + */ + emptyChildEnter: "outdent" | "exit-at-end" | "stay"; + /** + * Whether this block's children can be outdented out of it: Shift-Tab, the + * unnest button, and the outdent that Backspace and Enter do at the start of + * an empty or nested block. A layout block's children can never be outdented, + * so this has no effect on layout blocks. + * @default true, or false for layout blocks + */ + childrenCanOutdent: boolean; +}; + +/** + * The `experimental_keyboard` option of a block implementation (this API may + * change): the settings that differ + * from the defaults, or a function of the block that returns them, so they can + * depend on the block's props (a toggle heading vs. a regular heading) or on + * view state the block owns (whether a toggle is open). + */ +export type BlockKeyboardOption = + | Partial + // Declared as a method so a spec for a specific block type still fits where + // a spec for any block is expected (method parameters are checked + // bivariantly), like `meta.highlight`. + | { keyboard(block: TBlock): Partial }["keyboard"]; + +/** + * Fills in the defaults of a block's `experimental_keyboard` option. The + * result is what a + * block spec in a schema holds: a function of the block that returns every + * setting. + * @internal + */ +export function createBlockKeyboard( + option: BlockKeyboardOption | undefined, + spec: { + isLayoutBlock: boolean; + /** The deprecated `meta.hardBreakShortcut`, read when set. */ + hardBreakShortcut: "shift+enter" | "enter" | "none" | undefined; + }, +): (block: TBlock) => BlockKeyboard { + const { isLayoutBlock, hardBreakShortcut } = spec; + const defaults: BlockKeyboard = { + enter: hardBreakShortcut === "enter" ? "line-break" : "split", + shiftEnter: hardBreakShortcut === "none" ? "same-as-enter" : "line-break", + splitKeepsType: false, + splitKeepsChildren: false, + resetsTo: { type: "paragraph" }, + emptyEnterResets: false, + // A layout block's children can't be outdented (the schema doesn't allow + // them outside it), so an empty last child leaves it instead. + emptyChildEnter: isLayoutBlock ? "exit-at-end" : "outdent", + childrenCanOutdent: !isLayoutBlock, + }; + if (typeof option === "function") { + return (block) => withDefaults(defaults, option(block)); + } + const keyboard = withDefaults(defaults, option); + return () => keyboard; +} + +// A setting given as `undefined` (e.g. `open ? "into-children" : undefined`) +// keeps its default, rather than replacing it with `undefined`. +function withDefaults( + defaults: BlockKeyboard, + settings: Partial | undefined, +): BlockKeyboard { + const keyboard = { ...defaults }; + for (const [key, value] of Object.entries(settings ?? {})) { + if (value !== undefined) { + Object.assign(keyboard, { [key]: value }); + } + } + // A block whose Enter starts its children owns them. + if (settings?.splitKeepsChildren === undefined) { + keyboard.splitKeepsChildren = keyboard.enter === "into-children"; + } + return keyboard; +} diff --git a/packages/core/src/schema/blocks/layoutBlockAttributes.ts b/packages/core/src/schema/blocks/layoutBlockAttributes.ts new file mode 100644 index 0000000000..dae3ba0ea4 --- /dev/null +++ b/packages/core/src/schema/blocks/layoutBlockAttributes.ts @@ -0,0 +1,29 @@ +import { applyDataAttributes } from "../dataAttributes.js"; +import { PropSchema, Props } from "../propTypes.js"; + +/** + * Writes the attributes a layout block's round-trip parse reads onto its + * root element: the `data-node-type` marker, the block's non-default props as + * `data-*` (see `propsToDataAttributes`), and its id where there is one. + * Existing attributes follow the block props, including removing defaults. + * @internal + */ +export function applyLayoutBlockAttributes( + element: HTMLElement | undefined | null, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id?: string, +) { + if (!element) { + return; + } + + applyDataAttributes(element, blockProps, propSchema); + + // Reserved markers win even when a prop maps to the same attribute. + element.setAttribute("data-node-type", blockType); + if (id) { + element.setAttribute("data-id", id); + } +} diff --git a/packages/core/src/schema/blocks/meta.test.ts b/packages/core/src/schema/blocks/meta.test.ts new file mode 100644 index 0000000000..192babda55 --- /dev/null +++ b/packages/core/src/schema/blocks/meta.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveBlockMeta } from "./meta.js"; + +describe("resolveBlockMeta", () => { + it("fills in the defaults of a regular block", () => { + expect(resolveBlockMeta(undefined, {})).toEqual({ + selectable: true, + code: false, + defining: true, + isolating: true, + draggable: true, + }); + }); + + it("keeps what the block declares", () => { + const highlight = () => "latex"; + expect( + resolveBlockMeta( + { selectable: false, code: true, isolating: false, highlight }, + {}, + ), + ).toEqual({ + selectable: false, + code: true, + defining: true, + isolating: false, + draggable: true, + highlight, + }); + }); + + it("decides a layout block's node flags itself", () => { + expect( + resolveBlockMeta( + { code: true, defining: false, isolating: true }, + { layoutOnly: true }, + ), + ).toMatchObject({ code: false, defining: true, isolating: false }); + }); + + it("gives a named-only layout block no drag handle unless it opts in", () => { + const namedOnly = { layoutOnly: true, placeable: "namedOnly" } as const; + expect(resolveBlockMeta(undefined, namedOnly).draggable).toBe(false); + expect(resolveBlockMeta({ draggable: true }, namedOnly).draggable).toBe( + true, + ); + expect( + resolveBlockMeta(undefined, { layoutOnly: true, placeable: "anywhere" }) + .draggable, + ).toBe(true); + }); +}); diff --git a/packages/core/src/schema/blocks/meta.ts b/packages/core/src/schema/blocks/meta.ts new file mode 100644 index 0000000000..0be0248ebe --- /dev/null +++ b/packages/core/src/schema/blocks/meta.ts @@ -0,0 +1,35 @@ +import type { PropSchema } from "../propTypes.js"; +import type { + BlockConfig, + BlockConfigMeta, + ResolvedBlockConfigMeta, +} from "./types.js"; + +/** + * Fills in the defaults of a block's `meta`. The result is what a block spec + * in a schema holds, so readers never see a missing setting. + * + * A layout block's node is never `code`, is `defining`, and is not `isolating` + * (ProseMirror would otherwise wrap a slice pasted across its edges in a + * spurious `blockGroup`), whatever its `meta` says. A layout block placeable + * `"namedOnly"` can only live in its parent, so it gets no drag handle unless + * `draggable` says so. + * @internal + */ +export function resolveBlockMeta< + TName extends string, + TProps extends PropSchema, +>( + meta: BlockConfigMeta | undefined, + config: Pick, +): ResolvedBlockConfigMeta { + const isLayoutBlock = config.layoutOnly === true; + return { + ...meta, + selectable: meta?.selectable ?? true, + code: isLayoutBlock ? false : (meta?.code ?? false), + defining: isLayoutBlock ? true : (meta?.defining ?? true), + isolating: isLayoutBlock ? false : (meta?.isolating ?? true), + draggable: meta?.draggable ?? config.placeable !== "namedOnly", + }; +} diff --git a/packages/core/src/schema/blocks/renderFrame.test.ts b/packages/core/src/schema/blocks/renderFrame.test.ts new file mode 100644 index 0000000000..e9853ad4f2 --- /dev/null +++ b/packages/core/src/schema/blocks/renderFrame.test.ts @@ -0,0 +1,691 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + getBlockFromElement, + getDraggableBlockFromElement, +} from "../../extensions/blockDOM.js"; +import type { LooseBlockSpec } from "./types.js"; +import { createBlockSpec } from "./createSpec.js"; + +// Behaviour of the vanilla `renderFrame` hook: a block draws the box around +// its content and children, and may decline the frame by returning +// `undefined` — the toggle pattern. A frame can be patched +// in place through `update`. + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// A titled block that is only framed when it says so: closed toggles draw +// the box, open ones render as plain nesting. +const Toggle = createBlockSpec( + { + type: "toggle" as const, + propSchema: { + mode: { + default: "plain", + values: ["plain", "framed"], + }, + }, + content: "inline", + }, + { + render: renderDiv, + renderFrame: (block) => { + if (block.props.mode !== "framed") { + return undefined; + } + const dom = document.createElement("div"); + dom.className = "toggle"; + const slot = document.createElement("div"); + slot.className = "toggle-slot"; + dom.append(slot); + return { dom, slot }; + }, + }, +)(); + +// A titled block with an update hook that patches frame chrome in place. +const FrameBox = createBlockSpec( + { + type: "frameBox" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "warning"], + }, + }, + content: "inline", + }, + { + render: renderDiv, + renderFrame: (block) => { + const dom = document.createElement("div"); + dom.className = "frame-box"; + dom.dataset.flavor = block.props.flavor; + const slot = document.createElement("div"); + slot.className = "frame-slot"; + dom.append(slot); + return { + dom, + slot, + update: (newBlock) => { + dom.dataset.flavor = (newBlock as any).props.flavor; + }, + }; + }, + }, +)(); + +const ContentFrame = createBlockSpec( + { + type: "contentFrame", + propSchema: {}, + content: "inline", + }, + { + render: renderDiv, + meta: { draggable: false }, + renderFrame(block) { + const dom = document.createElement("section"); + dom.className = "content-frame"; + dom.dataset.title = JSON.stringify(block.content); + dom.dataset.count = String(block.children.length); + const slot = document.createElement("div"); + dom.append(slot); + // The chrome shows the title and the child count, so the frame keeps + // them current. + return { + dom, + slot, + update(updated) { + dom.dataset.title = JSON.stringify(updated.content); + dom.dataset.count = String(updated.children.length); + return true; + }, + }; + }, + }, +)(); + +// A frame without an `update` hook, whose chrome shows a prop. +const PropFrame = createBlockSpec( + { + type: "propFrame", + propSchema: { tone: { default: "calm" } }, + content: "inline", + }, + { + render: renderDiv, + renderFrame(block) { + const dom = document.createElement("section"); + dom.className = "prop-frame"; + dom.dataset.tone = block.props.tone; + return { dom, slot: dom }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + toggle: Toggle, + contentFrame: ContentFrame, + propFrame: PropFrame, + frameBox: FrameBox, + } as const, +}); + +function editorWith(initialContent: any[]) { + const editor = BlockNoteEditor.create({ schema, initialContent }); + editor.mount(document.createElement("div")); + return editor; +} + +describe("renderFrame decline", () => { + it("renders plain nesting when declined, frames when accepted, and flips back", () => { + const editor = editorWith([ + { + id: "t1", + type: "toggle", + props: { mode: "plain" }, + content: "Title", + children: [{ id: "c1", type: "paragraph", content: "Body" }], + }, + ]); + try { + const root = editor.domElement!; + // Declined: ordinary nesting, no box. + expect(root.querySelector(".toggle")).toBeNull(); + expect(root.textContent).toContain("Title"); + expect(root.textContent).toContain("Body"); + + editor.updateBlock("t1", { + props: { mode: "framed" }, + } as any); + + // Accepted: the box wraps the title and the body together, and the + // child block survives the rebuild. + const framed = root.querySelector(".toggle")!; + expect(framed).not.toBeNull(); + const slot = framed.querySelector(".toggle-slot")!; + expect(slot.textContent).toContain("Title"); + expect(slot.querySelector('[data-id="c1"]')).not.toBeNull(); + expect(slot.textContent).toContain("Body"); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + + editor.updateBlock("t1", { + props: { mode: "plain" }, + } as any); + + // Declined again: the box is gone, the content is intact. + expect(root.querySelector(".toggle")).toBeNull(); + expect(root.textContent).toContain("Title"); + expect(root.textContent).toContain("Body"); + expect(root.querySelector('[data-id="c1"]')).not.toBeNull(); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + } finally { + editor._tiptapEditor.destroy(); + } + }); +}); + +describe("renderFrame updates", () => { + it("keeps a frame without an update hook until the block's props change", () => { + const editor = editorWith([ + { id: "frame", type: "propFrame", content: "Title" }, + ]); + try { + const root = editor.domElement!; + const first = root.querySelector(".prop-frame"); + editor.updateBlock("frame", { content: "Typed" }); + editor.updateBlock("frame", { + children: [{ id: "child", type: "paragraph", content: "Body" }], + }); + expect(root.querySelector(".prop-frame")).toBe(first); + + editor.updateBlock("frame", { props: { tone: "loud" } }); + const rebuilt = root.querySelector(".prop-frame")!; + expect(rebuilt).not.toBe(first); + expect(rebuilt.dataset.tone).toBe("loud"); + expect(rebuilt.textContent).toContain("Typed"); + expect(rebuilt.textContent).toContain("Body"); + } finally { + editor._tiptapEditor.destroy(); + } + }); + + it("does not rebuild a declined frame's block while typing", () => { + const editor = editorWith([ + { + id: "frame", + type: "toggle", + props: { mode: "plain" }, + content: "Title", + }, + ]); + try { + const block = () => + editor.domElement!.querySelector( + '[data-node-type="blockContainer"][data-id="frame"]', + ); + const first = block(); + editor.updateBlock("frame", { content: "Typed" }); + editor.updateBlock("frame", { + children: [{ id: "child", type: "paragraph", content: "Body" }], + }); + expect(block()).toBe(first); + expect(editor.domElement!.querySelector(".toggle")).toBeNull(); + } finally { + editor._tiptapEditor.destroy(); + } + }); + + it("keeps a declined frame's block when a prop change keeps it declined", () => { + const editor = editorWith([ + { + id: "heading", + type: "heading", + content: "Title", + children: [{ id: "child", type: "paragraph", content: "Body" }], + }, + ]); + try { + const block = () => + editor.domElement!.querySelector( + '[data-node-type="blockContainer"][data-id="heading"]', + ); + const child = () => editor.domElement!.querySelector('[data-id="child"]'); + const [firstBlock, firstChild] = [block(), child()]; + editor.updateBlock("heading", { props: { textColor: "red" } }); + expect(block()).toBe(firstBlock); + expect(child()).toBe(firstChild); + + // Becoming toggleable draws the frame: the view is rebuilt. + editor.updateBlock("heading", { props: { isToggleable: true } }); + expect(block()).not.toBe(firstBlock); + expect(block()!.querySelector(".bn-toggle-frame")).not.toBeNull(); + } finally { + editor._tiptapEditor.destroy(); + } + }); + + it("refreshes vanilla frame chrome when title content changes", () => { + const editor = editorWith([ + { + id: "frame", + type: "contentFrame", + content: "Title", + children: [{ id: "child", type: "paragraph", content: "Body" }], + }, + ]); + try { + editor.updateBlock("frame", { content: "Updated" }); + expect( + editor + .domElement!.querySelector(".content-frame") + ?.getAttribute("data-title"), + ).toContain("Updated"); + expect( + editor.domElement!.querySelector('[data-id="child"]')?.textContent, + ).toBe("Body"); + } finally { + editor._tiptapEditor.destroy(); + } + }); + + it("mounts children in the slot and patches chrome in place on prop change", () => { + const editor = editorWith([ + { + id: "box-0", + type: "frameBox", + props: { flavor: "warning" }, + children: [{ id: "box-child", type: "paragraph", content: "Child" }], + }, + ]); + try { + const root = editor.domElement!; + const box = root.querySelector(".frame-box")!; + // Non-default props are stamped for the round-trip parse to read. + expect(box.getAttribute("data-flavor")).toBe("warning"); + const slot = box.querySelector(".frame-slot")!; + expect(slot.querySelector('[data-id="box-child"]')).not.toBeNull(); + expect(slot.textContent).toBe("Child"); + + editor.updateBlock("box-0", { + props: { flavor: "tip" }, + } as any); + + // The chrome follows the prop change, and the slot element itself is + // untouched: the frame patched in place instead of rebuilding. The + // author's frame also follows a prop returning to its default. + expect( + root.querySelector(".frame-box")!.getAttribute("data-flavor"), + ).toBe("tip"); + expect(root.querySelector(".frame-slot")).toBe(slot); + expect(slot.querySelector('[data-id="box-child"]')).not.toBeNull(); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + } finally { + editor._tiptapEditor.destroy(); + } + }); +}); + +it("honors a titled block's draggable flag through its regular block wrapper", () => { + const editor = editorWith([ + { id: "locked", type: "contentFrame", content: "Title" }, + ]); + try { + const blockSpecs: Record = editor.schema.blockSpecs; + const title = editor.domElement!.querySelector( + '[data-content-type="contentFrame"]', + )!; + expect( + getDraggableBlockFromElement( + title, + editor._tiptapEditor.view, + (type) => blockSpecs[type].implementation.meta?.draggable !== false, + ), + ).toBeUndefined(); + const block = getBlockFromElement(title, editor._tiptapEditor.view); + expect(block).toMatchObject({ id: "locked", type: "contentFrame" }); + expect(block?.node.getAttribute("data-node-type")).toBe("blockContainer"); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("supplies the node-view context to vanilla frames", () => { + const contextualFrame = createBlockSpec( + { type: "contextualFrame", propSchema: {}, content: "inline" }, + { + render: renderDiv, + renderFrame() { + expect(this.renderType).toBe("nodeView"); + expect(this.props?.node.firstChild?.type.name).toBe("contextualFrame"); + expect(this.propSchema).toEqual({}); + const dom = document.createElement("div"); + dom.className = "contextual-frame"; + return { dom, slot: dom }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, contextualFrame }, + }), + initialContent: [{ type: "contextualFrame", content: "Title" }], + }); + try { + editor.mount(document.createElement("div")); + expect( + editor.domElement?.querySelector(".contextual-frame")?.textContent, + ).toBe("Title"); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("supplies the node-view context to vanilla layout block frames, and stamps them", () => { + const contextualLayout = createBlockSpec( + { + type: "contextualLayout", + propSchema: { tone: { default: "plain" } }, + content: "none", + layoutOnly: true, + }, + { + renderFrame() { + expect(this.renderType).toBe("nodeView"); + expect(this.props?.node.type.name).toBe("contextualLayout"); + const dom = document.createElement("section"); + const slot = document.createElement("div"); + dom.append(slot); + return { dom, slot }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, contextualLayout }, + }), + initialContent: [ + { + id: "box", + type: "contextualLayout", + props: { tone: "loud" }, + children: [{ content: "Body" }], + }, + ], + }); + try { + editor.mount(document.createElement("div")); + const frame = editor.domElement!.querySelector("section")!; + expect(frame.getAttribute("data-node-type")).toBe("contextualLayout"); + expect(frame.getAttribute("data-id")).toBe("box"); + expect(frame.getAttribute("data-tone")).toBe("loud"); + expect(frame.firstElementChild!.hasAttribute("data-frame-slot")).toBe(true); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("rebuilds or declines a frame when update returns false and cleans up each instance", () => { + const destroy = vi.fn(); + const lifecycleFrame = createBlockSpec( + { + type: "lifecycleFrame", + propSchema: { + mode: { default: "first", values: ["first", "second", "plain"] }, + }, + content: "inline", + }, + { + render: renderDiv, + renderFrame(block) { + if (block.props.mode === "plain") { + return undefined; + } + const dom = document.createElement("section"); + dom.className = "lifecycle-frame"; + return { + dom, + slot: dom, + destroy, + update(updated) { + return updated.props.mode === block.props.mode; + }, + }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, lifecycleFrame }, + }), + initialContent: [ + { + id: "frame", + type: "lifecycleFrame", + content: "Title", + children: [{ id: "child", content: "Body" }], + }, + ], + }); + try { + editor.mount(document.createElement("div")); + const root = editor.domElement!; + const first = root.querySelector(".lifecycle-frame"); + editor.updateBlock("frame", { content: "Updated" }); + expect(root.querySelector(".lifecycle-frame")).toBe(first); + expect(destroy).not.toHaveBeenCalled(); + editor.updateBlock("frame", { props: { mode: "second" } }); + expect(root.querySelector(".lifecycle-frame")).not.toBe(first); + expect(destroy).toHaveBeenCalledTimes(1); + editor.updateBlock("frame", { props: { mode: "plain" } }); + expect(root.querySelector(".lifecycle-frame")).toBeNull(); + expect(destroy).toHaveBeenCalledTimes(2); + expect(editor.getBlock("frame")!.children[0].id).toBe("child"); + expect(root.textContent).toContain("Updated"); + expect(root.textContent).toContain("Body"); + editor.updateBlock("frame", { props: { mode: "first" } }); + expect(root.querySelector(".lifecycle-frame")).not.toBeNull(); + } finally { + editor._tiptapEditor.destroy(); + } + // Three frames, plus the one drawn to ask the declined frame again when + // its mode changed back: each is destroyed. + expect(destroy).toHaveBeenCalledTimes(4); +}); + +it("exports title and children inside the static frame slot with the DOM context", () => { + const staticFrame = createBlockSpec( + { + type: "staticFrame", + propSchema: { framed: { default: true } }, + content: "inline", + }, + { + render: renderDiv, + renderFrame(block) { + expect(this.renderType).toBe("dom"); + expect(this.props).toBeUndefined(); + expect(this.propSchema).toEqual({ framed: { default: true } }); + if (!block.props.framed) { + return undefined; + } + const dom = document.createElement("section"); + dom.className = "static-frame"; + const slot = document.createElement("div"); + slot.className = "static-slot"; + dom.append(slot); + return { dom, slot }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, staticFrame }, + }), + initialContent: [ + { + id: "framed", + type: "staticFrame", + content: "Title", + children: [{ id: "body", content: "Body" }], + }, + { + id: "plain", + type: "staticFrame", + props: { framed: false }, + content: "Plain", + }, + ], + }); + try { + const dom = document.createElement("div"); + dom.innerHTML = editor.blocksToFullHTML(editor.document); + expect(dom.querySelectorAll(".static-frame")).toHaveLength(1); + const slot = dom.querySelector(".static-slot")!; + expect(slot.querySelector(":scope > .bn-block-content")?.textContent).toBe( + "Title", + ); + expect(slot.querySelector(":scope > .bn-block-group")?.textContent).toBe( + "Body", + ); + expect(dom.textContent).toContain("Plain"); + expect(editor.tryParseHTMLToBlocks(dom.innerHTML)).toEqual(editor.document); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("keeps a vanilla frame whose chrome changes by itself", async () => { + const counterFrame = createBlockSpec( + { type: "counterFrame", propSchema: {}, content: "inline" }, + { + render: renderDiv, + renderFrame() { + const dom = document.createElement("section"); + dom.className = "counter-frame"; + const counter = document.createElement("button"); + counter.contentEditable = "false"; + counter.className = "counter"; + counter.textContent = "0"; + const slot = document.createElement("div"); + dom.append(counter, slot); + return { dom, slot }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, counterFrame }, + }), + initialContent: [{ id: "frame", type: "counterFrame", content: "Title" }], + }); + try { + editor.mount(document.createElement("div")); + const root = editor.domElement!; + const frame = root.querySelector(".counter-frame"); + // The chrome updates itself, for example from a click handler. + root.querySelector(".counter")!.textContent = "1"; + // Lets ProseMirror's mutation observer run. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(root.querySelector(".counter-frame")).toBe(frame); + expect(root.querySelector(".counter")!.textContent).toBe("1"); + expect(editor.document).toHaveLength(1); + expect(editor.document[0].content).toEqual([ + { type: "text", text: "Title", styles: {} }, + ]); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("parses chrome outside the static frame slot as no document content", () => { + // A frame with a labelled control beside its slot, as the toggle frame + // draws its buttons. The control's text is in the exported HTML, but the + // round-trip parse reads only the slot. + const chromeFrame = createBlockSpec( + { type: "chromeFrame", propSchema: {}, content: "inline" }, + { + render: renderDiv, + renderFrame() { + const dom = document.createElement("div"); + dom.className = "chrome-frame"; + const control = document.createElement("button"); + control.contentEditable = "false"; + control.textContent = "UI LABEL"; + const slot = document.createElement("div"); + slot.className = "chrome-slot"; + dom.append(control, slot); + return { dom, slot }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, chromeFrame }, + }), + initialContent: [ + { + id: "framed", + type: "chromeFrame", + content: "Title", + children: [{ id: "body", content: "Body" }], + }, + ], + }); + try { + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain("UI LABEL"); + expect(editor.tryParseHTMLToBlocks(html)).toEqual(editor.document); + } finally { + editor._tiptapEditor.destroy(); + } +}); + +it("never gives one layout block's frame to another layout block", () => { + // ProseMirror offers a view whose node is gone to the next node of the same + // type. A frame shows one block, so it must refuse another block. + const patchedBox = createBlockSpec( + { + type: "patchedBox", + propSchema: { tone: { default: "plain" } }, + content: "none", + layoutOnly: true, + }, + { + renderFrame(block) { + const dom = document.createElement("section"); + dom.dataset.drawnFor = block.id; + return { dom, slot: dom, update: () => true }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, patchedBox }, + }), + initialContent: [ + { id: "a", type: "patchedBox", children: [{ content: "A" }] }, + { id: "b", type: "patchedBox", children: [{ content: "B" }] }, + ], + }); + try { + editor.mount(document.createElement("div")); + editor.transact(() => { + editor.updateBlock("b", { props: { tone: "loud" } }); + editor.removeBlocks(["a"]); + }); + const frame = editor.domElement!.querySelector('[data-id="b"]')!; + expect(frame.getAttribute("data-drawn-for")).toBe("b"); + } finally { + editor._tiptapEditor.destroy(); + } +}); diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index d91ee10f89..5af9f0eb23 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,11 +1,8 @@ +import type { FrameView } from "./frame.js"; /** 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 { @@ -19,6 +16,7 @@ import type { StyledText, } from "../inlineContent/types.js"; import type { PropSchema, Props } from "../propTypes.js"; +import type { BlockKeyboard, BlockKeyboardOption } from "./keyboard.js"; import type { StyleSchema } from "../styles/types.js"; export type BlockNoteDOMElement = @@ -39,6 +37,10 @@ export interface BlockConfigMeta< /** * Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. * @default "shift+enter" + * @deprecated Use `experimental_keyboard.enter` and + * `experimental_keyboard.shiftEnter` instead: `"enter"` is + * `experimental_keyboard: { enter: "line-break" }`, and `"none"` is + * `experimental_keyboard: { shiftEnter: "same-as-enter" }`. */ hardBreakShortcut?: "shift+enter" | "enter" | "none"; @@ -67,6 +69,25 @@ 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, layout block 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`, or `false` for a layout block placeable `"namedOnly"`, which + * can only live in its parent + */ + draggable?: boolean; + + /** + * Whether a block dragged onto this block's content or frame chrome becomes + * its first child (as in Notion's toggles), instead of going before or after + * it. A block dragged onto its children still goes between them. + * @default false + */ + dropsIntoChildren?(block: { type: TName; props: Props }): boolean; + /** * Enables syntax highlighting of the contents of the block with the result of this callback */ @@ -81,9 +102,82 @@ export interface BlockConfigMeta< } /** - * BlockConfig contains the "schema" info about a Block type - * i.e. what props it supports, what content it supports, etc. + * A block's `meta` with every default applied: what a block spec in a schema + * holds, so readers never see a missing setting (see `resolveBlockMeta`). */ +export type ResolvedBlockConfigMeta< + TName extends string = string, + TProps extends PropSchema = PropSchema, +> = BlockConfigMeta & + Required< + Pick< + BlockConfigMeta, + "selectable" | "code" | "defining" | "isolating" | "draggable" + > + >; + +/** + * The type name of a layout block, as used in {@link ChildrenConfig.allow}. + */ +export type AllowedChildType = string; + +/** + * What may appear as a child of a layout block. + * + * - `"blocks"`: any regular block, or any layout block placeable anywhere. + * This cannot be narrowed to specific regular block types: every regular + * block is the *same* ProseMirror node (`blockContainer`), so paragraphs, + * headings and code blocks are indistinguishable at the node level. + * - `readonly AllowedChildType[]`: only these layout block types, enforced exactly + * by the schema. 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. + * + * Neither form includes `placeable: "namedOnly"` types. Those appear only + * where a parent names them explicitly in an array. + */ +export type ChildrenAllow = "blocks" | readonly AllowedChildType[]; + +/** + * Which child blocks a layout block holds (`layoutOnly: true`), exposed as + * `block.children` at runtime. + * + * The config describes one uniform set of children, semantically a single + * implicit slot. Ordered multi-slot children (a `sequence` of slots) can be + * added later as a sibling form. + */ +export type ChildrenConfig = + | { + /** + * Any blocks, the default. When its last child is removed, the + * layout block is removed too. + */ + allow?: "blocks"; + /** + * Layout block types the block does not hold, out of those placeable + * anywhere: a column holds any block except a column list. Regular + * block types can't be excluded, as every regular block is the same + * ProseMirror node. + */ + exclude?: readonly AllowedChildType[]; + } + | { + /** Which layout block types may appear as a child. */ + allow: readonly AllowedChildType[]; + /** + * How few children the layout block may hold; `0` lets it be empty. + * @default 1 + */ + min?: number; + /** + * Whether a layout block with too few children dissolves into what they + * hold, like a column list with one column left. `false` keeps the + * layout block and its empty children, like a tab set. + * @default true + */ + dissolve?: boolean; + }; + export interface BlockConfig< T extends string = string, PS extends PropSchema = PropSchema, @@ -106,8 +200,43 @@ 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) + /** + * `true` makes the block a layout block: a block without content of its own + * (`content: "none"`) whose own node holds its child blocks. Its + * `renderFrame` puts them in its slot (React: `slotRef`). With `false` (the + * default), the block's child blocks are indented below it. Only a block + * with `content: "none"` can set `true`. + */ + layoutOnly?: C extends "none" ? boolean : false; + /** + * Which child blocks the block may have. Every block can have any child + * blocks (`{ allow: "blocks" }`, the default). Only a layout block + * (`layoutOnly: true`) can restrict them to certain types or a minimum + * count, since the child blocks of other blocks all share one untyped + * group. How the keyboard treats a block's children (e.g. whether Enter in + * the block's text starts them) is set with its `experimental_keyboard` + * settings. + */ + children?: C extends "none" + ? ChildrenConfig + : { + allow?: "blocks"; + exclude?: undefined; + min?: undefined; + dissolve?: undefined; + }; + /** + * Where this block may be placed. + * + * - `"anywhere"` (default): anywhere a regular block goes, the document + * root or nested under any other block. + * - `"namedOnly"`: only inside a layout block that names this type in its + * `children.allow` array (e.g. a `column` inside a `columnList`). + * + * Only meaningful for layout blocks; regular blocks are always placeable + * anywhere. + */ + placeable?: "anywhere" | "namedOnly"; } declare module "prosemirror-model" { @@ -224,10 +353,19 @@ export type LooseBlockSpec< config: BlockConfig; implementation: Omit< BlockImplementation, - "render" | "toExternalHTML" + | "render" + | "renderFrame" + | "toExternalHTML" + | "experimental_keyboard" + | "meta" > & { + /** Every keyboard setting of the block, with defaults filled in. */ + keyboard: (block: any) => BlockKeyboard; + /** The block's `meta`, with defaults filled in. */ + meta: ResolvedBlockConfigMeta; // purposefully stub the types for render and toExternalHTML since they reference the block - render: ( + // A layout block declares `renderFrame` instead of `render`. + render?: ( /** * The custom block to render */ @@ -240,9 +378,21 @@ export type LooseBlockSpec< dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; + renderFrame?: ( + block: any, + editor: BlockNoteEditor, + ) => + | { + dom: HTMLElement; + slot: HTMLElement; + /** Releases resources when the live frame is replaced or destroyed. */ + destroy?: () => void; + update?: (block: any) => boolean | void; + } + | undefined; toExternalHTML?: ( block: any, editor: BlockNoteEditor, @@ -283,10 +433,12 @@ export type BlockSpecs = { config: BlockSpec["config"]; implementation: Omit< BlockSpec["implementation"], - "render" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" | "experimental_keyboard" > & { + experimental_keyboard?: BlockKeyboardOption; // purposefully stub the types for render and toExternalHTML since they reference the block - render: ( + // A layout block declares `renderFrame` instead of `render`. + render?: ( /** * The custom block to render */ @@ -299,9 +451,21 @@ export type BlockSpecs = { dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; - update?: (node: ProsemirrorNode) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; + renderFrame?: ( + block: any, + editor: BlockNoteEditor, + ) => + | { + dom: HTMLElement; + slot: HTMLElement; + /** Releases resources when the live frame is replaced or destroyed. */ + destroy?: () => void; + update?: (block: any) => boolean | void; + } + | undefined; toExternalHTML?: ( block: any, editor: BlockNoteEditor, @@ -566,14 +730,40 @@ export type BlockImplementation< | "table" | "plain", > = { + /** + * @internal Framework adapter (React): draws the block's frame in the + * editor, for the node view of a framed block's `blockContainer` or of a + * layout block. Core uses it instead of the vanilla `renderFrame` when present. + * + * TODO: the content node view has no such hook yet: React builds it inside + * `render` (see the `TODO` there). Add one, so `render` / `renderFrame` + * only draw. + */ + frameView?: (props: NodeViewRendererProps) => FrameView; /** * Metadata */ meta?: BlockConfigMeta; /** - * A function that converts the block into a DOM element + * Experimental: how the keyboard treats the block and its children (Enter, + * Shift-Enter, Backspace, and outdenting): the settings that differ from the + * defaults, or a function of the block that returns them. See + * {@link BlockKeyboard}. This API may change. */ - render: ( + experimental_keyboard?: BlockKeyboardOption< + BlockFromConfig, any, any> + >; + /** + * A function that converts the block into a DOM element. Every block + * declares it, except a layout block (`layoutOnly: true`), which has no content + * to draw and declares only `renderFrame`. + * + * FIXME: optional for every block here, checked at runtime + * (`validateRenderHooks`). The types could require exactly one of `render` + * and `renderFrame`, but core's first `createBlockSpec` overload can't see + * `layoutOnly`. + */ + render?: ( this: | Record | ({ @@ -603,6 +793,7 @@ export type BlockImplementation< dom: HTMLElement | DocumentFragment; contentDOM?: HTMLElement; 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 @@ -613,13 +804,74 @@ export type BlockImplementation< * 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). */ - update?: (node: ProsemirrorNode) => boolean; - destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; + /** + * Draws the chrome *around* a block's content and children: the author's + * markup wraps both, and the `slot` is where BlockNote mounts them. + * + * The slot holds the content first and the children after it. A layout block + * (`layoutOnly: true`) has no content, so it draws only its frame, with its + * child blocks in the slot. The frame's root is then the block's element, + * and a layout block must always draw it. + * + * `render` stays the knob for the block's own content. A block may use + * both: `render` draws the title, `renderFrame` draws the box around title + * and body. Returning `undefined` declines — the block renders plain — so + * a block can decide from its props, content, or children whether it is framed. + * + * Chrome outside the slot is the author's: ProseMirror leaves its events + * alone. An `update` hook receives the current block on updates and patches + * the frame in place. Return `false` to rebuild (or decline) the frame. + * Without an update hook, a change to the block's props rebuilds the frame; + * its content and children are in the slot, so typing doesn't. A frame + * whose chrome shows the block's content or children needs an `update` + * hook. Whether a block declines may depend only on its props: a declined + * frame is asked again when the block's props change. + */ + renderFrame?: ( + this: + | Record + | ({ + propSchema?: TProps; + } & ( + | { + renderType: "nodeView"; + props: NodeViewRendererProps; + } + | { + renderType: "dom"; + props: undefined; + } + )), + block: BlockFromConfig, any, any>, + editor: BlockNoteEditor< + Record>, + I, + S + >, + ) => + | { + dom: HTMLElement; + /** Where BlockNote mounts the block's content and/or children. */ + slot: HTMLElement; + /** Releases resources when the live frame is replaced or destroyed. */ + destroy?: () => void; + update?: ( + block: BlockFromConfig< + BlockConfig, + any, + any + >, + ) => boolean | void; + } + | undefined; + /** * Exports block to external HTML. If not defined, the output will be the same - * as `render(...).dom`. + * as `render(...).dom`, inside the block's frame (`renderFrame`) if it has + * one. When defined, it replaces both. */ toExternalHTML?: ( this: Partial<{ @@ -711,4 +963,4 @@ export type CustomBlockImplementation< T extends string = string, PS extends PropSchema = PropSchema, C extends "inline" | "none" | "plain" = "inline" | "none" | "plain", -> = BlockImplementation; +> = Omit, "frameView">; diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts new file mode 100644 index 0000000000..0157013b56 --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -0,0 +1,156 @@ +import type { BlockConfig, ChildrenAllow } from "./types.js"; + +/** Reject declarations ProseMirror would accept with different semantics. */ +export function validateChildrenConfigs( + blockSpecs: Record< + string, + { + config: Pick< + BlockConfig, + "content" | "placeable" | "layoutOnly" | "children" + >; + } + >, +) { + for (const [type, { config }] of Object.entries(blockSpecs)) { + if (config.layoutOnly === true && config.content !== "none") { + fail( + type, + '`layoutOnly: true` is only for blocks without content (`content: "none"`). A layout block holds only child blocks, no content of its own.', + ); + } + if (config.placeable === "namedOnly" && config.layoutOnly !== true) { + fail( + type, + '`placeable: "namedOnly"` is only for layout blocks (`layoutOnly: true`). Only a layout block can be limited to the blocks that name it in `children.allow`.', + ); + } + if (!config.children) { + continue; + } + + // Typed loosely: a JS caller can put anything here. + const { + allow = "blocks", + min, + exclude, + dissolve, + } = config.children as { + allow?: ChildrenAllow; + min?: number; + exclude?: readonly string[]; + dissolve?: boolean; + }; + + // The child blocks of a block that isn't a layout block share one untyped + // group, which can't enforce types or counts. + if (config.layoutOnly !== true) { + if ( + allow !== "blocks" || + min !== undefined || + exclude !== undefined || + dissolve !== undefined + ) { + fail( + type, + 'restricting child types, a minimum count or `dissolve` requires `layoutOnly: true`. Other blocks can always have any child blocks (`{ allow: "blocks" }`).', + ); + } + continue; + } + + // A layout block that accepts any blocks always holds at least one, and + // dissolves when it loses it. A minimum, and whether the layout block + // dissolves, only mean something for a set of named layout block types (two + // columns, one tab). A key can move the last block out of a layout block + // that accepts any blocks, so one that does not dissolve could never be + // removed with the keyboard. + if (allow === "blocks" && (min !== undefined || dissolve !== undefined)) { + fail( + type, + `\`children.${min !== undefined ? "min" : "dissolve"}\` needs an \`allow\` list. A layout block that accepts any blocks always holds at least one child, and dissolves when its last child is removed.`, + ); + } + + if (min !== undefined && !(Number.isInteger(min) && min >= 0)) { + fail( + type, + `\`children.min\` must be a whole number that is 0 or more, not ${String(min)}.`, + ); + } + + if (exclude !== undefined) { + if (allow !== "blocks") { + fail( + type, + "`children.exclude` is only for a layout block that accepts any blocks. An `allow` list already names exactly what the layout block holds.", + ); + } + for (const excluded of exclude) { + if (!Object.prototype.hasOwnProperty.call(blockSpecs, excluded)) { + fail( + type, + `\`exclude\` contains "${excluded}", which is not a configured block type.`, + ); + } + const { config: excludedConfig } = blockSpecs[excluded]; + if (excludedConfig.layoutOnly !== true) { + fail( + type, + `\`exclude\` contains "${excluded}", which is not a layout block. ` + + "Only layout blocks (`layoutOnly: true`) can be excluded.", + ); + } + if (excludedConfig.placeable === "namedOnly") { + fail( + type, + `\`exclude\` contains "${excluded}", which is \`placeable: "namedOnly"\` and never a child of a layout block that accepts any blocks.`, + ); + } + } + } + + // Every regular block is the same node (`blockContainer`), so naming one + // here compiles to a valid schema that restricts nothing. + if (allow !== "blocks") { + for (const allowed of allow) { + if (!Object.prototype.hasOwnProperty.call(blockSpecs, allowed)) { + fail( + type, + `\`allow\` contains "${allowed}", which is not a configured block type.`, + ); + } + if (blockSpecs[allowed].config.layoutOnly !== true) { + fail( + type, + `\`allow\` contains "${allowed}", which is not a layout block. ` + + "An `allow` list can name only layout blocks (`layoutOnly: true`); naming other blocks is not supported yet. " + + 'Use `allow: "blocks"` to accept all other blocks.', + ); + } + } + } + } + + // A `namedOnly` block can only be a child of a block that names it in + // `children.allow`. Without such a block, the editor cannot insert it. + const named = new Set( + Object.values(blockSpecs).flatMap(({ config }) => + config.children?.allow && config.children.allow !== "blocks" + ? config.children.allow + : [], + ), + ); + for (const [type, { config }] of Object.entries(blockSpecs)) { + if (config.placeable === "namedOnly" && !named.has(type)) { + fail( + type, + '`placeable: "namedOnly"` limits this block to the blocks that name it in `children.allow`, but no block names it. Name it in the `allow` list of a layout block.', + ); + } + } +} + +function fail(type: string, message: string): never { + throw new Error(`Invalid config for block "${type}": ${message}`); +} diff --git a/packages/core/src/schema/dataAttributes.test.ts b/packages/core/src/schema/dataAttributes.test.ts new file mode 100644 index 0000000000..a82a64530f --- /dev/null +++ b/packages/core/src/schema/dataAttributes.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + applyDataAttributes, + propsToDataAttributes, + setDataAttributes, +} from "./dataAttributes.js"; + +// The one rule props and their `data-*` attributes share: a prop becomes a +// kebab-case attribute, except at its default, which has none. + +const propSchema = { + textAlignment: { default: "left", values: ["left", "center"] }, + previewWidth: { default: undefined, type: "number" }, + checked: { default: false }, +} as const; + +describe("propsToDataAttributes", () => { + it("leaves out props at their default or absent, and stringifies the rest", () => { + expect( + propsToDataAttributes( + { textAlignment: "center", checked: true }, + propSchema, + ), + ).toEqual({ "data-text-alignment": "center", "data-checked": "true" }); + expect( + propsToDataAttributes( + { textAlignment: "left", previewWidth: 256, checked: false }, + propSchema, + ), + ).toEqual({ "data-preview-width": "256" }); + expect(propsToDataAttributes({}, propSchema)).toEqual({}); + }); + + it("keeps the props' own order, and has no attribute for an unknown prop", () => { + expect( + Object.keys( + propsToDataAttributes( + { checked: true, unknown: "x", textAlignment: "center" } as any, + propSchema, + ), + ), + ).toEqual(["data-checked", "data-text-alignment"]); + }); +}); + +describe("setDataAttributes", () => { + it("sets the attributes and removes nothing", () => { + const element = document.createElement("div"); + element.setAttribute("data-checked", "author"); + setDataAttributes( + element, + propsToDataAttributes({ textAlignment: "center" }, propSchema), + ); + expect(element.getAttribute("data-text-alignment")).toBe("center"); + expect(element.getAttribute("data-checked")).toBe("author"); + }); +}); + +describe("applyDataAttributes", () => { + it("sets non-default props and removes the attributes of the others", () => { + const element = document.createElement("div"); + element.setAttribute("data-text-alignment", "center"); + element.setAttribute("data-checked", "author"); + element.setAttribute("data-other", "kept"); + applyDataAttributes(element, { previewWidth: 100 }, propSchema); + expect(element.getAttribute("data-preview-width")).toBe("100"); + expect(element.hasAttribute("data-text-alignment")).toBe(false); + expect(element.hasAttribute("data-checked")).toBe(false); + expect(element.getAttribute("data-other")).toBe("kept"); + }); +}); diff --git a/packages/core/src/schema/dataAttributes.ts b/packages/core/src/schema/dataAttributes.ts new file mode 100644 index 0000000000..a0976aec17 --- /dev/null +++ b/packages/core/src/schema/dataAttributes.ts @@ -0,0 +1,69 @@ +import { camelToDataKebab } from "../util/string.js"; +import type { PropSchema, Props } from "./propTypes.js"; + +/** + * The prop names to visit: the props' own order first, so an element's + * attributes keep the order its props were given in, then the schema's for + * the props left out. A prop the schema does not know has no attribute. + */ +function propNames( + props: Partial>, + propSchema: PSchema, +): (keyof PSchema & string)[] { + const names = new Set([...Object.keys(props), ...Object.keys(propSchema)]); + return [...names].filter((name) => name in propSchema); +} + +/** + * The `data-*` attributes `props` serialize to: a `data-kebab-case` attribute + * holding each prop's value as a string, except props at their default (or + * absent), which have none. Parsing (`propsToAttributes`' `parseHTML`) reads + * these names back, so the rule lives here only. The result is what a new + * element gets set, what React spreads, and what tiptap merges into a node's + * HTML attributes. + */ +export function propsToDataAttributes( + props: Partial>, + propSchema: PSchema, +): Record { + const attributes: Record = {}; + for (const name of propNames(props, propSchema)) { + const value = props[name]; + if (value !== undefined && value !== propSchema[name].default) { + attributes[camelToDataKebab(name)] = String(value); + } + } + return attributes; +} + +/** Sets `attributes` on a new element. Nothing is removed. */ +export function setDataAttributes( + element: HTMLElement, + attributes: Record, +) { + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value); + } +} + +/** + * Makes an element's prop attributes follow `props`: sets those of the + * non-default props and removes those of every other prop in the schema, so + * an element that lives across updates (a layout block's root, the table view) + * drops stale attributes, including ones its author set. + */ +export function applyDataAttributes( + element: HTMLElement, + props: Partial>, + propSchema: PSchema, +) { + const attributes = propsToDataAttributes(props, propSchema); + for (const name of propNames(props, propSchema)) { + const attribute = camelToDataKebab(name); + if (attribute in attributes) { + element.setAttribute(attribute, attributes[attribute]); + } else { + element.removeAttribute(attribute); + } + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..3fe2eb15f2 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,6 +1,9 @@ export * from "./blocks/createSpec.js"; +export * from "./blocks/frame.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; +export * from "./dataAttributes.js"; +export type { BlockKeyboard, BlockKeyboardOption } from "./blocks/keyboard.js"; export * from "./inlineContent/createSpec.js"; export * from "./inlineContent/internal.js"; export * from "./inlineContent/types.js"; diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index ab283a674d..cc603b2ee2 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -4,7 +4,7 @@ import { Extension, ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; -import { camelToDataKebab } from "../../util/string.js"; +import { propsToDataAttributes, setDataAttributes } from "../dataAttributes.js"; import { PropSchema, Props } from "../propTypes.js"; import { CustomInlineContentConfig, @@ -34,17 +34,12 @@ export function addInlineContentAttributes< } { // Sets content type attribute element.dom.setAttribute("data-inline-content-type", inlineContentType); - // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips props - // set to their default values. - Object.entries(inlineContentProps) - .filter(([prop, value]) => { - const spec = propSchema[prop]; - return value !== spec?.default; - }) - .map(([prop, value]) => { - return [camelToDataKebab(prop), value]; - }) - .forEach(([prop, value]) => element.dom.setAttribute(prop, value)); + // Adds the props as `data-*` attributes, leaving out those at their default. + // Set only: the element is the author's, and their own attributes stay. + setDataAttributes( + element.dom, + propsToDataAttributes(inlineContentProps, propSchema), + ); if (element.contentDOM) { element.contentDOM.setAttribute("data-editable", ""); diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index 6182103da8..12bceae599 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -16,6 +16,7 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "./index.js"; +import { validateChildrenConfigs } from "./blocks/validateChildren.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -91,6 +92,10 @@ export class CustomBlockNoteSchema< })), ); + // Validation runs before the nodes are built, so the misconfigurations + // ProseMirror cannot report on its own surface as clear errors. + validateChildrenConfigs(this.opts.blockSpecs); + const blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { return [ diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts index 37fb1fd4e9..2b167c5c30 100644 --- a/packages/core/src/yjs/extensions/FixUpSchema.ts +++ b/packages/core/src/yjs/extensions/FixUpSchema.ts @@ -25,6 +25,12 @@ 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())); + // The first fill of the doc's blockGroup is always a `blockContainer`: + // layout block nodes are clamped below its priority + // (`layoutNodePriority`) precisely so auto-fill picks it first. If + // that ever stops holding, throwing here is better than silently + // leaving the id unset, which would let every peer generate its own + // initial block id. jsonNode.content[0].content[0].attrs.id = "initialBlockId"; cache = Node.fromJSON(schema, jsonNode); diff --git a/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx b/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx index 7b6f0af639..c6aefb8117 100644 --- a/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx +++ b/packages/dev-scripts/examples/template-react/vite.config.ts.template.tsx @@ -1,6 +1,5 @@ import type { Project } from "../util"; -// TODO: the ../../ paths are broken const template = ( project: Project, ) => `// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY @@ -23,7 +22,7 @@ export default defineConfig(((conf: { command: string }) => ({ resolve: { alias: conf.command === "build" || - !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) ? {} : ({ // The repo-wide alias for the shared test-utils directory (private, @@ -34,11 +33,11 @@ export default defineConfig(((conf: { command: string }) => ({ // or, keep as is to load live from sources with live reload working "@blocknote/core": path.resolve( __dirname, - "../../packages/core/src/" + "../../../packages/core/src/" ), "@blocknote/react": path.resolve( __dirname, - "../../packages/react/src/" + "../../../packages/react/src/" ), } as any), }, diff --git a/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx index a7fd54101e..6a85ec9ebf 100644 --- a/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx +++ b/packages/diagram-block/src/block/createReactDiagramBlockSpec.tsx @@ -39,7 +39,10 @@ export const createReactDiagramBlockSpec = createReactBlockSpec( // upstream, highlighting should start working with no change here. highlight: () => "mermaid", hasPreview: true, - hardBreakShortcut: "enter", + }, + // Multi-line source: Enter inserts a line break. + experimental_keyboard: { + enter: "line-break", }, parse: parseDiagramCodeElement, parseContent: parseDiagramCodeContent, diff --git a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx index 8e3e399adc..5f4df9716d 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.test.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.test.tsx @@ -134,7 +134,7 @@ describe("Math block source popup keyboard handling", () => { await flush(); expect(isPopupOpen("math")).toBe(true); - // Math uses `hardBreakShortcut: "shift+enter"`, so unlike the diagram + // Math keeps the default Enter (no `keyboard.enter: "line-break"`), so unlike the diagram // block, a plain Enter closes the popup rather than extending the source // with a newline (that needs Shift+Enter - see the next test). pressKey("Enter"); diff --git a/packages/math-block/src/block/createReactMathBlockSpec.tsx b/packages/math-block/src/block/createReactMathBlockSpec.tsx index c8cb18c33d..cbd2b77382 100644 --- a/packages/math-block/src/block/createReactMathBlockSpec.tsx +++ b/packages/math-block/src/block/createReactMathBlockSpec.tsx @@ -29,7 +29,6 @@ export const createReactMathBlockSpec = createReactBlockSpec( isolating: false, highlight: () => "latex", hasPreview: true, - hardBreakShortcut: "shift+enter", }, parse: parseBlockMathMLElement, parseContent: parseBlockMathMLContent, diff --git a/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx index 1384b13cda..f42438991e 100644 --- a/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx +++ b/packages/react/src/blocks/SourceWithPreview/block/SourceBlockWithPreview.tsx @@ -31,8 +31,8 @@ export const SourceBlockWithPreview = (props: SourceBlockWithPreviewProps) => { // block uses Enter for hard breaks (multi-line source, e.g. diagrams), // Enter inserts a newline instead of closing the popup. const enterSubmits = - editor.schema.blockSpecs[block.type]?.implementation?.meta - ?.hardBreakShortcut !== "enter"; + editor.schema.blockSpecs[block.type].implementation.keyboard(block) + .enter !== "line-break"; return ( { - if (action.type === "toggled") { - return !showChildren; - } - - if (action.type === "childAdded") { - return true; - } - - if (action.type === "lastChildRemoved") { - return false; - } - - throw new UnreachableCaseError(action); -}; - -export const ToggleWrapper = ( - props: Omit< - ReactCustomBlockRenderProps>, - "contentRef" - > & { - children: ReactNode; - toggledState?: { - set: (block: Block, isToggled: boolean) => void; - get: (block: Block) => boolean; - }; - }, -) => { - const { block, editor, children, toggledState } = props; - - const [showChildren, dispatch] = useReducer( - showChildrenReducer, - (toggledState || defaultToggledState).get(block), - ); - - const handleToggle = (block: Block) => { - const currentBlock = editor.getBlock(block); - if (!currentBlock) { - return; - } - (toggledState || defaultToggledState).set(currentBlock, !showChildren); - dispatch({ - type: "toggled", - }); - }; - - const handleChildAdded = (block: Block) => { - (toggledState || defaultToggledState).set(block, true); - dispatch({ - type: "childAdded", - }); - }; - - const handleLastChildRemoved = (block: Block) => { - (toggledState || defaultToggledState).set(block, false); - dispatch({ - type: "lastChildRemoved", - }); - }; - - const childCount = useEditorState({ - editor, - selector: ({ editor }) => { - if ( - !blockHasType(block, editor, block.type, { isToggleable: "boolean" }) && - !block.props.isToggleable - ) { - return 0; - } - - const newBlock = editor.getBlock(block); - if (!newBlock) { - return 0; - } - const newChildCount = newBlock.children.length || 0; - - if (newChildCount > childCount) { - // If a child block is added while children are hidden, show children. - if (!showChildren) { - handleChildAdded(newBlock); - } - } else if (newChildCount === 0 && newChildCount < childCount) { - // If the last child block is removed while children are shown, hide - // children. - if (showChildren) { - handleLastChildRemoved(newBlock); - } - } - - return newChildCount; - }, - }); - - if ("isToggleable" in block.props && !block.props.isToggleable) { - return children; - } - - return ( -
-
- - {children} -
- {editor.isEditable && showChildren && childCount === 0 && ( - - )} -
- ); -}; diff --git a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx index 41e726ca72..a3b0be2138 100644 --- a/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx +++ b/packages/react/src/components/FilePanel/DefaultTabs/UploadTab.tsx @@ -84,7 +84,7 @@ export const UploadTab = < } const spec = editor.schema.blockSpecs[block.type]; - const accept = spec.implementation.meta?.fileBlockAccept?.length + const accept = spec.implementation.meta.fileBlockAccept?.length ? spec.implementation.meta.fileBlockAccept.join(",") : "*/*"; diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts new file mode 100644 index 0000000000..f7b2071eae --- /dev/null +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { + type BlockNoteEditor, + BlockNoteSchema, + createHeadingBlockSpec, + defaultBlockSpecs, +} from "@blocknote/core"; +import { en } from "@blocknote/core/locales"; +import { describe, expect, it } from "vite-plus/test"; + +import { blockTypeSelectItems } from "./BlockTypeSelect.js"; + +function schemaWith(heading = createHeadingBlockSpec()) { + return BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, heading }, + }); +} + +describe("blockTypeSelectItems", () => { + it("lists the default items in the same order as before", () => { + // Only the schema and the dictionary are read. + const editor = { + schema: schemaWith(), + dictionary: en, + } as unknown as BlockNoteEditor; + expect(blockTypeSelectItems(editor).map((item) => item.name)).toEqual([ + "Paragraph", + "Heading 1", + "Heading 2", + "Heading 3", + "Heading 4", + "Heading 5", + "Heading 6", + "Toggle Heading 1", + "Toggle Heading 2", + "Toggle Heading 3", + "Quote", + "Toggle List", + "Bullet List", + "Numbered List", + "Check List", + ]); + }); +}); diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index ba66847b02..0faa196988 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -1,7 +1,9 @@ import { + BlockNoteEditor, BlockSchema, - Dictionary, + DefaultBlockTypeItem, editorHasBlockWithType, + getDefaultBlockTypeItems, InlineContentSchema, StyleSchema, } from "@blocknote/core"; @@ -38,94 +40,37 @@ export type BlockTypeSelectItem = { icon: IconType; }; +const icons: Record = { + paragraph: RiText, + heading: RiH1, + heading_2: RiH2, + heading_3: RiH3, + heading_4: RiH4, + heading_5: RiH5, + heading_6: RiH6, + toggle_heading: RiH1, + toggle_heading_2: RiH2, + toggle_heading_3: RiH3, + quote: RiQuoteText, + toggle_list: RiPlayList2Fill, + bullet_list: RiListUnordered, + numbered_list: RiListOrdered, + check_list: RiListCheck3, +}; + +/** + * The default block type select items: the block types of the editor's schema + * that the default menus offer (see `getDefaultBlockTypeItems`). + */ export const blockTypeSelectItems = ( - dict: Dictionary, -): BlockTypeSelectItem[] => [ - { - name: dict.slash_menu.paragraph.title, - type: "paragraph", - icon: RiText, - }, - { - name: dict.slash_menu.heading.title, - type: "heading", - props: { level: 1, isToggleable: false }, - icon: RiH1, - }, - { - name: dict.slash_menu.heading_2.title, - type: "heading", - props: { level: 2, isToggleable: false }, - icon: RiH2, - }, - { - name: dict.slash_menu.heading_3.title, - type: "heading", - props: { level: 3, isToggleable: false }, - icon: RiH3, - }, - { - name: dict.slash_menu.heading_4.title, - type: "heading", - props: { level: 4, isToggleable: false }, - icon: RiH4, - }, - { - name: dict.slash_menu.heading_5.title, - type: "heading", - props: { level: 5, isToggleable: false }, - icon: RiH5, - }, - { - name: dict.slash_menu.heading_6.title, - type: "heading", - props: { level: 6, isToggleable: false }, - icon: RiH6, - }, - { - name: dict.slash_menu.toggle_heading.title, - type: "heading", - props: { level: 1, isToggleable: true }, - icon: RiH1, - }, - { - name: dict.slash_menu.toggle_heading_2.title, - type: "heading", - props: { level: 2, isToggleable: true }, - icon: RiH2, - }, - { - name: dict.slash_menu.toggle_heading_3.title, - type: "heading", - props: { level: 3, isToggleable: true }, - icon: RiH3, - }, - { - name: dict.slash_menu.quote.title, - type: "quote", - icon: RiQuoteText, - }, - { - name: dict.slash_menu.toggle_list.title, - type: "toggleListItem", - icon: RiPlayList2Fill, - }, - { - name: dict.slash_menu.bullet_list.title, - type: "bulletListItem", - icon: RiListUnordered, - }, - { - name: dict.slash_menu.numbered_list.title, - type: "numberedListItem", - icon: RiListOrdered, - }, - { - name: dict.slash_menu.check_list.title, - type: "checkListItem", - icon: RiListCheck3, - }, -]; + editor: BlockNoteEditor, +): BlockTypeSelectItem[] => + getDefaultBlockTypeItems(editor).map(({ key, type, props }) => ({ + name: editor.dictionary.slash_menu[key].title, + type, + props, + icon: icons[key], + })); export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { const Components = useComponentsContext()!; @@ -149,7 +94,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { // the schema. const filteredItems = useMemo( () => - (props.items || blockTypeSelectItems(editor.dictionary)).filter((item) => + (props.items || blockTypeSelectItems(editor)).filter((item) => editorHasBlockWithType( editor, item.type, diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 7bca85a434..c30625c9c9 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 { getBlockElementForNode, getNodeById } from "@blocknote/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -28,16 +28,12 @@ export const BlockPopover = ( return undefined; } - const { node } = editor.prosemirrorView.domAtPos( - nodePosInfo.posBeforeNode + 1, + const element = getBlockElementForNode( + editor.prosemirrorView, + nodePosInfo.posBeforeNode, + blockId, ); - if (!(node instanceof Element)) { - return undefined; - } - - return { - element: node, - }; + return element ? { element } : undefined; }), [editor, blockId], ); diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index f72b760521..d03b6d60ce 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -111,6 +111,12 @@ width: 100%; } +/* A frame's root is the block's element, so the wrapper React needs around it + must not be a box of its own. */ +.bn-react-node-view-renderer.bn-frame-node-view { + display: contents; +} + /* Indent line styling */ .bn-block-group .bn-block:not(:has(.bn-toggle-wrapper)) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9b7bb9db9c..e6d1c0ed26 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -24,7 +24,6 @@ export * from "./blocks/SourceWithPreview/block/useSourceBlockPreviewPopup.js"; export * from "./blocks/SourceWithPreview/inlineContent/SourceInlineContentWithPreview.js"; export * from "./blocks/SourceWithPreview/inlineContent/useSourceInlineContentPreviewPopup.js"; export * from "./blocks/Video/block.js"; -export * from "./blocks/ToggleWrapper/ToggleWrapper.js"; export * from "./components/FormattingToolbar/DefaultButtons/AddCommentButton.js"; export * from "./components/FormattingToolbar/DefaultButtons/AddTiptapCommentButton.js"; diff --git a/packages/react/src/schema/@util/ReactRenderUtil.ts b/packages/react/src/schema/@util/ReactRenderUtil.ts index dac1a68a86..5ecc4ca6f3 100644 --- a/packages/react/src/schema/@util/ReactRenderUtil.ts +++ b/packages/react/src/schema/@util/ReactRenderUtil.ts @@ -36,9 +36,9 @@ export function renderToDOMSpec( } if (!div.childElementCount) { - // TODO - // eslint-disable-next-line no-console - console.warn("ReactInlineContentSpec: renderHTML() failed"); + // A conditional frame may render null. Dispose its effects even when + // there is no DOM to clone, just as on the non-empty path below. + root?.unmount(); return { dom: document.createElement("span"), }; diff --git a/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx new file mode 100644 index 0000000000..2e9cafb1dc --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx @@ -0,0 +1,180 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + createBlockSpec, +} from "@blocknote/core"; +import { userEvent } from "vite-plus/test/browser"; +import { useState } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +function createFrameSchema(content: "inline" | "plain") { + return BlockNoteSchema.create().extend({ + blockSpecs: { + vanilla: createBlockSpec( + { type: "vanilla", propSchema: {}, content }, + { + render() { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }, + renderFrame(block) { + const dom = document.createElement("section"); + dom.className = "vanilla-frame"; + dom.dataset.title = JSON.stringify(block.content); + const slot = document.createElement("div"); + dom.append(slot); + // The chrome shows the title, so it refreshes it on updates. + return { + dom, + slot, + update(updated) { + dom.dataset.title = JSON.stringify(updated.content); + return true; + }, + }; + }, + }, + )(), + framed: createReactBlockSpec( + { + type: "framed", + propSchema: { framed: { default: true } }, + content, + }, + { + render: (props) => ( +
+ ), + renderFrame: function Frame(props) { + const [clicks, setClicks] = useState(0); + if (!props.block.props.framed) { + return null; + } + return ( +
+ +
+
+ ); + }, + }, + )(), + }, + }); +} + +it.each(["inline", "plain"] as const)( + "keeps native %s editing and selection working across frame changes", + async (content) => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const editor = BlockNoteEditor.create({ + schema: createFrameSchema(content), + trailingBlock: false, + initialContent: [ + { + id: "frame", + type: "framed", + content: "Title", + children: [{ id: "body", type: "paragraph", content: "Body" }], + }, + ], + }); + try { + flushSync(() => root.render()); + await vi.waitFor(() => + expect(host.querySelector(".frame-body")?.textContent).toBe( + "TitleBody", + ), + ); + const child = host.querySelector('[data-id="body"]'); + + editor.focus(); + editor.setTextCursorPosition("frame", "end"); + await userEvent.keyboard("!"); + await vi.waitFor(() => + expect(host.querySelector(".frame-title")?.textContent).toBe("Title!"), + ); + await userEvent.click(host.querySelector("button")!); + await vi.waitFor(() => + expect(host.querySelector("button")?.textContent).toBe("1"), + ); + + editor.focus(); + editor.setTextCursorPosition("body", "end"); + editor.updateBlock("frame", { props: { framed: false } }); + await vi.waitFor(() => expect(host.querySelector(".frame")).toBeNull()); + await userEvent.keyboard("?"); + await vi.waitFor(() => expect(child?.textContent).toBe("Body?")); + + editor.updateBlock("frame", { props: { framed: true } }); + await vi.waitFor(() => + expect(host.querySelector(".frame-body")?.textContent).toBe( + "Title!Body?", + ), + ); + await userEvent.keyboard("!"); + await vi.waitFor(() => expect(child?.textContent).toBe("Body?!")); + expect(host.querySelector('[data-id="body"]')).toBe(child); + expect(editor.getTextCursorPosition().block.id).toBe("body"); + } finally { + root.unmount(); + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); + +it.each(["inline", "plain"] as const)( + "keeps native %s typing and selection while vanilla frames refresh", + async (content) => { + const host = document.createElement("div"); + document.body.append(host); + const editor = BlockNoteEditor.create({ + schema: createFrameSchema(content), + trailingBlock: false, + initialContent: [ + { + id: "frame", + type: "vanilla", + content: "Title", + children: [{ id: "body", type: "paragraph", content: "Body" }], + }, + ], + }); + try { + editor.mount(host); + editor.focus(); + editor.setTextCursorPosition("frame", "end"); + await userEvent.keyboard("abc"); + await vi.waitFor(() => + expect( + host.querySelector(".vanilla-frame")?.getAttribute("data-title"), + ).toContain("Titleabc"), + ); + expect(editor.getTextCursorPosition().block.id).toBe("frame"); + editor.setTextCursorPosition("body", "end"); + await userEvent.keyboard("xyz"); + await vi.waitFor(() => + expect(host.querySelector('[data-id="body"]')?.textContent).toBe( + "Bodyxyz", + ), + ); + expect(editor.getTextCursorPosition().block.id).toBe("body"); + } finally { + editor._tiptapEditor.destroy(); + host.remove(); + } + }, +); diff --git a/packages/react/src/schema/ReactBlockSpec.layout.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.layout.browser.test.tsx new file mode 100644 index 0000000000..bb5b803742 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.layout.browser.test.tsx @@ -0,0 +1,279 @@ +import { BlockNoteEditor, BlockNoteSchema, getNodeById } from "@blocknote/core"; +import { posToDOMRect } from "@tiptap/core"; +import { NodeSelection } from "@tiptap/pm/state"; +import { useState } from "react"; +import { userEvent } from "vite-plus/test/browser"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockPopover } from "../components/Popovers/BlockPopover.js"; +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +/** + * Tests for React layout 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 + * layout blocks in general is covered by the core suites in + * `api/blockManipulation/layoutBlocks/`. + */ + +// A layout block: its `slotRef` element holds its child blocks. +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { flavor: { default: "tip" } }, + content: "none", + layoutOnly: true, + }, + { + renderFrame: function Callout(props) { + const [alternate, setAlternate] = useState(false); + const Tag = alternate ? "section" : "div"; + return ( + + +
+ + ); + }, + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + callout: createCallout(), + }, +}); + +describe("React layout 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); + + // Layout 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 the frame returned, with no React wrapper in + // between, so `.callout[data-*]` CSS matches it here exactly as in the + // live editor. Like a regular block, it carries no BlockNote markers. + expect(html).not.toContain('data-content-type="callout"'); + expect(html).not.toContain("data-node-view-wrapper"); + expect(html).toContain('class="callout"'); + expect(html).not.toContain("data-node-type"); + 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 layout block node view", () => { + it("anchors a layout block popover to the author's box", async () => { + const { editor, div } = await mountEditor([ + { + id: "outer", + type: "callout", + children: [{ id: "inner", type: "callout" }], + }, + ]); + const box = div.querySelector(".callout")!; + let anchor: Element | undefined; + flushSync(() => { + root!.render( + + {}; + }, + }} + > + Layout block menu + + , + ); + }); + await expect.poll(() => anchor).toBe(box); + expect(box.getBoundingClientRect().height).toBeGreaterThan(0); + }); + + it("measures a layout block by its own box, not React's wrapper", async () => { + // ProseMirror and BlockNote measure the `dom` of a node view: + // `coordsAtPos`, `posToDOMRect` (toolbars), `nodeDOM` (drop cursor), the + // selection box and scrolling. The wrapper of React has no box + // (`display: contents`). See `measureFrameRoot`, a workaround. + const { editor, div } = await mountEditor([ + { id: "before", type: "paragraph", content: "Before" }, + { + id: "c-0", + type: "callout", + children: [{ id: "child", type: "paragraph", content: "Body" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + const view = editor.prosemirrorView; + const { posBeforeNode: from, node } = getNodeById("c-0", view.state.doc)!; + const to = from + node.nodeSize; + const box = div.querySelector(".callout")!.getBoundingClientRect(); + expect(box.height).toBeGreaterThan(0); + function expectBox(rect: { + left: number; + top: number; + right: number; + bottom: number; + }) { + expect(rect.left).toBeCloseTo(box.left); + expect(rect.top).toBeCloseTo(box.top); + expect(rect.right).toBeCloseTo(box.right); + expect(rect.bottom).toBeCloseTo(box.bottom); + } + expectBox((view.nodeDOM(from) as HTMLElement).getBoundingClientRect()); + expectBox(posToDOMRect(view, from, to)); + expect(view.coordsAtPos(from, 1).top).toBeCloseTo(box.top); + expect(view.coordsAtPos(to, -1).bottom).toBeCloseTo(box.bottom); + view.dispatch( + view.state.tr.setSelection(NodeSelection.create(view.state.doc, from)), + ); + expectBox(editor.getSelectionBoundingBox()!); + + // Also after the author's state replaces the root. + await userEvent.click(div.querySelector(".callout button")!); + const swapped = div + .querySelector("section.callout")! + .getBoundingClientRect(); + expect( + (view.nodeDOM(from) as HTMLElement).getBoundingClientRect().top, + ).toBeCloseTo(swapped.top); + }); + + it("keeps attributes and native editing after a local state root swap", async () => { + const { editor, div } = await mountEditor([ + { + id: "c-0", + type: "callout", + props: { flavor: "warning" }, + children: [{ id: "child", type: "paragraph", content: "Body" }], + }, + ]); + const child = div.querySelector('[data-id="child"]'); + await userEvent.click(div.querySelector(".callout button")!); + const box = div.querySelector("section.callout")!; + expect(box.getAttribute("data-id")).toBe("c-0"); + expect(box.getAttribute("data-node-type")).toBe("callout"); + expect(box.getAttribute("data-flavor")).toBe("warning"); + expect(box.querySelector('[data-id="child"]')).toBe(child); + editor.focus(); + editor.setTextCursorPosition("child", "end"); + await userEvent.keyboard("!"); + expect(child?.textContent).toBe("Body!"); + expect(editor.getTextCursorPosition().block.id).toBe("child"); + }); + + 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 layout block's children inside its `slotRef` 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 layout block with no content of its own puts its children where the + // author placed `slotRef`, 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..fd64a6428f 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -1,31 +1,102 @@ import { + applyLayoutBlockAttributes, + Block, BlockConfig, + BlockFromConfig, BlockConfigOrCreator, BlockImplementation, BlockNoDefaults, BlockNoteEditor, BlockSpec, - camelToDataKebab, CustomBlockImplementation, Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, + markFrameSlot, mergeCSSClasses, + nodeToBlock, Props, PropSchema, + propsToDataAttributes, + validateRenderHooks, } from "@blocknote/core"; import { NodeViewProps, NodeViewWrapper, + ReactNodeView, ReactNodeViewRenderer, useReactNodeView, } from "@tiptap/react"; -import { FC, ReactNode } from "react"; +import { + CSSProperties, + FC, + ReactNode, + useCallback, + useLayoutEffect, + useRef, +} 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 layout 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" }; + +// Marks where a React `toExternalHTML` puts the block's children +// (`childrenRef`), until the export is rendered. +const CHILDREN_MARK = "data-bn-children-ref"; + +/** The element marked with `CHILDREN_MARK` in `dom`, with the mark removed. */ +function takeMarked(dom: HTMLElement): HTMLElement | undefined { + let marked = dom.querySelector(`[${CHILDREN_MARK}]`); + if (dom.hasAttribute(CHILDREN_MARK)) { + marked = dom; + } + marked?.removeAttribute(CHILDREN_MARK); + return marked ?? undefined; +} + +/** + * FIXME: This function is a workaround, not a correct solution. + * + * Problem: ProseMirror and BlockNote measure the `dom` of a node view. For + * example, `coordsAtPos`, `posToDOMRect`, the drop cursor, the selection box + * and scrolling use it. For a React layout block, the `dom` is the wrapper of + * TipTap. The wrapper has `display: contents`, so it has no box. Without this + * function, all these measurements give `0,0,0,0`. + * + * Workaround: this function replaces two methods of the wrapper, + * `getBoundingClientRect` and `getClientRects`. The replacements measure the + * root of the frame. The root is the element of the block in the wrapper. + * + * Why it is a workaround: + * - It changes the behavior of DOM methods on one element. A developer who + * does not know this can find the behavior difficult to understand. + * - Other measurements of the wrapper stay incorrect: `offsetTop`, + * `offsetHeight`, `IntersectionObserver`, `ResizeObserver`, and + * `scrollIntoView` on the wrapper. + * - Before React draws the root, the box of the wrapper is still empty. + * + * Correct solution: make the root of the frame the `dom` of the node view. + * React draws the root after ProseMirror makes the node view, so this needs + * more work. See the review of #3059 (thread r4084731902). + */ +function measureFrameRoot(wrapper: HTMLElement, blockId: () => string) { + function measured(): Element { + // The root is before the child blocks, so it is the first element with + // an ID. React puts the ID of the block on the root. + const root = wrapper.querySelector("[data-id]"); + return root?.getAttribute("data-id") === blockId() ? root : wrapper; + } + wrapper.getBoundingClientRect = () => + Element.prototype.getBoundingClientRect.call(measured()); + wrapper.getClientRects = () => + Element.prototype.getClientRects.call(measured()); +} + export type ReactCustomBlockRenderProps< B extends BlockConfigOrCreator, Config extends ExtractBlockConfigFromConfigOrCreator = @@ -33,12 +104,27 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; + // A block with content gets a `contentRef` to mount its editable region. + // A block without content (and the table block, whose content is managed + // separately) has nothing to place. } & (Config["content"] extends "inline" | "plain" - ? { - contentRef: (node: HTMLElement | null) => void; - } + ? { contentRef: (node: HTMLElement | null) => void } : object); +// The props of a React `renderFrame` component. +export type ReactCustomBlockFrameProps< + B extends BlockConfigOrCreator, + Config extends ExtractBlockConfigFromConfigOrCreator = + ExtractBlockConfigFromConfigOrCreator, +> = { + block: BlockFromConfig; + editor: BlockNoteEditor, any, any>; + // A frame gets a `slotRef` for its slot: the mount for a layout block's + // children, or for a block's content and children together. Attach it + // with `ref={slotRef}` on the slot element. + slotRef: (node: HTMLElement | null) => void; +}; + // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< B extends BlockConfigOrCreator = BlockConfigOrCreator, @@ -50,11 +136,23 @@ export type ReactCustomBlockImplementation< Config["propSchema"], Config["content"] >, - "render" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" > & { - render: FC>; + // Every block declares `render`, except a layout block, which declares only + // `renderFrame`. FIXME: checked at runtime (`validateRenderHooks`); the + // types could require exactly one, as `Config` shows `layoutOnly` here. + render?: FC>; + // The frame around a block's content and its children, or a layout block's + // only element. Its slot holds the content followed by the children (a + // layout block: only the children). + renderFrame?: FC>; + // Like core's `toExternalHTML`, which returns `contentDOM` and + // `childrenDOM`: `contentRef` marks where the block's content goes, and + // `childrenRef` where its children go. Without `childrenRef`, the children + // follow the block. toExternalHTML?: FC< ReactCustomBlockRenderProps & { + childrenRef: (node: HTMLElement | null) => void; context: { nestingLevel: number; }; @@ -102,20 +200,8 @@ export function BlockContentWrapper< )} // Sets content type attribute data-content-type={props.blockType} - // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips - // props which are already added as HTML attributes to the parent - // `blockContent` element (inheritedProps) and props set to their default - // values - {...Object.fromEntries( - Object.entries(props.blockProps) - .filter(([prop, value]) => { - const spec = props.propSchema[prop]; - return value !== spec.default; - }) - .map(([prop, value]) => { - return [camelToDataKebab(prop), value]; - }), - )} + // Adds the props as `data-*` attributes, leaving out those at their default. + {...propsToDataAttributes(props.blockProps, props.propSchema)} data-file-block={props.isFileBlock === true || undefined} > {props.children} @@ -126,25 +212,29 @@ export function BlockContentWrapper< /** * Helper function to create a React block definition. * Can accept either functions that return the required objects, or the objects directly. + * + * FIXME: hard to read (review thread on `createReactBlockSpec`). Split into + * named top-level functions: static content, content node view, static frame, + * frame node view (incl. a layout block's). */ 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 +242,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, @@ -219,131 +315,369 @@ export function createReactBlockSpec< ? blockImplementationOrCreator(options as any) : blockImplementationOrCreator; + validateRenderHooks(blockConfig, blockImplementation); + + const { + render: ReactRender, + renderFrame: reactRenderFrame, + toExternalHTML: ReactToExternalHTML, + ...coreImplementation + } = blockImplementation; + const extensions = extensionsOrCreator ? typeof extensionsOrCreator === "function" ? extensionsOrCreator(options as any) : extensionsOrCreator : undefined; + // Being a layout block is fixed per spec, so every render path can decide once. + // A titled block (content of its own plus `children`) keeps its ordinary + // shape: only a contentless block builds a layout block node, so only one + // takes the layout block node view. The titled block's content node renders + // through the regular node view; core installs its frame at the + // `blockContainer` level (see the `renderFrame` adapter below). + const isLayoutBlock = blockConfig.layoutOnly === true; + + // Shared by the two paths that render to plain DOM: `toExternalHTML`, and + // `render` outside a node view (a layout block has no `render`). A layout block + // block's export is the block's root element, with no wrapper: the props + // core adds afterwards land on the author's own element. + function renderStatic(args: { + BlockContent: FC; + block: any; + editor: any; + domAttributes?: Record; + isFileBlock?: boolean; + context?: any; + childrenRef?: (element: HTMLElement | null) => void; + }) { + const { BlockContent, block, editor } = args; + + return renderToDOMSpec((refCB) => { + const content = ( + { + refCB(element); + if (element && !isLayoutBlock) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + context={args.context} + /> + ); + + return isLayoutBlock ? ( + content + ) : ( + + {content} + + ); + }, editor); + } + + const Frame = reactRenderFrame; + + // Marks a layout block's slot for the parser and puts the block's attributes + // on its frame's root, the block's element. + function stampLayoutFrame( + slot: HTMLElement, + block: Block, + selected: boolean, + ) { + markFrameSlot(slot); + const root = slot.closest("[data-node-view-wrapper]")?.firstElementChild; + if (!(root instanceof HTMLElement)) { + throw new Error( + "Layout block content must be inside its node view wrapper.", + ); + } + applyLayoutBlockAttributes( + root, + blockConfig.type, + block.props, + blockConfig.propSchema, + block.id, + ); + root.toggleAttribute("data-selected", selected); + } + + function FrameNodeView(props: NodeViewProps) { + // This view belongs to the block's own node (the blockContainer, or the + // layout block node), so its node is the block. + const block = nodeToBlock(props.node, props.view.state.doc); + if (block.type !== blockConfig.type) { + throw new Error( + `Frame for "${blockConfig.type}" received block "${block.type}".`, + ); + } + const mountContent = useReactNodeView().nodeViewContentRef; + const wrapper = useRef(null); + const slot = useRef(null); + if (!mountContent || !Frame) { + throw new Error("Frame node view requires a frame and content mount."); + } + // A layout block's frame root is its block's element, so it carries the + // block's attributes, kept current after every render. + const latest = useRef({ block, selected: props.selected }); + useLayoutEffect(() => { + latest.current = { block, selected: props.selected }; + if (isLayoutBlock && slot.current) { + stampLayoutFrame(slot.current, block, props.selected); + } + }); + const contentRef = useCallback( + (element: HTMLElement | null) => { + slot.current = element; + if (element) { + element.dataset.nodeViewContent = ""; + } + // TipTap owns contentDOM and preserves it as a conditional frame + // switches between author markup and the default wrapper. A + // layout block's frame never declines, so it has no default wrapper. + mountContent(element ?? (isLayoutBlock ? null : wrapper.current)); + // Also when author state replaces the frame's root or slot. + if (isLayoutBlock && element) { + stampLayoutFrame( + element, + latest.current.block, + latest.current.selected, + ); + } + }, + [mountContent], + ); + const wrapperRef = useCallback( + (element: HTMLDivElement | null) => { + wrapper.current = element; + // TODO: a declined React frame falls back to this wrapper inside the + // node view, so core can't tell it is unframed. A declined vanilla + // frame gives no frame (`vanillaFrameView`), and the block keeps + // `.bn-block`. Lift declining out of the component. + if (!slot.current && !isLayoutBlock) { + mountContent(element); + } + }, + [mountContent], + ); + + return ( + + ["block"] + } + editor={props.extension.options.editor} + slotRef={contentRef} + /> + + ); + } + return { config: blockConfig, implementation: { - ...blockImplementation, - toExternalHTML(block, editor, context) { - const BlockContent = - blockImplementation.toExternalHTML || blockImplementation.render; - const output = renderToDOMSpec((refCB) => { - return ( - - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - context={context} - /> - - ); - }, editor); - return output; - }, - render(block, editor) { - if (this.renderType === "nodeView") { - // The block core's `addNodeView` resolved when this node view was - // constructed (itself guarded, via `getBlockFromNodeView`). Seeds - // the fallback below so there is always something to render. - const initialBlock = block; + ...coreImplementation, + // Only a block with its own export has `toExternalHTML`. Without + // one, the external HTML serializer makes the default export. + ...(ReactToExternalHTML && { + toExternalHTML( + this: ThisParameterType< + NonNullable + >, + block: any, + editor: any, + context: { nestingLevel: number }, + ) { + const output = renderStatic({ + BlockContent: ReactToExternalHTML, + block, + editor, + domAttributes: this.blockContentDOMAttributes, + isFileBlock: + blockImplementation.meta?.fileBlockAccept !== undefined, + context, + // Marks the element the export puts the children in. The + // rendered DOM is cloned, so the clone is found by the mark. + childrenRef: (element) => + element?.setAttribute(CHILDREN_MARK, ""), + }); + return { ...output, childrenDOM: takeMarked(output.dom) }; + }, + }), + // A layout block has no `render`: it draws only its frame, and core + // builds its node view around its `frameView`. + ...(ReactRender && + ({ + render(block, editor) { + // TODO: `render` does two jobs here, picked by `renderType`: the + // live node view (built from `this.props`) and the static render. + // Frames keep them apart: `renderFrame` only draws, and React + // supplies the frame's view through the internal `frameView` hook. + // Do the same for content (an internal node view hook that core's + // `blockNodeView` uses when present), so `render` is static only + // and nothing in BlockNote needs `renderType` / `this.props`. + if (this.renderType === "nodeView") { + // The block core's `addNodeView` resolved when this node view was + // constructed (itself guarded, via `getBlockFromNodeView`). Seeds + // the fallback below so there is always something to render. + const initialBlock = block; + const BlockContent = ReactRender; + const blockContentDOMAttributes = + this.blockContentDOMAttributes; - 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); + function BlockNodeView(props: NodeViewProps) { + const block = useNodeViewBlock(props, initialBlock); + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } - const ref = useReactNodeView().nodeViewContentRef; + const mountContent = ref; + function contentRef(element: HTMLElement | null) { + mountContent(element); + if (!element) { + return; + } + element.dataset.nodeViewContent = ""; + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } - if (!ref) { - throw new Error("nodeViewContentRef is not set"); + return ( + + + + ); } - const BlockContent = blockImplementation.render; - return ( - - { - ref(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - element.dataset.nodeViewContent = ""; - } - }} - /> - + return ReactNodeViewRenderer(BlockNodeView, { + className: "bn-react-node-view-renderer", + })(this.props!) as ReturnType< + NonNullable + >; + } else { + return renderStatic({ + BlockContent: ReactRender, + block, + editor, + domAttributes: this.blockContentDOMAttributes, + }); + } + }, + } satisfies Pick< + BlockImplementation, + "render" + >)), + ...(Frame + ? ({ + // Serialization uses the same component through the existing + // static renderer. Live rendering uses the outer node view below. + renderFrame(block, editor) { + const { dom, contentDOM } = renderToDOMSpec( + (slotRef) => ( + + ), + editor, ); + return contentDOM ? { dom, slot: contentDOM } : undefined; }, - { - className: "bn-react-node-view-renderer", + // The frame in the editor: TipTap renders React only through a + // node view, so this draws one and hands core its frame parts. + // Core owns the block's node view. + frameView: (props) => { + const nodeView = ReactNodeViewRenderer(FrameNodeView, { + // Separate from the regular class, because it removes the + // box that one relies on (see `Block.css`). + className: "bn-react-node-view-renderer bn-frame-node-view", + })(props); + // TipTap returns no node view when the editor has no React + // content component (a headless editor), which never draws + // node views. + if (!(nodeView instanceof ReactNodeView)) { + throw new Error( + "React frames need an editor that renders React node views.", + ); + } + // Read once: TipTap's `dom` getter throws whenever its + // wrapper element isn't rendered, e.g. while unmounting. + const dom = nodeView.dom; + const slot = nodeView.contentDOM; + if (!slot) { + throw new Error("React frame node view has no content DOM."); + } + if (isLayoutBlock) { + // A layout block's slot determines layout, not TipTap's host. + slot.style.display = "contents"; + // FIXME: workaround. The `dom` of a layout block is the wrapper + // of TipTap, which has no box (see `measureFrameRoot`). + measureFrameRoot(dom, () => nodeView.node.attrs.id); + } + return { + dom, + slot, + // TipTap keeps the node it shows, and changes it only when + // it keeps the view. + get node() { + return nodeView.node; + }, + update(node, decorations, innerDecorations) { + // A frame belongs to one block type. A regular block's + // node is its `blockContainer`, so its type is the type + // of the content node. (A layout block's node is the block + // itself, so ProseMirror never offers it another type.) + if ( + !isLayoutBlock && + node.firstChild?.type !== nodeView.node.firstChild?.type + ) { + // The block's type changed. Return `false`: the node + // view draws the block again, with the frame of the + // new type. + return false; + } + // TipTap gives the new node to the React frame. Return + // `true`: the node view keeps the frame. + return nodeView.update(node, decorations, innerDecorations); + }, + // React shows the selection (`props.selected`). + selectNode: () => nodeView.selectNode(), + deselectNode: () => nodeView.deselectNode(), + destroy: () => nodeView.destroy(), + }; }, - )(this.props!) as ReturnType; - } else { - const BlockContent = blockImplementation.render; - const output = renderToDOMSpec((refCB) => { - return ( - - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - /> - - ); - }, editor); - return output; - } - }, + } satisfies Pick< + BlockImplementation, + "renderFrame" | "frameView" + >) + : {}), }, extensions: extensions, }; diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 9674a83b81..863d5421a3 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -2,7 +2,6 @@ import { addInlineContentAttributes, addInlineContentKeyboardShortcuts, BlockNoteEditor, - camelToDataKebab, createInternalInlineContentSpec, CustomInlineContentConfig, CustomInlineContentImplementation, @@ -19,6 +18,7 @@ import { Props, PropSchema, propsToAttributes, + propsToDataAttributes, StyleSchema, } from "@blocknote/core"; import { Node } from "@tiptap/core"; @@ -89,18 +89,8 @@ export function InlineContentWrapper< className={"bn-inline-content-section"} // Sets content type attribute data-inline-content-type={props.inlineContentType} - // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips - // props set to their default values. - {...Object.fromEntries( - Object.entries(props.inlineContentProps) - .filter(([prop, value]) => { - const spec = props.propSchema[prop]; - return value !== spec.default; - }) - .map(([prop, value]) => { - return [camelToDataKebab(prop), value]; - }), - )} + // Adds the props as `data-*` attributes, leaving out those at their default. + {...propsToDataAttributes(props.inlineContentProps, props.propSchema)} > {props.children} diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts index 02393a2fd0..05557d4e8d 100644 --- a/packages/react/src/schema/useNodeViewBlock.ts +++ b/packages/react/src/schema/useNodeViewBlock.ts @@ -1,4 +1,4 @@ -import { Block, getBlockFromPos } from "@blocknote/core"; +import { Block, getBlockFromPos, nodeToBlock } from "@blocknote/core"; import type { NodeViewProps } from "@tiptap/react"; import { useRef } from "react"; @@ -42,6 +42,11 @@ export function useNodeViewBlock( const lastBlockRef = useRef(initialBlock); const doc = props.view.state.doc; + // Layout block node views already receive the complete block node. + if (props.node.type.isInGroup("bnBlock")) { + return nodeToBlock(props.node, doc); + } + 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 1c07d50461..2b84bf0394 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -27,7 +27,8 @@ export default defineConfig( test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], - // Browser tests run in the tests package's Docker browser suite. + // `.browser.test` files need a real browser; the tests package's + // browser suite runs them. exclude: [...configDefaults.exclude, "**/*.browser.test.*"], }, plugins: [react(), webpackStats()], diff --git a/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap index f088373cc6..b14833b3f9 100644 --- a/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap +++ b/packages/server-util/src/context/__snapshots__/ServerBlockNoteEditor.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"

Heading 2

Paragraph

list item

Example
Caption

Example

Caption
"`; +exports[`Test ServerBlockNoteEditor > converts to HTML (blocksToFullHTML) 1`] = `"

Heading 2

Paragraph

list item

Example
Caption

Example

Caption
"`; exports[`Test ServerBlockNoteEditor > converts to and from HTML (blocksToHTMLLossy) 1`] = `"

Heading 2

Paragraph

  • list item

Example
Caption
Example

Caption

"`; diff --git a/packages/xl-ai/src/api/formats/html-blocks/columnContainerDocumentState.test.ts b/packages/xl-ai/src/api/formats/html-blocks/columnContainerDocumentState.test.ts index 82dd20b72c..91a778072c 100644 --- a/packages/xl-ai/src/api/formats/html-blocks/columnContainerDocumentState.test.ts +++ b/packages/xl-ai/src/api/formats/html-blocks/columnContainerDocumentState.test.ts @@ -76,7 +76,9 @@ describe("htmlBlockLLMFormat.defaultDocumentStateBuilder with multi-column", () b.id.includes("column-list-0"), ); expect(columnListEntry).toBeDefined(); - expect(columnListEntry!.block).toContain('data-node-type="columnList"'); + // External HTML has no BlockNote markers; the column list is recognized + // by its class (see the `parse` of the column blocks). + expect(columnListEntry!.block).toContain('class="bn-block-column-list"'); expect(idEntries.find((b) => b.id.includes("column-0"))).toBeDefined(); expect(idEntries.find((b) => b.id.includes("column-1"))).toBeDefined(); diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 7429770e22..109ed5efe9 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -227,54 +227,64 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< }, }); }, - column: (block, _exporter, _nestingLevel, _numberedListIndex, children) => { - return new TableCell({ - width: { - size: `${block.props.width * 100}%`, - type: "pct", - }, - children: (children || []).flatMap((child) => { - if (Array.isArray(child)) { - return child; - } + column: { + withChildren: ( + block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => { + return new TableCell({ + width: { + size: `${block.props.width * 100}%`, + type: "pct", + }, + children: (children || []).flatMap((child) => { + if (Array.isArray(child)) { + return child; + } - return [child]; - }), - }) as any; - }, - columnList: ( - _block, - _exporter, - _nestingLevel, - _numberedListIndex, - children, - ) => { - return new DocxTable({ - layout: "autofit", - borders: { - bottom: { style: "nil" }, - top: { style: "nil" }, - left: { style: "nil" }, - right: { style: "nil" }, - insideHorizontal: { style: "nil" }, - insideVertical: { style: "nil" }, - }, - rows: [ - new TableRow({ - children: (children as unknown as TableCell[]).map( - (cell, _index, children) => { - return new TableCell({ - width: { - size: `${(parseFloat(`${cell.options.width?.size || "100%"}`) / (children.length * 100)) * 100}%`, - type: "pct", - }, - children: cell.options.children, - }); - }, - ), + return [child]; }), - ], - }); + }) as any; + }, + }, + columnList: { + withChildren: ( + _block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => { + return new DocxTable({ + layout: "autofit", + borders: { + bottom: { style: "nil" }, + top: { style: "nil" }, + left: { style: "nil" }, + right: { style: "nil" }, + insideHorizontal: { style: "nil" }, + insideVertical: { style: "nil" }, + }, + rows: [ + new TableRow({ + children: (children as unknown as TableCell[]).map( + (cell, _index, children) => { + return new TableCell({ + width: { + size: `${(parseFloat(`${cell.options.width?.size || "100%"}`) / (children.length * 100)) * 100}%`, + type: "pct", + }, + children: cell.options.children, + }); + }, + ), + }), + ], + }); + }, }, image: async (block, exporter) => { if (!block.props.url) { diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index f27a4b6bcb..98512d0fdb 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -56,7 +56,7 @@ export class DOCXExporter< /** * The schema of your editor. The mappings are automatically typed checked against this schema. */ - protected readonly schema: BlockNoteSchema, + schema: BlockNoteSchema, /** * The mappings that map the BlockNote schema to the docxjs content. * Pass {@link docxDefaultSchemaMappings} for the default schema. @@ -158,7 +158,7 @@ export class DOCXExporter< let children = await this.transformBlocks(b.children, nestingLevel + 1); - if (!["columnList", "column"].includes(b.type)) { + if (!this.placesChildren(b)) { children = children.map((c, _i) => { // NOTE: nested tables not supported (we can't insert the new Tab before a table) if ( @@ -178,17 +178,16 @@ export class DOCXExporter< // The `numberedListIndex` slot carries the numbering instance for the docx // block mappings (bullet/numbered list items); other block types ignore it. const self = await this.mapBlock( - b as any, + b, nestingLevel, numberingInstance, children, - ); // TODO: any - if (["columnList", "column"].includes(b.type)) { - ret.push(self as Table); - } else if (Array.isArray(self)) { - ret.push(...self, ...children); - } else { - ret.push(self, ...children); + ); + ret.push(...(Array.isArray(self) ? self : [self])); + // A layout block's mapping is handed its children and places them itself, + // so they must not be appended after it as well. + if (!this.placesChildren(b)) { + ret.push(...children); } } return ret; diff --git a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx index a3ba8b653e..de2bffab1a 100644 --- a/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx +++ b/packages/xl-email-exporter/src/react-email/defaultSchema/blocks.tsx @@ -542,10 +542,27 @@ export const createReactEmailBlockMappingForDefaultSchema = ( ); }, // Email clients handle side-by-side layout poorly, so columns are stacked: - // these wrappers render nothing themselves and the exporter's generic child - // rendering stacks the column contents vertically. - column: () => <>, - columnList: () => <>, + // these layout block mappings place their children (which the exporter passes + // in) one after another, with no wrapper of their own - so the column + // contents render flat, in document order. + column: { + withChildren: ( + _block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => <>{children}, + }, + columnList: { + withChildren: ( + _block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => <>{children}, + }, }); // Export the original mapping for backward compatibility diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx index 976acb1fc1..da13b5aa65 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx @@ -6,12 +6,26 @@ import { createPageBreakBlockSpec, defaultBlockSpecs, } from "@blocknote/core"; +import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; import { testDocument } from "@shared/testDocument.js"; +// Schema including the multi-column blocks, matching the shared testDocument. +// The columns are layout blocks, so the exporter only recognizes them as +// such (and lets their mappings place the children) when they're in the +// schema it was constructed with. +const fullSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + pageBreak: createPageBreakBlockSpec(), + column: ColumnBlock, + columnList: ColumnListBlock, + }, +}); + describe("react email exporter", () => { it("should export a document (HTML snapshot)", async () => { const exporter = new ReactEmailExporter( - BlockNoteSchema.create(), + fullSchema, reactEmailDefaultSchemaMappings, ); @@ -21,12 +35,7 @@ describe("react email exporter", () => { it("should export a document with preview", async () => { const exporter = new ReactEmailExporter( - BlockNoteSchema.create({ - blockSpecs: { - ...defaultBlockSpecs, - pageBreak: createPageBreakBlockSpec(), - }, - }), + fullSchema, reactEmailDefaultSchemaMappings, ); @@ -38,12 +47,7 @@ describe("react email exporter", () => { it("should export a document with multiple preview lines", async () => { const exporter = new ReactEmailExporter( - BlockNoteSchema.create({ - blockSpecs: { - ...defaultBlockSpecs, - pageBreak: createPageBreakBlockSpec(), - }, - }), + fullSchema, reactEmailDefaultSchemaMappings, ); @@ -686,7 +690,7 @@ describe("react email exporter", () => { it("should handle document with custom body styles", async () => { const exporter = new ReactEmailExporter( - BlockNoteSchema.create(), + fullSchema, reactEmailDefaultSchemaMappings, ); diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx index fd1aa88a52..1f1130163b 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx @@ -92,11 +92,7 @@ export class ReactEmailExporter< j++, itemIndex++ ) { const block = blocks[j]; - const liContent = (await this.mapBlock( - block as any, - nestingLevel, - itemIndex, - )) as any; + const liContent = await this.mapBlock(block, nestingLevel, itemIndex); let nestedList: React.ReactElement[] = []; if (block.children && block.children.length > 0) { nestedList = await this.renderNestedLists( @@ -157,11 +153,11 @@ export class ReactEmailExporter< j++, itemIndex++ ) { const listItem = children[j]; - const liContent = (await this.mapBlock( - listItem as any, + const liContent = await this.mapBlock( + listItem, nestingLevel, itemIndex, - )) as any; + ); const style = this.blocknoteDefaultPropsToReactEmailStyle( listItem.props as any, ); @@ -251,26 +247,17 @@ export class ReactEmailExporter< i = nextIndex; continue; } - // Multi-column blocks stack their content vertically in email (their - // mappings render nothing themselves). The columns' children are - // structural, not nested sub-content, so they render flat - no - // indentation wrapper, and at the *same* nesting level (a level bump - // per wrapper would report column content as deeply nested to - // level-sensitive mappings). - if (b.type === "columnList" || b.type === "column") { - ret.push( - - {await this.transformBlocks(b.children, nestingLevel)} - , - ); - i++; - continue; - } - - // Non-list blocks + const placesChildren = this.placesChildren(b); const children = await this.transformBlocks(b.children, nestingLevel + 1); - const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any; - const style = this.blocknoteDefaultPropsToReactEmailStyle(b.props as any); + const self = await this.mapBlock( + b, + nestingLevel, + 0, + placesChildren ? children : undefined, + ); + const style = placesChildren + ? {} + : this.blocknoteDefaultPropsToReactEmailStyle(b.props); ret.push( @@ -279,7 +266,7 @@ export class ReactEmailExporter< ) : ( self )} - {children.length > 0 && ( + {!placesChildren && children.length > 0 && (
{children}
)}
, diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index 2e49261ec6..00c3bd5885 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,28 +1,77 @@ +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", + layoutOnly: true, + placeable: "namedOnly", + // Column lists don't nest in columns, as on `main` and in Notion. + children: { allow: "blocks", exclude: ["columnList"] }, }, { - width: { - default: 1, + // Enter in an empty column block adds another block, as in Notion: + // columns are layout, and people add space with Enter. Other layout blocks + // keep the default, where an empty last block leaves the layout block. + experimental_keyboard: { emptyChildEnter: "stay" }, + // External HTML carries no BlockNote markers, so columns are recognized + // by the class their frame draws. `width` comes back from `data-width`. + parse: (el) => (el.classList.contains("bn-block-column") ? {} : undefined), + renderFrame: (block) => { + const dom = document.createElement("div"); + dom.className = "bn-block-column"; + dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT); + + return { + dom, + slot: dom, + update: (newBlock) => { + dom.style.flexGrow = String( + newBlock.props.width ?? COLUMN_WIDTH_DEFAULT, + ); + }, + }; }, }, - [MultiColumnDropHandlerExtension()], -); + [MultiColumnDropHandlerExtension(), ColumnResizeExtension()], +)(); -export const ColumnListBlock = createBlockSpecFromTiptapNode( +export const ColumnListBlock = createBlockSpec( { - node: ColumnList, - type: "columnList", + type: "columnList" as const, + propSchema: {}, content: "none", + layoutOnly: true, + children: { + allow: ["column"], + min: 2, + }, + }, + { + meta: { + draggable: false, + }, + parse: (el) => + el.classList.contains("bn-block-column-list") ? {} : undefined, + renderFrame: () => { + const dom = document.createElement("div"); + dom.className = "bn-block-column-list"; + dom.style.display = "flex"; + + return { + dom, + slot: dom, + }; + }, }, - {}, -); +)(); diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts index 5713466a6d..44bcc255c7 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,75 @@ 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", +); + +function isAdjacentColumnPair( + doc: Node, + columnList: Pick, + leftColumn: Pick, + rightColumn: Pick, +): boolean { + const left = doc.resolve(leftColumn.posBeforeNode); + const right = doc.resolve(rightColumn.posBeforeNode); + return ( + columnList.node.type.name === "columnList" && + leftColumn.node.type.name === "column" && + rightColumn.node.type.name === "column" && + left.parent === columnList.node && + right.parent === columnList.node && + left.index() + 1 === right.index() + ); +} + +// Resolve stored positions after edits; removed or separated columns end the interaction. +function refreshColumnState(state: ColumnState, doc: Node): ColumnState { + if (state.type === "default") { + return state; + } + + const columnList = getNodeById(state.columnList.id, doc); + if (!columnList) { + return { type: "default" }; + } + const refreshedList = { ...state.columnList, ...columnList }; + if (state.type === "hover-column-list") { + return { ...state, columnList: refreshedList }; + } + + const left = getNodeById(state.leftColumn.id, doc); + const right = getNodeById(state.rightColumn.id, doc); + if (!left || !right || !isAdjacentColumnPair(doc, columnList, left, right)) { + return { type: "default" }; + } + + // Narrow before spreading so resize columns retain their starting widths. + switch (state.type) { + case "hover-column": + return { + ...state, + columnList: refreshedList, + leftColumn: { ...state.leftColumn, ...left }, + rightColumn: { ...state.rightColumn, ...right }, + }; + case "resize": + return { + ...state, + columnList: refreshedList, + leftColumn: { ...state.leftColumn, ...left }, + rightColumn: { ...state.rightColumn, ...right }, + }; + } +} class ColumnResizePluginView implements PluginView { editor: BlockNoteEditor; @@ -428,22 +489,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/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 1c64a25d1b..9aac26564c 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -4,12 +4,43 @@ import { createExtension, fragmentToBlocks, getBlockInfoFromNode, + getNodeById, nodeToBlock, } from "@blocknote/core"; +import type { Node } from "prosemirror-model"; import { Plugin } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; import { detectEdgePosition } from "./multiColumnDropCursor.js"; +/** + * Whether the drop target is one of the dragged blocks, or inside one: the + * blocks are being dropped onto themselves. E.g. a block selection that spans + * the paragraph above a column list and the whole list, dragged by one of the + * list's blocks and dropped on a column's edge. Removing the dragged blocks + * would take the target with them. + */ +function targetInDraggedBlocks( + doc: Node, + draggedBlockIds: Set, + targetId: string, +): boolean { + const target = getNodeById(targetId, doc); + if (!target) { + throw new Error(`Drop target "${targetId}" is not in the document`); + } + for (const id of draggedBlockIds) { + const dragged = getNodeById(id, doc); + if ( + dragged && + dragged.posBeforeNode <= target.posBeforeNode && + target.posBeforeNode < dragged.posBeforeNode + dragged.node.nodeSize + ) { + return true; + } + } + return false; +} + /** * Creates a ProseMirror plugin that handles drop events for multi-column layouts. * When a block is dropped near the left or right edge of another block, it creates @@ -51,12 +82,15 @@ export function createMultiColumnHandleDropPlugin( // 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.block.node.forEach((child: any) => { + blockInfo.block.node.forEach((child) => { if (!draggedBlockIds.has(child.attrs.id)) { allTargetChildrenDragged = false; } }); - if (allTargetChildrenDragged) { + if ( + allTargetChildrenDragged && + draggedBlockIds.size === blockInfo.block.node.childCount + ) { return true; } @@ -70,6 +104,17 @@ export function createMultiColumnHandleDropPlugin( view.state.doc, ); + // The user is dropping the blocks on a list they hold - do nothing. + if ( + targetInDraggedBlocks( + view.state.doc, + draggedBlockIds, + columnList.id, + ) + ) { + return true; + } + // 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 @@ -116,36 +161,39 @@ export function createMultiColumnHandleDropPlugin( blocksAlreadyInColumnList.add(block.id); return false; }), - })) - // Remove empty columns (can happen when dragged blocks are - // removed). - .filter((column) => column.children.length > 0); - - // The insertion index is computed on the remaining columns, as - // removing an emptied column before the drop target shifts the - // target's position in the list. - const targetIndex = remainingColumns.findIndex( + })); + + // Count surviving columns before the original drop boundary. This + // also works when the selection empties the target column itself. + const originalTargetIndex = columnList.children.findIndex( (column) => column.id === targetColumnId, ); - if (targetIndex === -1) { - // The target column can only be missing if the drag emptied it, - // which is handled as a no-op above. + if (originalTargetIndex === -1) { + // The drop target comes from this column list, so it is one of + // its columns. If it is not, the two come from different + // documents. Without this check, the new column goes to an + // incorrect position and no error shows. throw new Error( - "Drop target column not found in the remaining columns", + "The drop target column is not a column of its column list.", ); } - const insertionIndex = - edgePos.position === "left" ? targetIndex : targetIndex + 1; + const boundary = + originalTargetIndex + (edgePos.position === "right" ? 1 : 0); + const insertionIndex = remainingColumns + .slice(0, boundary) + .filter((column) => column.children.length > 0).length; // 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(), - }); + const newChildren = remainingColumns + .filter((column) => column.children.length > 0) + .toSpliced(insertionIndex, 0, { + type: "column", + children: draggedBlocks, + props: {}, + content: undefined, + id: UniqueID.options.generateID(), + }); const blocksToRemove = draggedBlocks.filter( (block) => @@ -156,16 +204,22 @@ export function createMultiColumnHandleDropPlugin( editor.removeBlocks(blocksToRemove); } - editor.updateBlock(columnList, { - children: newChildren, - }); + if (newChildren.length === 1) { + editor.replaceBlocks([columnList], draggedBlocks); + } else { + editor.updateBlock(columnList, { + children: newChildren, + }); + } } else { // Create new columnList with blocks as columns const block = nodeToBlock(blockInfo.block.node, view.state.doc); - // The user is dropping next to one of the blocks being dragged - do - // nothing. - if (draggedBlockIds.has(block.id)) { + // The user is dropping next to one of the blocks being dragged, or + // next to a block inside one - do nothing. + if ( + targetInDraggedBlocks(view.state.doc, draggedBlockIds, block.id) + ) { return true; } diff --git a/packages/xl-multi-column/src/extensions/SuggestionMenu/__snapshots__/getMultiColumnSlashMenuItems.test.tsx.snap b/packages/xl-multi-column/src/extensions/SuggestionMenu/__snapshots__/getMultiColumnSlashMenuItems.test.tsx.snap index 0cfde34ae5..59054935f0 100644 --- a/packages/xl-multi-column/src/extensions/SuggestionMenu/__snapshots__/getMultiColumnSlashMenuItems.test.tsx.snap +++ b/packages/xl-multi-column/src/extensions/SuggestionMenu/__snapshots__/getMultiColumnSlashMenuItems.test.tsx.snap @@ -983,7 +983,7 @@ exports[`Test getMultiColumnSlashMenuItems > Text cursor not in a column > Repla { "children": [], "content": [], - "id": "2", + "id": "1", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -993,7 +993,7 @@ exports[`Test getMultiColumnSlashMenuItems > Text cursor not in a column > Repla }, ], "content": undefined, - "id": "1", + "id": "0", "props": { "width": 1, }, @@ -1004,7 +1004,7 @@ exports[`Test getMultiColumnSlashMenuItems > Text cursor not in a column > Repla { "children": [], "content": [], - "id": "4", + "id": "3", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -1014,7 +1014,7 @@ exports[`Test getMultiColumnSlashMenuItems > Text cursor not in a column > Repla }, ], "content": undefined, - "id": "3", + "id": "2", "props": { "width": 1, }, @@ -1022,7 +1022,7 @@ exports[`Test getMultiColumnSlashMenuItems > Text cursor not in a column > Repla }, ], "content": undefined, - "id": "0", + "id": "empty-paragraph", "props": {}, "type": "columnList", }, diff --git a/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx b/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx index f1f3572513..0ab7e8c047 100644 --- a/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx +++ b/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.test.tsx @@ -53,9 +53,8 @@ describe("Test getMultiColumnSlashMenuItems", () => { insertColumnList(getEditor(), 2); - // The empty paragraph should have been replaced by the column list, so it - // should no longer be in the document. - expect(getEditor().getBlock("empty-paragraph")).toBeUndefined(); + // The empty paragraph becomes the column list, and keeps its id. + expect(getEditor().getBlock("empty-paragraph")?.type).toBe("columnList"); expect(getEditor().document).toMatchSnapshot(); }); }); 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/columnChildren.test.ts b/packages/xl-multi-column/src/test/columnChildren.test.ts new file mode 100644 index 0000000000..c395eb1dbf --- /dev/null +++ b/packages/xl-multi-column/src/test/columnChildren.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { setupTestEnv } from "./setupTestEnv.js"; + +const getEditor = setupTestEnv(); + +const column = (id: string, text: string) => ({ + type: "column" as const, + id, + children: [{ id: `${id}-p`, type: "paragraph" as const, content: text }], +}); + +// A column holds any block except a column list (`children.exclude`), as on +// `main`, where its node's content was `blockContainer+`. +describe("column children", () => { + it("accept any block but a column list", () => { + const { column, columnList, blockContainer } = getEditor().pmSchema.nodes; + + expect(column.spec.content).toBe("blockContainer+"); + expect(column.contentMatch.matchType(blockContainer)).not.toBeNull(); + expect(column.contentMatch.matchType(columnList)).toBeNull(); + }); + + it("refuse a column list inserted through the API", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "cl-0", + children: [column("col-1", "one"), column("col-2", "two")], + }, + ]); + + expect(() => + editor.insertBlocks( + [ + { + type: "columnList", + children: [column("in-1", "a"), column("in-2", "b")], + }, + ], + "col-1", + "last-child", + ), + ).toThrow(/at "last-child" of block "col-1": no valid position/); + expect(editor.getBlock("col-1")!.children.map((child) => child.id)).toEqual( + ["col-1-p"], + ); + }); + + it("refuse a document with a column list in a column", () => { + const editor = getEditor(); + + expect(() => + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "cl-0", + children: [ + { + type: "column", + id: "col-1", + children: [ + { + type: "columnList", + id: "nested", + children: [column("in-1", "a"), column("in-2", "b")], + }, + ], + }, + column("col-2", "two"), + ], + }, + ]), + ).toThrow(/Cannot create block "column": its children don't fit/); + }); +}); 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/__snapshots__/moveBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap index d9021ec0e0..7851a21e5d 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/moveBlocks.test.ts.snap @@ -1,5 +1,591 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`Empty a column by moving out of it > Move the only block out of the first column 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Before", + "type": "text", + }, + ], + "id": "paragraph-before", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Only 0", + "type": "text", + }, + ], + "id": "only-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-single-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Only 1", + "type": "text", + }, + ], + "id": "only-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-single-1", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "column-list-single", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "After", + "type": "text", + }, + ], + "id": "paragraph-after", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Empty a column by moving out of it > Move the only block out of the last column 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Before", + "type": "text", + }, + ], + "id": "paragraph-before", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Only 0", + "type": "text", + }, + ], + "id": "only-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-single-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-single-1", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "column-list-single", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Only 1", + "type": "text", + }, + ], + "id": "only-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "After", + "type": "text", + }, + ], + "id": "paragraph-after", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`Move a column > Move column down 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": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 1", + "type": "text", + }, + ], + "id": "paragraph-1", + "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": "0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "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", + }, + { + "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[`Move a column > Move column up 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": [], + "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", + }, + { + "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-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "0", + "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[`Move past empty sibling within a column > Move down below empty sibling 1`] = ` [ { diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/updateBlock.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/updateBlock.test.ts.snap index 84408b8cfe..1e1476b718 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/updateBlock.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/updateBlock.test.ts.snap @@ -526,7 +526,7 @@ exports[`Test updateBlock > Update nested paragraph to column list 1`] = ` "type": "text", }, ], - "id": "2", + "id": "1", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -536,7 +536,7 @@ exports[`Test updateBlock > Update nested paragraph to column list 1`] = ` }, ], "content": undefined, - "id": "1", + "id": "0", "props": { "width": 1, }, @@ -553,7 +553,7 @@ exports[`Test updateBlock > Update nested paragraph to column list 1`] = ` "type": "text", }, ], - "id": "4", + "id": "3", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -563,7 +563,7 @@ exports[`Test updateBlock > Update nested paragraph to column list 1`] = ` }, ], "content": undefined, - "id": "3", + "id": "2", "props": { "width": 1, }, @@ -571,7 +571,7 @@ exports[`Test updateBlock > Update nested paragraph to column list 1`] = ` }, ], "content": undefined, - "id": "0", + "id": "nested-paragraph-0", "props": {}, "type": "columnList", }, @@ -941,7 +941,7 @@ exports[`Test updateBlock > Update paragraph to column list 1`] = ` "type": "text", }, ], - "id": "2", + "id": "1", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -951,7 +951,7 @@ exports[`Test updateBlock > Update paragraph to column list 1`] = ` }, ], "content": undefined, - "id": "1", + "id": "0", "props": { "width": 1, }, @@ -968,7 +968,7 @@ exports[`Test updateBlock > Update paragraph to column list 1`] = ` "type": "text", }, ], - "id": "4", + "id": "3", "props": { "backgroundColor": "default", "textAlignment": "left", @@ -978,7 +978,7 @@ exports[`Test updateBlock > Update paragraph to column list 1`] = ` }, ], "content": undefined, - "id": "3", + "id": "2", "props": { "width": 1, }, @@ -986,7 +986,7 @@ exports[`Test updateBlock > Update paragraph to column list 1`] = ` }, ], "content": undefined, - "id": "0", + "id": "paragraph-0", "props": {}, "type": "columnList", }, diff --git a/packages/xl-multi-column/src/test/commands/backspace.test.ts b/packages/xl-multi-column/src/test/commands/backspace.test.ts index 87156e81a2..88587db3a0 100644 --- a/packages/xl-multi-column/src/test/commands/backspace.test.ts +++ b/packages/xl-multi-column/src/test/commands/backspace.test.ts @@ -83,6 +83,115 @@ const threeColumnsWithParagraphAbove = [ }, ]; +const columnsOnly = [ + { + type: "columnList" as const, + children: [ + { + type: "column" as const, + children: [ + { id: "col1-para", type: "paragraph" as const, content: "col1" }, + ], + }, + { + type: "column" as const, + children: [ + { id: "col2-para", type: "paragraph" as const, content: "col2" }, + ], + }, + ], + }, +]; + +const columnsWithEmptyFirst = [ + { + type: "columnList" as const, + children: [ + { + type: "column" as const, + children: [ + { id: "empty-para", type: "paragraph" as const, content: "" }, + ], + }, + { + type: "column" as const, + children: [ + { id: "col2-para", type: "paragraph" as const, content: "col2" }, + { id: "col2-trailing", type: "paragraph" as const, content: "" }, + ], + }, + ], + }, +]; + +/** Every bit of text in the document, so a fix can't quietly drop content. */ +function texts(editor: BlockNoteEditor): string[] { + const out: string[] = []; + const walk = (blocks: any[]) => { + for (const block of blocks) { + if (Array.isArray(block.content)) { + const text = block.content.map((c: any) => c.text ?? "").join(""); + if (text) { + out.push(text); + } + } + walk(block.children ?? []); + } + }; + walk(editor.document); + return out.sort(); +} + +// A column layout with nothing after it, and one whose first column is empty, +// both used to throw out of the keydown handler: there is no following block +// to pull in, and no non-empty first column to merge with. The document is +// left alone (or repaired) instead. +describe("Delete and Backspace at a column layout's edges", () => { + it("Delete at the end of the last column, with the layout ending the document", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, columnsOnly); + + editor.setTextCursorPosition("col2-para", "end"); + + expect(() => pressDelete(editor)).not.toThrow(); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + expect(texts(editor)).toEqual(["col1", "col2"]); + }); + + it("Delete at the start of an empty first column", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, columnsWithEmptyFirst); + + editor.setTextCursorPosition("empty-para", "start"); + + expect(() => pressDelete(editor)).not.toThrow(); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + expect(texts(editor)).toEqual(["col2"]); + }); + + it("Delete at the end of a trailing empty block in the last column", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, columnsWithEmptyFirst); + + editor.setTextCursorPosition("col2-trailing", "end"); + + expect(() => pressDelete(editor)).not.toThrow(); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + expect(texts(editor)).toEqual(["col2"]); + }); + + it("Backspace at the start of a column following an empty column", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, columnsWithEmptyFirst); + + editor.setTextCursorPosition("col2-para", "start"); + + expect(() => pressBackspace(editor)).not.toThrow(); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + expect(texts(editor)).toEqual(["col2"]); + }); +}); + describe("Backspace with multi-column", () => { // TODO: When migrating to vitest browser mode, replace this test with // a version that presses Backspace 5 times from offset 5 in "hello world" @@ -200,3 +309,51 @@ describe("Delete with multi-column", () => { expect(editor.document).toMatchSnapshot(); }); }); + +describe("Backspace at the start of a column's first block", () => { + // Left column A1 + A2, right column B1, caret at the start of B1. The block + // leaves the column list, which dissolves into its blocks, and the caret + // stays at the start of the block (#3104 unwraps the list in place). + it("moves the block out, dissolves the list, and keeps the caret", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "cl-0", + children: [ + { + type: "column", + id: "col-1", + children: [ + { id: "A1", type: "paragraph", content: "A1" }, + { id: "A2", type: "paragraph", content: "A2" }, + ], + }, + { + type: "column", + id: "col-2", + children: [{ id: "B1", type: "paragraph", content: "B1" }], + }, + ], + }, + { id: "after", type: "paragraph", content: "after" }, + ]); + + editor.setTextCursorPosition("B1", "start"); + pressBackspace(editor); + + expect(editor.document.map((block) => block.id)).toEqual([ + "A1", + "A2", + "B1", + "after", + ]); + expect(editor.document.every((block) => block.type === "paragraph")).toBe( + true, + ); + expect(editor.getTextCursorPosition().block.id).toBe("B1"); + const { selection } = editor.prosemirrorState; + expect(selection.empty).toBe(true); + expect(selection.$from.parentOffset).toBe(0); + }); +}); 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..498da3613b --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/enter.test.ts @@ -0,0 +1,99 @@ +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 set `emptyChildEnter: "stay"`: Enter in an empty column block adds +// another block in the column, as on `main` and in Notion, instead of the +// layout block default where an empty last block leaves the layout block. The +// generic exit mechanics are covered in core's `layoutBlocks.browser.test.ts`. +describe("Enter in columns", () => { + it("adds blocks in the column and never leaves it", () => { + 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 created = editor.getBlock("col-2")!.children[1].id; + expect(editor.getBlock("col-2")!.children).toHaveLength(2); + expect(editor.getTextCursorPosition().block.id).toBe(created); + + pressEnter(editor); + + // Second press, in the empty block: another block in the column, caret + // in it, instead of leaving the column list. + const children = editor.getBlock("col-2")!.children; + expect(children.map((child) => child.id).slice(0, 2)).toEqual([ + "col2-para", + created, + ]); + expect(children).toHaveLength(3); + expect(editor.document.map((block) => block.id)).toEqual(["cl-0"]); + expect(editor.getTextCursorPosition().block.id).toBe(children[2].id); + }); + + it("adds a block after a column's only empty block, keeping the column", () => { + 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(["cl-0"]); + const children = editor.getBlock("col-2")!.children; + expect(children).toHaveLength(2); + expect(children[0].id).toBe("col2-empty"); + expect(editor.getTextCursorPosition().block.id).toBe(children[1].id); + }); +}); 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..329ba7ad12 100644 --- a/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts +++ b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts @@ -6,60 +6,69 @@ 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", () => { - // should throw an error as we don't allow empty columns - expect(() => { - getEditor().insertBlocks( - [ - { - type: "columnList", - children: [ - { - type: "column", - }, - ], - }, - ], - "paragraph-0", - "after", - ); - }).toThrow(); + // The empty column is padded with a paragraph, and the list is padded to + // its `min: 2` with a second column, instead of throwing. + getEditor().insertBlocks( + [ + { + type: "columnList", + children: [ + { + type: "column", + }, + ], + }, + ], + "paragraph-0", + "after", + ); + + const list = getEditor().document[1] as any; + expect(list.type).toBe("columnList"); + expect(list.children).toHaveLength(2); + expect(list.children[0].children).toHaveLength(1); + expect(list.children[1].children).toHaveLength(1); }); it("Insert column list with single column", () => { - // should throw an error as we don't allow column list with single column - expect(() => { - getEditor().insertBlocks( - [ - { - type: "columnList", - children: [ - { - type: "column", - children: [ - { - type: "paragraph", - content: "Inserted Column Paragraph", - }, - ], - }, - ], - }, - ], - "paragraph-0", - "after", - ); - }).toThrow(); + // A one-column list is padded up to `min: 2` with a second column, + // instead of throwing. + getEditor().insertBlocks( + [ + { + type: "columnList", + children: [ + { + type: "column", + children: [ + { + type: "paragraph", + content: "Inserted Column Paragraph", + }, + ], + }, + ], + }, + ], + "paragraph-0", + "after", + ); + + const list = getEditor().document[1] as any; + expect(list.type).toBe("columnList"); + expect(list.children).toHaveLength(2); + expect(list.children[0].children[0].content[0].text).toBe( + "Inserted Column Paragraph", + ); + expect(list.children[1].children[0].content).toEqual([]); }); it("Insert valid column list with two columns", () => { diff --git a/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts b/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts index 6970c6c036..474428330b 100644 --- a/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts +++ b/packages/xl-multi-column/src/test/commands/moveBlocks.test.ts @@ -196,3 +196,64 @@ describe("Move past empty sibling within a column", () => { expect(getEditor().document).toMatchSnapshot(); }); }); + +// A `column` is `placeable: "namedOnly"`, so it can't be moved anywhere a +// regular block goes: moving one dissolves it and moves its children instead. +// The column list is left at its `min` of 2 by an empty replacement column, +// rather than unwrapping - see the note on emptied columns below. +describe("Move a column", () => { + it("Move column up", () => { + getEditor().moveBlocksUp("column-1"); + + expect(getEditor().document).toMatchSnapshot(); + }); + + it("Move column down", () => { + getEditor().moveBlocksDown("column-0"); + + expect(getEditor().document).toMatchSnapshot(); + }); +}); + +// A move is a rearrangement rather than a deletion, so a column it empties out +// is deliberately left standing instead of being collapsed (see `moveBlocks`). +describe("Empty a column by moving out of it", () => { + beforeEach(() => { + getEditor().replaceBlocks(getEditor().document, [ + { id: "paragraph-before", type: "paragraph", content: "Before" }, + { + id: "column-list-single", + type: "columnList", + children: [ + { + id: "column-single-0", + type: "column", + children: [{ id: "only-0", type: "paragraph", content: "Only 0" }], + }, + { + id: "column-single-1", + type: "column", + children: [{ id: "only-1", type: "paragraph", content: "Only 1" }], + }, + ], + }, + { id: "paragraph-after", type: "paragraph", content: "After" }, + ]); + }); + + it("Move the only block out of the first column", () => { + getEditor().setTextCursorPosition("only-0"); + + getEditor().moveBlocksUp(); + + expect(getEditor().document).toMatchSnapshot(); + }); + + it("Move the only block out of the last column", () => { + getEditor().setTextCursorPosition("only-1"); + + getEditor().moveBlocksDown(); + + expect(getEditor().document).toMatchSnapshot(); + }); +}); diff --git a/packages/xl-multi-column/src/test/commands/nestBlock.test.ts b/packages/xl-multi-column/src/test/commands/nestBlock.test.ts new file mode 100644 index 0000000000..83df41caac --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/nestBlock.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { setupTestEnv } from "../setupTestEnv.js"; + +const getEditor = setupTestEnv(); + +// Tab and Shift-Tab with a selection spanning two columns. There is no +// nestable range inside the column list — it holds only `column`s — so the +// range has to resolve outside it and move the list as a unit. Nesting used to +// resolve the range at the columns themselves, where the preconditions can +// never hold, making both keys silent no-ops. +describe("Nest & unnest a selection spanning two columns", () => { + it("Nests the whole column list under its previous sibling", () => { + const editor = getEditor(); + + editor.setSelection("column-paragraph-1", "column-paragraph-2"); + + expect(editor.canNestBlock()).toBe(true); + editor.nestBlock(); + + expect(editor.document.map((block) => block.id)).toEqual([ + "paragraph-0", + "paragraph-1", + "paragraph-2", + ]); + expect(editor.getBlock("paragraph-1")!.children.map((c) => c.id)).toEqual([ + "column-list-0", + ]); + // The list itself is untouched — only its position changed. + expect(editor.getBlock("column-list-0")!.children.map((c) => c.id)).toEqual( + ["column-0", "column-1"], + ); + }); + + // The slice `sinkItem` builds used to open into the last of the previous + // sibling's children, which fails when that is a column list ("Cannot join + // blockContainer onto columnList"). The block now goes after the list. + it("Nests a block after a column list at the end of the previous sibling's children", () => { + const editor = getEditor(); + + editor.setSelection("column-paragraph-1", "column-paragraph-2"); + editor.nestBlock(); + expect(editor.getBlock("paragraph-1")!.children.map((c) => c.id)).toEqual([ + "column-list-0", + ]); + + editor.setTextCursorPosition("paragraph-2", "start"); + expect(editor.canNestBlock()).toBe(true); + editor.nestBlock(); + + expect(editor.getBlock("paragraph-1")!.children.map((c) => c.id)).toEqual([ + "column-list-0", + "paragraph-2", + ]); + expect(editor.getTextCursorPosition().block.id).toBe("paragraph-2"); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Unnests the whole column list out of its parent", () => { + const editor = getEditor(); + + editor.setSelection("column-paragraph-1", "column-paragraph-2"); + editor.nestBlock(); + + editor.setSelection("column-paragraph-1", "column-paragraph-2"); + expect(editor.canUnnestBlock()).toBe(true); + editor.unnestBlock(); + + expect(editor.document.map((block) => block.id)).toEqual([ + "paragraph-0", + "paragraph-1", + "column-list-0", + "paragraph-2", + ]); + expect(editor.getBlock("paragraph-1")!.children).toEqual([]); + expect(editor.getBlock("column-list-0")!.children.map((c) => c.id)).toEqual( + ["column-0", "column-1"], + ); + }); + + it("Reports no nesting when the column list has no previous sibling", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + editor.getBlock("column-list-0")!, + { id: "after", type: "paragraph", content: "After" }, + ]); + + editor.setSelection("column-paragraph-1", "column-paragraph-2"); + + const before = editor.document; + expect(editor.canNestBlock()).toBe(false); + editor.nestBlock(); + expect(editor.document).toEqual(before); + }); +}); diff --git a/packages/xl-multi-column/src/test/commands/removeBlocks.test.ts b/packages/xl-multi-column/src/test/commands/removeBlocks.test.ts index 031ebfe33d..cab7abe628 100644 --- a/packages/xl-multi-column/src/test/commands/removeBlocks.test.ts +++ b/packages/xl-multi-column/src/test/commands/removeBlocks.test.ts @@ -79,4 +79,57 @@ describe("Test removeBlocks", () => { expect(getEditor().document).toMatchSnapshot(); }); + + // Removing the other column leaves the column list with one column, so it + // dissolves and the surviving column's blocks are lifted out. That is an + // unwrap of content that never moves, so a caret in it survives untouched. + it("Keeps the cursor in place when removing a column dissolves the columnList", () => { + const editor = getEditor(); + editor.setTextCursorPosition("column-paragraph-2", "end"); + const offsetBefore = editor.prosemirrorState.selection.$from.parentOffset; + + editor.removeBlocks(["column-0"]); + + expect(editor.getTextCursorPosition().block.id).toBe("column-paragraph-2"); + expect(editor.prosemirrorState.selection.$from.parentOffset).toBe( + offsetBefore, + ); + }); + + // A block that is nested under a regular block cannot empty a column, so + // removing it must not repair the column list. Here column 0 holds only an + // empty paragraph, which the repair would otherwise remove. + it("Keeps the column list when removing a nested block in a column", () => { + getEditor().replaceBlocks(getEditor().document, [ + { + id: "cl", + type: "columnList", + children: [ + { + id: "c0", + type: "column", + children: [{ id: "e", type: "paragraph" }], + }, + { + id: "c1", + type: "column", + children: [ + { + id: "a", + type: "paragraph", + content: "A", + children: [{ id: "n", type: "paragraph", content: "N" }], + }, + ], + }, + ], + }, + ]); + + getEditor().removeBlocks(["n"]); + + const list = getEditor().getBlock("cl"); + expect(list?.type).toBe("columnList"); + expect(list?.children.map((column) => column.id)).toEqual(["c0", "c1"]); + }); }); 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__/fixColumnLists.test.ts.snap deleted file mode 100644 index 87b5f2e588..0000000000 --- a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap +++ /dev/null @@ -1,408 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Test fixColumnList > First of two columns empty 1`] = ` -{ - "content": [ - { - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "content": [ - { - "text": "Paragraph 1", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "blockGroup", - }, - ], - "type": "doc", -} -`; - -exports[`Test fixColumnList > Last of two columns empty 1`] = ` -{ - "content": [ - { - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "content": [ - { - "text": "Paragraph 1", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "blockGroup", - }, - ], - "type": "doc", -} -`; - -exports[`Test fixColumnList > Two empty columns 1`] = ` -{ - "content": [ - { - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "blockGroup", - }, - ], - "type": "doc", -} -`; - -exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` -{ - "content": [ - { - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "content": [ - { - "text": "Paragraph 1", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - ], - "type": "columnList", - }, - ], - "type": "blockGroup", - }, - ], - "type": "doc", -} -`; - -exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` -{ - "content": [ - { - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "content": [ - { - "text": "Paragraph 1", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - ], - "type": "columnList", - }, - ], - "type": "blockGroup", - }, - ], - "type": "doc", -} -`; - -exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` -{ - "content": [ - { - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "content": [ - { - "text": "Paragraph 1", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "content": [ - { - "text": "Paragraph 2", - "type": "text", - }, - ], - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - ], - "type": "columnList", - }, - ], - "type": "blockGroup", - }, - ], - "type": "doc", -} -`; - -exports[`Test removeEmptyColumns > Two empty columns 1`] = ` -{ - "content": [ - { - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - { - "attrs": { - "id": null, - "width": 1, - }, - "content": [ - { - "attrs": { - "id": null, - }, - "content": [ - { - "attrs": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "type": "blockContainer", - }, - ], - "type": "column", - }, - ], - "type": "columnList", - }, - ], - "type": "blockGroup", - }, - ], - "type": "doc", -} -`; diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixLayoutBlock.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixLayoutBlock.test.ts.snap new file mode 100644 index 0000000000..2a41da1e60 --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixLayoutBlock.test.ts.snap @@ -0,0 +1,183 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Test fixLayoutBlock drops emptied columns > First of two columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test fixLayoutBlock drops emptied columns > Last of two columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test fixLayoutBlock drops emptied columns > Start and end columns empty 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 1", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + { + "attrs": { + "id": null, + "width": 1, + }, + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Paragraph 2", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "column", + }, + ], + "type": "columnList", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; + +exports[`Test fixLayoutBlock drops emptied columns > Two empty columns 1`] = ` +{ + "content": [ + { + "content": [ + { + "attrs": { + "id": null, + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "doc", +} +`; diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts deleted file mode 100644 index b5bd190c6d..0000000000 --- a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { setupTestEnv } from "../../setupTestEnv.js"; -import { - fixColumnList, - isEmptyColumn, - removeEmptyColumns, -} from "@blocknote/core"; - -const getEditor = setupTestEnv(); - -describe("Test isEmptyColumn", () => { - it("Empty blocks", () => { - const schema = getEditor()._tiptapEditor.schema; - - const column = schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]); - - expect(isEmptyColumn(column)).toBeTruthy(); - }); - - it("Multiple blocks", () => { - const schema = getEditor()._tiptapEditor.schema; - - const column = schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined), - ]), - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]); - - expect(isEmptyColumn(column)).toBeFalsy(); - }); - - it("Block with children", () => { - const schema = getEditor()._tiptapEditor.schema; - - const column = schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined), - schema.nodes["blockGroup"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - ]), - ]); - - expect(isEmptyColumn(column)).toBeFalsy(); - }); - - it("Block with text", () => { - const schema = getEditor()._tiptapEditor.schema; - - const column = schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined, [ - schema.text("Paragraph 1"), - ]), - ]), - ]); - - expect(isEmptyColumn(column)).toBeFalsy(); - }); - - it("Non-text block", () => { - const schema = getEditor()._tiptapEditor.schema; - - const column = schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["image"].create(), - ]), - ]); - - expect(isEmptyColumn(column)).toBeFalsy(); - }); -}); - -describe("Test removeEmptyColumns", () => { - it("Start and end columns empty", () => { - const editor = getEditor(); - const schema = editor._tiptapEditor.schema; - - const columnList = schema.nodes["columnList"].create(undefined, [ - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined, [ - schema.text("Paragraph 1"), - ]), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined, [ - schema.text("Paragraph 2"), - ]), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - ]); - - const tr = editor.prosemirrorState.tr; - - tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); - - expect(tr.doc).toMatchSnapshot(); - }); - - it("First of two columns empty", () => { - const editor = getEditor(); - const schema = editor._tiptapEditor.schema; - - const columnList = schema.nodes["columnList"].create(undefined, [ - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined, [ - schema.text("Paragraph 1"), - ]), - ]), - ]), - ]); - - const tr = editor.prosemirrorState.tr; - - tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); - - expect(tr.doc).toMatchSnapshot(); - }); - - it("Last of two columns empty", () => { - const editor = getEditor(); - const schema = editor._tiptapEditor.schema; - - const columnList = schema.nodes["columnList"].create(undefined, [ - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined, [ - schema.text("Paragraph 1"), - ]), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - ]); - - const tr = editor.prosemirrorState.tr; - - tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); - - expect(tr.doc).toMatchSnapshot(); - }); - - it("Two empty columns", () => { - const editor = getEditor(); - const schema = editor._tiptapEditor.schema; - - const columnList = schema.nodes["columnList"].create(undefined, [ - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - ]); - - const tr = editor.prosemirrorState.tr; - - tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); - - expect(tr.doc).toMatchSnapshot(); - }); -}); - -describe("Test fixColumnList", () => { - it("First of two columns empty", () => { - const editor = getEditor(); - const schema = editor._tiptapEditor.schema; - - const columnList = schema.nodes["columnList"].create(undefined, [ - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined, [ - schema.text("Paragraph 1"), - ]), - ]), - ]), - ]); - - const tr = editor.prosemirrorState.tr; - - tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); - - expect(tr.doc).toMatchSnapshot(); - }); - - it("Last of two columns empty", () => { - const editor = getEditor(); - const schema = editor._tiptapEditor.schema; - - const columnList = schema.nodes["columnList"].create(undefined, [ - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(undefined, [ - schema.text("Paragraph 1"), - ]), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - ]); - - const tr = editor.prosemirrorState.tr; - - tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); - - expect(tr.doc).toMatchSnapshot(); - }); - - it("Two empty columns", () => { - const editor = getEditor(); - const schema = editor._tiptapEditor.schema; - - const columnList = schema.nodes["columnList"].create(undefined, [ - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - schema.nodes["column"].create(undefined, [ - schema.nodes["blockContainer"].create(undefined, [ - schema.nodes["paragraph"].create(), - ]), - ]), - ]); - - const tr = editor.prosemirrorState.tr; - - tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); - - expect(tr.doc).toMatchSnapshot(); - }); -}); diff --git a/packages/xl-multi-column/src/test/commands/util/fixLayoutBlock.test.ts b/packages/xl-multi-column/src/test/commands/util/fixLayoutBlock.test.ts new file mode 100644 index 0000000000..c96f45d630 --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/util/fixLayoutBlock.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { setupTestEnv } from "../../setupTestEnv.js"; +import { fixLayoutBlock, isEmptyLayoutChild } from "@blocknote/core"; + +const getEditor = setupTestEnv(); + +describe("Test isEmptyLayoutChild", () => { + it("Empty blocks", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]); + + expect(isEmptyLayoutChild(column)).toBeTruthy(); + }); + + it("Multiple blocks", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined), + ]), + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]); + + expect(isEmptyLayoutChild(column)).toBeFalsy(); + }); + + it("Block with children", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined), + schema.nodes["blockGroup"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(), + ]), + ]), + ]), + ]); + + expect(isEmptyLayoutChild(column)).toBeFalsy(); + }); + + it("Block with text", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create(undefined, [ + schema.text("Paragraph 1"), + ]), + ]), + ]); + + expect(isEmptyLayoutChild(column)).toBeFalsy(); + }); + + it("Non-text block", () => { + const schema = getEditor()._tiptapEditor.schema; + + const column = schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["image"].create(), + ]), + ]); + + expect(isEmptyLayoutChild(column)).toBeFalsy(); + }); +}); + +describe("Test fixLayoutBlock drops emptied columns", () => { + it.each<[string, string[]]>([ + ["Start and end columns empty", ["", "Paragraph 1", "Paragraph 2", ""]], + ["First of two columns empty", ["", "Paragraph 1"]], + ["Last of two columns empty", ["Paragraph 1", ""]], + ["Two empty columns", ["", ""]], + ])("%s", (_name, texts) => { + const editor = getEditor(); + const schema = editor._tiptapEditor.schema; + const columnList = schema.nodes["columnList"].create( + undefined, + texts.map((text) => + schema.nodes["column"].create(undefined, [ + schema.nodes["blockContainer"].create(undefined, [ + schema.nodes["paragraph"].create( + undefined, + text ? schema.text(text) : undefined, + ), + ]), + ]), + ), + ); + const tr = editor.prosemirrorState.tr; + tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); + fixLayoutBlock(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..ddb7b97e07 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..44030ea891 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/columnDrop.browser.test.ts b/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts new file mode 100644 index 0000000000..792482b6b4 --- /dev/null +++ b/packages/xl-multi-column/src/test/extensions/columnDrop.browser.test.ts @@ -0,0 +1,143 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + getNodeById, + type SideMenuExtension, +} from "@blocknote/core"; +import "@blocknote/core/style.css"; +import { TextSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { withMultiColumn } from "../../blocks/schema.js"; +import { multiColumnDropCursor } from "../../extensions/DropCursor/multiColumnDropCursor.js"; + +// Drags through the side menu's drag API with synthetic drag events, as in +// core's `toggleBlocks.browser.test.ts`: an emulated mouse drag does not reach +// every target reliably. + +const schema = withMultiColumn(BlockNoteSchema.create()); + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +let root: HTMLElement; + +function mount() { + root = document.createElement("div"); + document.body.appendChild(root); + editor = BlockNoteEditor.create({ + schema, + dropCursor: multiColumnDropCursor, + initialContent: [ + { id: "before", type: "paragraph", content: "Before" }, + { + id: "list", + type: "columnList", + children: [ + { + id: "col-a", + type: "column", + children: [{ id: "a", type: "paragraph", content: "A" }], + }, + { + id: "col-b", + type: "column", + children: [{ id: "b", type: "paragraph", content: "B" }], + }, + { + id: "col-c", + type: "column", + children: [{ id: "c", type: "paragraph", content: "C" }], + }, + ], + }, + { id: "after", type: "paragraph", content: "After" }, + ], + }); + editor.mount(root); +} + +afterEach(() => { + sideMenu().blockDragEnd(); + editor._tiptapEditor.destroy(); + root.remove(); +}); + +function sideMenu() { + return editor.getExtension("sideMenu")!; +} + +function columnElement(id: string): HTMLElement { + const element = root.querySelector( + `[data-node-type="column"][data-id="${id}"]`, + ); + if (!element) { + throw new Error(`Column "${id}" is not rendered`); + } + return element; +} + +/** + * Starts a drag of the block as its drag handle would: the handle's + * `dragstart` goes through the side menu's `blockDragStart`, and then bubbles + * up to the document, where every editor's side menu handles it. It starts + * from the editor's container element, where the handle lives: `UniqueID` keeps the + * ids of blocks dragged from there, and regenerates them for drops from + * elsewhere. + */ +function startBlockDrag(id: string) { + const dataTransfer = new DataTransfer(); + sideMenu().blockDragStart({ dataTransfer, clientY: 0 }, editor.getBlock(id)!); + editor.prosemirrorView.dom.parentElement!.dispatchEvent( + new DragEvent("dragstart", { bubbles: true, dataTransfer }), + ); + return dataTransfer; +} + +/** Drops the drag at the point: on the element there, as the browser does. */ +function dropAt(dataTransfer: DataTransfer, clientX: number, clientY: number) { + const init: DragEventInit = { + bubbles: true, + cancelable: true, + clientX, + clientY, + dataTransfer, + }; + const element = document.elementFromPoint(clientX, clientY)!; + element.dispatchEvent(new DragEvent("dragenter", init)); + element.dispatchEvent(new DragEvent("dragover", init)); + document + .elementFromPoint(clientX, clientY)! + .dispatchEvent(new DragEvent("drop", init)); +} + +describe("dragging a block selection that spans the column list", () => { + // The sequence behind a flaky failure of the multi-column drop tests in CI: + // the selection was left in the paragraph above the column list and + // extended into a column, so the drag took the paragraph and the whole + // list. Dropping on a column's edge removed the list before updating it, + // which threw `Block with ID 1 not found` out of the drop handler. + it("drops it as usual, without throwing", () => { + mount(); + const doc = editor.prosemirrorState.doc; + // From inside the paragraph above the list to inside a column's block. + const anchor = getNodeById("before", doc)!.posBeforeNode + 2; + const head = getNodeById("a", doc)!.posBeforeNode + 2; + editor.transact((tr) => + tr.setSelection(TextSelection.create(tr.doc, anchor, head)), + ); + const before = editor.document; + + const dataTransfer = startBlockDrag("a"); + // The paragraph and the whole column list. + expect(editor.prosemirrorView.dragging!.slice.content.childCount).toBe(2); + + const rect = columnElement("col-c").getBoundingClientRect(); + dropAt(dataTransfer, rect.right - 1, rect.top + rect.height / 2); + + expect(editor.document).toEqual(before); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); +}); diff --git a/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts new file mode 100644 index 0000000000..94c696f512 --- /dev/null +++ b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts @@ -0,0 +1,119 @@ +import { getNodeById } from "@blocknote/core"; +import { Fragment, Slice } from "prosemirror-model"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createMultiColumnHandleDropPlugin } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js"; +import { detectEdgePosition } from "../../extensions/DropCursor/multiColumnDropCursor.js"; +import { setupTestEnv } from "../setupTestEnv.js"; + +vi.mock("../../extensions/DropCursor/multiColumnDropCursor.js", () => ({ + detectEdgePosition: vi.fn(), +})); + +const getEditor = setupTestEnv(); + +/** Drops the blocks on an edge of `target`, as the drop handler sees it. */ +function dropOnEdge(ids: string[], target: string, position: "left" | "right") { + const editor = getEditor(); + const view = editor.prosemirrorView; + const column = getNodeById(target, view.state.doc)!; + vi.mocked(detectEdgePosition).mockReturnValue({ ...column, position }); + const nodes = ids.map((id) => getNodeById(id, view.state.doc)!.node); + const plugin = createMultiColumnHandleDropPlugin(editor); + return plugin.props.handleDrop!.call( + plugin, + view, + Object.assign(new MouseEvent("drop"), { dataTransfer: null }), + new Slice(Fragment.from(nodes), 0, 0), + true, + ); +} + +describe("column edge drops", () => { + it.each(["left", "right"] as const)( + "moves a mixed selection to an emptied target's %s edge", + (edge) => { + const editor = getEditor(); + editor.insertBlocks( + [ + { + id: "column-last", + type: "column", + children: [{ id: "last", type: "paragraph", content: "Last" }], + }, + ], + "column-1", + "after", + ); + const dragged = [ + "column-paragraph-1", + "column-paragraph-2", + "column-paragraph-3", + ]; + expect(dropOnEdge(dragged, "column-1", edge)).toBe(true); + expect( + editor + .getBlock("column-list-0")! + .children.map((column) => column.children.map((block) => block.id)), + ).toEqual([["column-paragraph-0"], dragged, ["last"]]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }, + ); + + it("preserves the column when dropping only its own children", () => { + const editor = getEditor(); + const before = editor.document; + dropOnEdge( + ["column-paragraph-0", "column-paragraph-1"], + "column-0", + "right", + ); + expect(editor.document).toEqual(before); + }); + + it("unwraps the layout when all its blocks are dropped together", () => { + const editor = getEditor(); + const dragged = [ + "column-paragraph-0", + "column-paragraph-1", + "column-paragraph-2", + "column-paragraph-3", + ]; + dropOnEdge(dragged, "column-0", "left"); + expect(editor.getBlock("column-list-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "paragraph-0", + "paragraph-1", + ...dragged, + "paragraph-2", + ]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); +}); + +// Dropping blocks onto themselves does nothing, like dropping a block next to +// itself. Removing the dragged blocks from where they are used to remove the +// drop target with them, and the handler then threw when it updated the +// target (`Block with ID ... not found`). +describe("drops whose target the dragged blocks hold", () => { + it("does nothing when the dragged blocks hold the target's column list", () => { + const editor = getEditor(); + const before = editor.document; + // A block selection that spans the paragraph above the column list and + // the whole list (e.g. extended with Shift+Arrow from the paragraph into + // a column), dragged by one of the list's blocks. + expect( + dropOnEdge(["paragraph-1", "column-list-0"], "column-0", "right"), + ).toBe(true); + expect(editor.document).toEqual(before); + }); + + it("does nothing when a dragged block holds the target", () => { + const editor = getEditor(); + const before = editor.document; + expect(dropOnEdge(["paragraph-0"], "nested-paragraph-0", "left")).toBe( + true, + ); + expect(editor.document).toEqual(before); + }); +}); 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..c8cf8e909b --- /dev/null +++ b/packages/xl-multi-column/src/test/extensions/columnResize.test.ts @@ -0,0 +1,176 @@ +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"); + }); +}); + +describe.each(["hover-column", "resize"] as const)( + "%s pair validation", + (mode) => { + function startPair() { + hoverColumnBoundary(); + if (mode === "resize") { + const view = getEditor().prosemirrorView; + const state = columnResizePluginKey.getState(view.state); + if (state?.type !== "hover-column") { + throw new Error("Expected a hovered column pair"); + } + const resize: ColumnState = { + ...state, + type: "resize", + startPos: 0, + leftColumn: { ...state.leftColumn, widthPx: 100, widthPercent: 1 }, + rightColumn: { ...state.rightColumn, widthPx: 100, widthPercent: 1 }, + }; + view.dispatch(view.state.tr.setMeta(columnResizePluginKey, resize)); + } + } + + it.each(["reorder", "separate", "reparent"] as const)( + "clears the pair after %s", + (change) => { + const editor = getEditor(); + startPair(); + const columns = editor.getBlock("column-list-0")!.children; + const extra = { + id: "extra-column", + type: "column" as const, + children: [{ type: "paragraph" as const, content: "Extra" }], + }; + if (change === "reorder") { + editor.updateBlock("column-list-0", { + children: [columns[1], columns[0]], + }); + } else if (change === "separate") { + editor.updateBlock("column-list-0", { + children: [columns[0], extra, columns[1]], + }); + } else { + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "column-list-0", + children: [columns[0], extra], + }, + { + type: "columnList", + id: "other-list", + children: [ + columns[1], + { type: "column", children: [{ type: "paragraph" }] }, + ], + }, + ]); + } + expect(columnResizePluginKey.getState(editor.prosemirrorState)).toEqual( + { type: "default" }, + ); + }, + ); + + it("keeps an adjacent pair after an unrelated edit", () => { + const editor = getEditor(); + startPair(); + editor.updateBlock("paragraph-1", { content: "Changed" }); + expect( + columnResizePluginKey.getState(editor.prosemirrorState)?.type, + ).toBe(mode); + }); + }, +); diff --git a/packages/xl-multi-column/vite.config.ts b/packages/xl-multi-column/vite.config.ts index eece7cb1a4..835016604a 100644 --- a/packages/xl-multi-column/vite.config.ts +++ b/packages/xl-multi-column/vite.config.ts @@ -1,6 +1,6 @@ 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 { buildCacheInputs } from "../../scripts/build-cache-inputs.js"; // import eslintPlugin from "vite-plugin-eslint"; @@ -26,6 +26,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: [webpackStats()], resolve: { diff --git a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx index 13c5c4a269..9c07706e26 100644 --- a/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx +++ b/packages/xl-odt-exporter/src/odt/defaultSchema/blocks.tsx @@ -354,67 +354,77 @@ export const odtBlockMappingForDefaultSchema: BlockMapping< return ; }, - column: (_block, exporter, _nestingLevel, _numberedListIndex, children) => { - const ex = exporter as ODTExporter; - const style = ex.registerStyle((name) => ( - - - - )); + column: { + withChildren: ( + _block, + exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => { + const ex = exporter as ODTExporter; + const style = ex.registerStyle((name) => ( + + + + )); - return ( - {children} - ); + return ( + {children} + ); + }, }, - columnList: ( - block, - exporter, - _nestingLevel, - _numberedListIndex, - children, - ) => { - const blockWithChildren = block as BlockFromConfig< - { - type: "columnList"; - content: "none"; - propSchema: Record; - }, - any, - any - >; - const ex = exporter as ODTExporter; - const style = ex.registerStyle((name) => ( - - - - )); - - return ( - - {(blockWithChildren.children || []).map((column, index) => { - const style = ex.registerStyle((name) => ( - - - - )); + columnList: { + withChildren: ( + block, + exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => { + const blockWithChildren = block as BlockFromConfig< + { + type: "columnList"; + content: "none"; + propSchema: Record; + }, + any, + any + >; + const ex = exporter as ODTExporter; + const style = ex.registerStyle((name) => ( + + + + )); - return ; - })} - {children} - - ); + return ( + + {(blockWithChildren.children || []).map((column, index) => { + const style = ex.registerStyle((name) => ( + + + + )); + + return ; + })} + {children} + + ); + }, }, image: async (block, exporter) => { diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index fc72dc1be0..4222b3a6ba 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -59,7 +59,7 @@ export class ODTExporter< public readonly options: ExporterOptions; constructor( - protected readonly schema: BlockNoteSchema, + schema: BlockNoteSchema, mappings: Exporter< NoInfer, NoInfer, @@ -155,32 +155,23 @@ export class ODTExporter< numberedListIndex = 0; } - if (["columnList", "column"].includes(block.type)) { - const children = await this.transformBlocks(block.children, 0); - const content = await this.mapBlock( - block as any, - 0, - numberedListIndex, - children, - ); - - ret.push(content); - } else { - const children = await this.transformBlocks( - block.children, - nestingLevel + 1, - ); - const content = await this.mapBlock( - block as any, - nestingLevel, + const placesChildren = this.placesChildren(block); + // Mappings that place their children own the layout: table cells start a fresh + // indentation context instead of inheriting literal s. + const children = await this.transformBlocks( + block.children, + placesChildren ? 0 : nestingLevel + 1, + ); + ret.push( + await this.mapBlock( + block, + placesChildren ? 0 : nestingLevel, numberedListIndex, children, - ); - - ret.push(content); - if (children.length > 0) { - ret.push(...children); - } + ), + ); + if (!placesChildren) { + ret.push(...children); } } diff --git a/packages/xl-pdf-exporter/src/react-pdf/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/react-pdf/defaultSchema/blocks.tsx index 44ff2b73e0..31ea106117 100644 --- a/packages/xl-pdf-exporter/src/react-pdf/defaultSchema/blocks.tsx +++ b/packages/xl-pdf-exporter/src/react-pdf/defaultSchema/blocks.tsx @@ -172,27 +172,37 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< /> ); }, - column: (block, _exporter, _nestingLevel, _numberedListIndex, children) => { - return {children}; + column: { + withChildren: ( + block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => { + return {children}; + }, }, - columnList: ( - _block, - _exporter, - _nestingLevel, - _numberedListIndex, - children, - ) => { - return ( - - {children} - - ); + columnList: { + withChildren: ( + _block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => { + return ( + + {children} + + ); + }, }, audio: (block, exporter) => { return ( diff --git a/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx index f6242c0bb7..9584dc57e1 100644 --- a/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.tsx @@ -106,7 +106,7 @@ export class PDFExporter< /** * The schema of your editor. The mappings are automatically typed checked against this schema. */ - protected readonly schema: BlockNoteSchema, + schema: BlockNoteSchema, /** * The mappings that map the BlockNote schema to the react-pdf content. * @@ -178,13 +178,13 @@ export class PDFExporter< } const children = await this.transformBlocks(b.children, nestingLevel + 1); const self = await this.mapBlock( - b as any, + b, nestingLevel, numberedListIndex, children, - ); // TODO: any + ); - if (["pageBreak", "columnList", "column"].includes(b.type)) { + if (b.type === "pageBreak" || this.placesChildren(b)) { ret.push(self); continue; } diff --git a/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ b/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ index 7beb8b3135..51eeea8aab 100644 --- a/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ +++ b/packages/xl-typst-exporter/src/__snapshots__/testDocument.typ @@ -169,19 +169,20 @@ #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#strong("Some inline code: ")#raw("var foo = 'bar';")] -#grid( - columns: (0.8fr, 1.4fr, 0.8fr), - column-gutter: 1em, - [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"This paragraph is in a column!"]], - [#block(width: 100%, inset: (top: (8pt + 6.9pt), bottom: 6.9pt))[#heading(level: 1, outlined: true)[#"So is this heading!"]]], - [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"You can have multiple blocks in a column too"] +#{ + let cols = ( + (width: 0.8, body: [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"This paragraph is in a column!"]]), + (width: 1.4, body: [#block(width: 100%, inset: (top: (8pt + 6.9pt), bottom: 6.9pt))[#heading(level: 1, outlined: true)[#"So is this heading!"]]]), + (width: 0.8, body: [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"You can have multiple blocks in a column too"] #list( [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Block 1"]], [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Block 2"]], [#block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#"Block 3"]] -)] -) +)]), + ) + grid(columns: cols.map(c => c.width * 1fr), column-gutter: 1em, ..cols.map(c => c.body)) +} #block(width: 100%, inset: (top: 6.9pt, bottom: 6.9pt))[#line(length: 100%, stroke: 1pt + rgb("#7D797A"))] diff --git a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts index daeb3733bf..2361b1130d 100644 --- a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts +++ b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts @@ -159,11 +159,46 @@ export const typstBlockMappingForDefaultSchema: BlockMapping< divider: () => `#line(length: 100%, stroke: 1pt + rgb("#7D797A"))`, pageBreak: () => `#pagebreak(weak: true)`, - // Multi-column layout is assembled by TypstExporter.transformBlocks (columns - // become grid cells). These entries exist only to satisfy the BlockMapping - // type — they are never invoked. - column: () => "", - columnList: () => "", + // A column only ever exists as a columnList's child (its config is + // `placeable: "namedOnly"`), so rather than content it returns the + // Typst *value* its parent needs: the width and the cell body. Typst takes + // a grid's track sizes on the grid, not on the cell, so the width has to + // reach the parent - the same reason the DOCX mapping hands its columnList + // a width-carrying table cell. + column: { + withChildren: ( + block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => + `(width: ${block.props.width ?? 1}, body: [${(children ?? []).join("\n\n")}])`, + }, + + // Lays the columns out side-by-side as a Typst `grid`, assembled in Typst + // code from the columns' (width, body) pairs so the fractional (`fr`) + // tracks keep their relative sizes. `grid` is a layout primitive (not a + // `table`), so it isn't tagged as a data table in the PDF. The trailing + // comma matters: `(x)` in Typst is a parenthesized value, not a one-element + // array. + columnList: { + withChildren: ( + _block, + _exporter, + _nestingLevel, + _numberedListIndex, + children, + ) => + [ + `#{`, + ` let cols = (`, + ...(children ?? []).map((c) => ` ${c},`), + ` )`, + ` grid(columns: cols.map(c => c.width * 1fr), column-gutter: 1em, ..cols.map(c => c.body))`, + `}`, + ].join("\n"), + }, // --- media -> Figure + Alt -------------------------------------------------- image: (block, exporter) => diff --git a/packages/xl-typst-exporter/src/typstExporter.ts b/packages/xl-typst-exporter/src/typstExporter.ts index 1c8f6af33c..59b4a2995a 100644 --- a/packages/xl-typst-exporter/src/typstExporter.ts +++ b/packages/xl-typst-exporter/src/typstExporter.ts @@ -253,23 +253,15 @@ export class TypstExporter< continue; } - // A columnList lays its column children out side-by-side. transformBlocks - // owns this (rather than the block mapping) because the columns must - // become grid cells, not the generic indented-children wrapper. - if (b.type === "columnList") { - out.push(await this.renderColumnList(b, nestingLevel)); - i++; - continue; - } - + const placesChildren = this.placesChildren(b); const children = await this.transformBlocks(b.children, nestingLevel + 1); - const self = (await this.mapBlock( - b as any, + const self = await this.mapBlock( + b, nestingLevel, 0, - [], - )) as string; - out.push(this.wrapBlock(b, self, children)); + placesChildren ? children : [], + ); + out.push(placesChildren ? self : this.wrapBlock(b, self, children)); i++; } return out; @@ -279,12 +271,7 @@ export class TypstExporter< block: Block, nestingLevel: number, ): Promise { - const body = (await this.mapBlock( - block as any, - nestingLevel, - 0, - [], - )) as string; + const body = await this.mapBlock(block, nestingLevel, 0, []); const children = await this.transformBlocks( block.children, nestingLevel + 1, @@ -324,32 +311,6 @@ export class TypstExporter< ); } - /** - * Render a columnList as a Typst `grid`: each child column becomes a grid - * cell, its `width` prop mapped to a fractional (`fr`) track so relative - * column sizes are preserved. `grid` is a layout primitive (not a `table`), - * so it isn't tagged as a data table in the PDF. - */ - private async renderColumnList( - block: Block, - nestingLevel: number, - ): Promise { - const columns = block.children; - const tracks = columns - .map((c) => `${(c.props as { width?: number }).width ?? 1}fr`) - .join(", "); - const cells: string[] = []; - for (const col of columns) { - const inner = ( - await this.transformBlocks(col.children, nestingLevel) - ).join("\n\n"); - cells.push(`[${inner}]`); - } - return `#grid(\n columns: (${tracks}),\n column-gutter: 1em,\n ${cells.join( - ",\n ", - )}\n)`; - } - private wrapList( kind: "bullet" | "numbered" | "check", items: string[], diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 5b0aca0a61..a370a2f199 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1305,13 +1305,7 @@ export const examples = { playground: true, docs: true, author: "matthewlipski", - tags: [ - "Intermediate", - "Blocks", - "Custom Schemas", - "Suggestion Menus", - "Slash Menu", - ], + tags: ["Intermediate", "Blocks", "Custom Schemas"], dependencies: { "@mantine/core": "^9.0.2", "react-icons": "^5.5.0", @@ -1323,7 +1317,7 @@ export const examples = { slug: "custom-schema", }, readme: - 'In this example, we create a custom `Alert` block which is used to emphasize text.\n\n**Try it out:** Click the "!" icon to change the alert type!\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + 'In this example, we create a custom `Alert` block which is used to emphasize text.\n\n**Try it out:** Click the "!" icon to change the alert type!\n\n**Relevant Docs:**\n\n- [Alert Block with Full UX](/examples/custom-schema/alert-block-full-ux)\n- [Alert Block with Title and Body](/examples/custom-schema/alert-block-with-title-and-body)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', }, { projectSlug: "suggestion-menus-mentions", @@ -1430,25 +1424,7 @@ export const examples = { slug: "custom-schema", }, readme: - 'In this example, we create a custom `Alert` block which is used to emphasize text, same as in the [minimal `Alert` block example](/examples/custom-schema/alert-block). However, in this example, we also add a command to insert the block via the Slash Menu, and an entry in the Formatting Toolbar\'s Block Type Select to change the current block to an `Alert`.\n\n**Try it out:** Press the "/" key to open the Slash Menu and insert an `Alert` block! Or highlight text in a paragraph, then change the block type to an `Alert` using the Block Type Select in the Formatting Toolbar!\n\n**Relevant Docs:**\n\n- [Minimal Alert Block Example](/examples/custom-schema/alert-block)\n- [Changing Slash Menu Items](/docs/react/components/suggestion-menus)\n- [Changing Block Type Select Items](/docs/react/components/formatting-toolbar)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', - }, - { - projectSlug: "toggleable-blocks", - fullSlug: "custom-schema/toggleable-blocks", - pathFromRoot: "examples/06-custom-schema/06-toggleable-blocks", - config: { - playground: true, - docs: true, - author: "matthewlipski", - tags: ["Basic"], - }, - title: "Toggleable Custom Blocks", - group: { - pathFromRoot: "examples/06-custom-schema", - slug: "custom-schema", - }, - readme: - "This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done using the use the `ToggleWrapper` component from `@blocknote/react`.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Default Schema](/docs/features/blocks)", + 'In this example, we create a custom `Alert` block which is used to emphasize text, same as in the [minimal `Alert` block example](/examples/custom-schema/alert-block). However, in this example, we also add a command to insert the block via the Slash Menu, and an entry in the Formatting Toolbar\'s Block Type Select to change the current block to an `Alert`.\n\n**Try it out:** Press the "/" key to open the Slash Menu and insert an `Alert` block! Or highlight text in a paragraph, then change the block type to an `Alert` using the Block Type Select in the Formatting Toolbar!\n\n**Relevant Docs:**\n\n- [Minimal Alert Block Example](/examples/custom-schema/alert-block)\n- [Alert Block with Title and Body](/examples/custom-schema/alert-block-with-title-and-body)\n- [Changing Slash Menu Items](/docs/react/components/suggestion-menus)\n- [Changing Block Type Select Items](/docs/react/components/formatting-toolbar)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', }, { projectSlug: "configuring-blocks", @@ -1486,6 +1462,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: "layout-block", + fullSlug: "custom-schema/layout-block", + pathFromRoot: "examples/06-custom-schema/09-layout-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: "Layout Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we create a custom `Panel` block that holds other blocks as its body, such as a panel containing headings and paragraphs.\n\nThe block sets `layoutOnly: true` on `BlockConfig`, which makes it a layout block: it has no content of its own, only child blocks. It draws its box in `renderFrame`, and its child blocks go where `slotRef` is attached. At runtime, they are in `block.children`.\n\nWe also wire up a Slash Menu item to insert the panel.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\n- Insert a new panel via the Slash Menu (search "panel").\n\n**Relevant Docs:**\n\n- [Layout Blocks](/docs/features/custom-schemas/layout-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", @@ -1565,6 +1568,74 @@ 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: "alert-block-with-title-and-body", + fullSlug: "custom-schema/alert-block-with-title-and-body", + pathFromRoot: + "examples/06-custom-schema/13-alert-block-with-title-and-body", + config: { + playground: true, + docs: true, + author: "nickthesick", + tags: ["Intermediate", "Blocks", "Custom Schemas"], + dependencies: { + "@mantine/core": "^9.0.2", + "react-icons": "^5.5.0", + } as any, + }, + title: "Alert Block with Title and Body", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + "In this example, we extend the [`Alert` block](/examples/custom-schema/alert-block) with a title and a body: the alert's text is its title, and its child blocks are the body.\n\nThe block has `content: \"inline\"`, so the title is ordinary inline content: formatting, links, and multiplayer cursors all work. Its child blocks are in `block.children` at runtime. `render` draws the title row, the same as in the Alert example, and `renderFrame` draws the alert's box around the title and the body together. Its `experimental_keyboard` settings keep the body inside the alert: Enter in the title adds a first child block, Shift-Tab doesn't move child blocks out, and Enter in an empty last child block leaves the alert.\n\n**Try it out:**\n\n- Click the icon to change the alert type.\n- Press Enter at the end of the alert's title to add a block to its body.\n- Press Enter in an empty last block of the body to leave the alert.\n- Press Backspace at the start of the first block in the body to merge it back into the title.\n- Press \"/\" in the body and add a code block, heading, or list.\n\n**Relevant Docs:**\n\n- [Minimal Alert Block Example](/examples/custom-schema/alert-block)\n- [Alert Block with Full UX](/examples/custom-schema/alert-block-full-ux)\n- [Block Frames](/docs/features/custom-schemas/custom-blocks#block-frames)\n- [Layout Blocks and Block Frames](/docs/features/custom-schemas/layout-blocks#layout-blocks-and-block-frames)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)", + }, + { + projectSlug: "tabs-block", + fullSlug: "custom-schema/tabs-block", + pathFromRoot: "examples/06-custom-schema/14-tabs-block", + config: { + playground: true, + docs: true, + author: "yousefed", + tags: ["Intermediate", "Blocks", "Custom Schemas"], + dependencies: { + "react-icons": "^5.5.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + } as any, + }, + title: "Tabs Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + "A tab set built on the layout block API. `tabs` is a layout block whose\n`children` are restricted to `tab` panels, and `tab` is a `placeable:\n\"namedOnly\"` layout block, so a panel can only ever exist inside a tab set —\nthe schema enforces both. Both blocks are layout blocks (`layoutOnly: true`), so\nthey declare only `renderFrame`, and the child blocks go where `slotRef` is\nattached.\n\nEach panel holds any block. A panel's label is document content and lives in\nits props. Which panel is open is not: it belongs to each reader, so it is kept\noutside the document, in `localStorage` keyed by the tab set's id. Switching\ntabs is therefore not an undo step and is not sent to collaborators.\n\n**Try it out:** Click a tab to open it, click the open tab for its menu, and\ndrag a tab to reorder it.\n\n## What a tab set needs beyond the layout block API\n\n**Revealing the panel the caret lands in.** A hidden panel is still part of the\ndocument, so the editor will move content into it: Backspace at the start of\nthe block after a tab set pulls that block into the last panel, which may not\nbe the open one. `useRevealCaretPanel` opens whichever panel the caret ends up\nin, using `editor.onSelectionChange`, so every route in (Backspace, Delete,\narrow keys, drag and drop, paste) is covered at once.\n\n**Moving the caret when a tab is clicked.** An explicit switch takes the caret\nwith it when the caret was inside the set. Otherwise the reveal above would\nimmediately reopen the panel it was left in.\n\n**A menu instead of buttons.** Clicking the open tab opens a menu to rename,\nmove or delete it. It is built from `useComponentsContext()`, so it matches\nwhichever UI library the editor uses.\n\n**Drag to reorder.** Tabs are sortable with dnd-kit. A tab keeps its block id\nwhen it moves, so the reader's open tab follows it.\n\n**Empty tabs stay.** The tab set sets `children.dissolve` to `false`. A tab\nis content, not layout: an empty tab is still a tab, and when the last tab is\nremoved, the tab set stays with a new, empty tab. (A column list dissolves:\nan empty column is removed, and one column left is just its blocks.)\n\n**Relevant Docs:**\n\n- [Layout Blocks](/docs/features/custom-schemas/layout-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)", + }, + { + projectSlug: "stepper-block", + fullSlug: "custom-schema/stepper-block", + pathFromRoot: "examples/06-custom-schema/15-stepper-block", + config: { + playground: true, + docs: true, + author: "yousefed", + tags: ["Intermediate", "Blocks", "Custom Schemas"], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Stepper Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'Numbered steps built on the layout block API. `stepper` is a layout block\nrestricted to `step` children, and `step` is a `placeable: "namedOnly"`\nlayout block, so a step can only exist inside a stepper.\n\nEach step holds any blocks: headings, lists, code. The numbers are a CSS\ncounter, so they stay correct as steps are added, removed, reordered or\nundone. Nothing in the document has to be kept in sync.\n\n**Add step** below the last step adds an untitled one.\n\nBoth blocks are layout blocks (`layoutOnly: true`), so they declare only\n`renderFrame`, and the child blocks go where `slotRef` is attached. A step can\nonly be inside a stepper, so it gets no drag handle by default. The step sets\n`meta: { draggable: true }` to keep it, so the side menu can move or delete a\nstep.\n\n## Things worth knowing\n\n**Enter starts the next step.** By default, Enter on an empty block at the end\nof a step moves that block out below the whole stepper, so the keyboard alone\ncannot start a new step. `enterStartsNextStep` makes Enter work the way it does\nin a list:\n\n1. Enter at the end of a step adds a block to that step.\n2. Enter again, on the empty block, starts the next step and puts the caret\n in its title.\n3. Enter again, on the empty title, leaves the stepper. It leaves a paragraph\n behind, not the empty heading the step was created with.\n\nThe block\'s `experimental_keyboard` settings cannot insert a new step, so this\nis written as an extension, passed as the third argument of\n`createReactBlockSpec`.\n\n## Known limitations\n\n**The title is not enforced.** A step has no text of its own: its title is\nsimply its first child, a heading. The user can turn that heading into any\nother block, delete it, or put blocks above it. A real title needs a block\nwith its own text that may only appear inside a stepper, and the layout block API\ncannot express that yet: `placeable: "namedOnly"` requires a layout block (a\nblock without content), and `children.allow` can only name layout blocks.\n\n**Empty steps stay.** The stepper sets `children.dissolve` to `false`, so a\nstep that holds only an empty paragraph is not removed, and the stepper stays\nwhen its last step is removed.\n\n## What the layout block API gives you for free\n\n- Backspace at the start of a step\'s title first turns it into a paragraph.\n A second Backspace moves it to the end of the previous step, or above the\n stepper if it is in the first step.\n- A step stays when its blocks are removed. When the last step is removed,\n the stepper stays and gets a new step with an empty paragraph.\n- Shift-Tab unnests inside a step and stops at the step\'s edge.\n\n**Relevant Docs:**\n\n- [Layout Blocks](/docs/features/custom-schemas/layout-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)', + }, { projectSlug: "draggable-inline-content", fullSlug: "custom-schema/draggable-inline-content", @@ -1872,7 +1943,7 @@ export const examples = { dependencies: { "y-websocket": "^2.1.0", yjs: "^13.6.27", - lib0: "^0.2.99", + lib0: "^0.2.119", } as any, }, title: "Local Storage Versioning (yjs v13)", @@ -1896,7 +1967,7 @@ export const examples = { "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", "@y/y": "^14.0.0-rc.23", - lib0: "1.0.0-rc.22", + lib0: "^1.0.0-rc.34", } as any, }, title: "YHub Multi-Doc", @@ -1921,7 +1992,7 @@ export const examples = { "@y/protocols": "^1.0.6-rc.1", "@y/websocket": "^4.0.0-3", "@y/y": "^14.0.0-rc.23", - lib0: "1.0.0-rc.22", + lib0: "^1.0.0-rc.34", } as any, }, title: "YHub Versioning (@y/y v14)", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05a537504f..d552ccc323 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,15 @@ importers: '@blocknote/xl-typst-exporter': specifier: workspace:* version: link:../packages/xl-typst-exporter + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.5) '@floating-ui/react': specifier: ^0.27.18 version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -3386,7 +3395,7 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0) - examples/06-custom-schema/06-toggleable-blocks: + examples/06-custom-schema/07-configuring-blocks: dependencies: '@blocknote/ariakit': specifier: latest @@ -3429,7 +3438,7 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0) - examples/06-custom-schema/07-configuring-blocks: + examples/06-custom-schema/08-non-editable-block: dependencies: '@blocknote/ariakit': specifier: latest @@ -3472,7 +3481,7 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0) - examples/06-custom-schema/08-non-editable-block: + examples/06-custom-schema/09-layout-block: dependencies: '@blocknote/ariakit': specifier: latest @@ -3501,6 +3510,9 @@ importers: 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 @@ -3665,6 +3677,153 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/13-alert-block-with-title-and-body: + 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.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0) + + examples/06-custom-schema/14-tabs-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 + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.5) + '@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.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0) + + examples/06-custom-schema/15-stepper-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.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.9.5)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.51.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': @@ -7128,6 +7287,28 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} @@ -17637,6 +17818,31 @@ snapshots: '@date-fns/tz@1.4.1': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.5)': + dependencies: + react: 19.2.5 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.5) + '@dnd-kit/utilities': 3.2.2(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@dnd-kit/utilities': 3.2.2(react@19.2.5) + react: 19.2.5 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.5)': + dependencies: + react: 19.2.5 + tslib: 2.8.1 + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 diff --git a/shared/testDocument.ts b/shared/testDocument.ts index 4c368103f8..23e27f58b9 100644 --- a/shared/testDocument.ts +++ b/shared/testDocument.ts @@ -27,6 +27,13 @@ const testDocumentSchema = BlockNoteSchema.create({ // `ColumnBlock` / `ColumnListBlock`. Exported so xl-multi-column's tests can // guard against drift from the real specs (see `testDocumentSchema.test` in // that package). +// +// The columns are layout blocks: every exporter maps them with +// `{ withChildren }`, so the mapping places their children. The exporters' +// snapshot tests of this document check that each exporter gives the +// children to the mapping and does not also place them after the block. An +// exporter's custom blocks need no test of their own for this: the exporter +// handles every `{ withChildren }` mapping the same (`placesChildren`). export const columnBlockSchema: { column: { type: "column"; diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index d435842966..6f6cc456f6 100644 --- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx @@ -1,3 +1,11 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + createBlockSpec, +} from "@blocknote/core"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { createReactBlockSpec, useCreateBlockNote } from "@blocknote/react"; import TestingApp from "@examples/01-basic/testing/src/App"; import PdfFileApp from "@examples/06-custom-schema/04-pdf-file-block/src/App"; import { describe, expect, test } from "vite-plus/test"; @@ -16,9 +24,14 @@ import { insertHeading, insertParagraph } from "../../utils/copypaste.js"; import { compareDocToSnapshot, focusOnEditor, + sleep, waitForSelector, } from "../../utils/editor.js"; -import { dragAndDropBlock } from "../../utils/mouse.js"; +import { + dragAndDropBlock, + dropDraggedBlock, + startBlockDrag, +} from "../../utils/mouse.js"; import { executeSlashCommand } from "../../utils/slashmenu.js"; // `expect.element` is augmented against the bare `vitest` module, but vite-plus @@ -149,3 +162,209 @@ describe("Check Block Dragging Functionality", () => { }, ); }); + +// Dragging layout blocks by their drag handle: the drag preview shows the +// dragged block, and a drop places the dragged block itself, not a new copy +// of its parent around it. + +/** + * A layout block's frame: a label (chrome) above the slot. The drag handle of the + * layout block shows when the mouse is on its chrome. + */ +function labelledFrame(label: string) { + const dom = document.createElement("section"); + dom.style.border = "1px solid gray"; + dom.style.padding = "8px"; + const chrome = document.createElement("div"); + chrome.className = "label"; + chrome.contentEditable = "false"; + chrome.textContent = label; + const slot = document.createElement("div"); + dom.append(chrome, slot); + return { dom, slot }; +} + +// A React layout block: React wraps its frame in elements of its own. +const createReactPanel = createReactBlockSpec( + { type: "reactPanel", propSchema: {}, content: "none", layoutOnly: true }, + { + renderFrame: (props) => ( +
+
+ React panel +
+
+
+ ), + }, +); + +// A list of items. An item is placed only in a list (`namedOnly`), and it +// keeps its drag handle (`draggable`), like the steps of a stepper. +const Item = createBlockSpec( + { + type: "item", + propSchema: {}, + content: "none", + layoutOnly: true, + placeable: "namedOnly", + }, + { + meta: { draggable: true }, + renderFrame: () => labelledFrame("Item"), + }, +); + +const List = createBlockSpec( + { + type: "list", + propSchema: {}, + content: "none", + layoutOnly: true, + children: { allow: ["item"], min: 1 }, + }, + { + renderFrame: () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + reactPanel: createReactPanel(), + item: Item(), + list: List(), + }, +}); + +const item = (id: string, text: string) => ({ + id, + type: "item", + children: [{ type: "paragraph", content: text }], +}); + +const editors: BlockNoteEditor[] = []; + +function Editor(props: { initialContent: any[] }) { + const editor = useCreateBlockNote({ + schema, + initialContent: props.initialContent, + }); + editors.push(editor as any); + return ; +} + +async function renderEditors(...documents: any[][]) { + editors.length = 0; + await render( +
+ {documents.map((initialContent, index) => ( + + ))} +
, + ); + await waitForSelector(EDITOR_SELECTOR); + await sleep(100); +} + +/** The element of the block with the ID. */ +function block(id: string) { + return document.querySelector(`[data-id="${id}"]`)!; +} + +/** + * Where the mouse picks the block with the ID: the chrome of a layout block, or + * else the block's element. + */ +function handleOf(id: string) { + return block(id).querySelector(":scope > .label") ?? block(id); +} + +/** The IDs of the blocks. */ +function ids(blocks: any[]) { + return blocks.map((block) => block.id); +} + +/** The number of `list` blocks in a block tree. */ +function lists(blocks: any[]): number { + return blocks.reduce( + (count, block) => + count + (block.type === "list" ? 1 : 0) + lists(block.children), + 0, + ); +} + +// Playwright doesn't correctly simulate drag events in Firefox. +describe.skipIf(browserName === "firefox")("Dragging layout blocks", () => { + test.each([ + { name: "a React layout block", id: "reactPanel", text: "In React" }, + { + name: "a block in a React layout block", + id: "inReact", + text: "In React", + }, + ])("shows $name in the drag preview", async ({ id, text }) => { + await renderEditors([ + { + id: "reactPanel", + type: "reactPanel", + children: [{ id: "inReact", type: "paragraph", content: "In React" }], + }, + ]); + + await startBlockDrag(handleOf(id)); + try { + const preview = await waitForSelector(".bn-drag-preview"); + expect(preview.textContent).toContain(text); + const { width, height } = preview.getBoundingClientRect(); + expect(width).toBeGreaterThan(0); + expect(height).toBeGreaterThan(0); + } finally { + // Releases the mouse also when a check fails, for the next tests. + await dropDraggedBlock(block(id), true); + } + }); + + test("moves a namedOnly layout block in its list, without a copy of the list", async () => { + await renderEditors([ + { + id: "list", + type: "list", + children: [item("i1", "One"), item("i2", "Two"), item("i3", "Three")], + }, + ]); + + await dragAndDropBlock(handleOf("i1"), handleOf("i3"), true); + await sleep(100); + + // The item moved: the list holds the same items, and no new list. + expect(lists(editors[0].document)).toBe(1); + expect(ids(editors[0].document[0].children)).toEqual(["i2", "i1", "i3"]); + }); + + test("moves a namedOnly layout block into a list of another editor", async () => { + await renderEditors( + [ + { + id: "list", + type: "list", + children: [item("i1", "One"), item("i2", "Two")], + }, + ], + [{ id: "list2", type: "list", children: [item("j1", "Uno")] }], + ); + + await dragAndDropBlock(handleOf("i1"), handleOf("j1"), true); + await sleep(100); + + expect(ids(editors[0].document[0].children)).toEqual(["i2"]); + // The item moved into the other list, without a new list around it. + expect(lists(editors[1].document)).toBe(1); + expect(editors[1].document[0].children).toHaveLength(2); + expect( + editors[1].document[0].children.map((child: any) => child.type), + ).toEqual(["item", "item"]); + }); +}); diff --git a/tests/src/end-to-end/exporters/exporterTestUtil.tsx b/tests/src/end-to-end/exporters/exporterTestUtil.tsx index bd857d6ef9..3a4b165555 100644 --- a/tests/src/end-to-end/exporters/exporterTestUtil.tsx +++ b/tests/src/end-to-end/exporters/exporterTestUtil.tsx @@ -1,4 +1,5 @@ import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; +import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; import { expect } from "vite-plus/test"; import { screenshotFull } from "../../utils/screenshotFull.js"; @@ -38,8 +39,19 @@ export const invalidMathBlock = { children: [], } as any; +// Includes the multi-column blocks, which the shared test document contains. +// They have to be in the *schema*, not just the mappings: the exporters read +// the schema to tell a layout block from a regular one, and a layout block +// the exporter doesn't recognize gets its children appended after it instead +// of placed by its mapping. export function schema() { - return BlockNoteSchema.create({ blockSpecs: defaultBlockSpecs }); + return BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + column: ColumnBlock, + columnList: ColumnListBlock, + }, + }); } /** diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx index 77cd1cd21d..e647555520 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,49 @@ 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/ +// blockGeometry.browser.test.ts`. This tests the whole path, +// through a real column list. Columns have no drag handles themselves, but +// hovering their gutter must still resolve the child on the hovered row. +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/sidemenu/sideMenuHover.test.tsx b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx index 593f534818..6f6434e505 100644 --- a/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx +++ b/tests/src/end-to-end/sidemenu/sideMenuHover.test.tsx @@ -1,6 +1,7 @@ import { BlockNoteEditor, BlockNoteSchema, + createBlockSpec, type PartialBlock, defaultBlockSpecs, } from "@blocknote/core"; @@ -33,7 +34,22 @@ import { getRect, mouseSequence } from "../../utils/mouse.js"; // whether it shows at all. "works today" guards behavior that works on main; // "fixed issues" reproduces issues that fail on main. -const schema = withMultiColumn(BlockNoteSchema.create()); +// A draggable layout block with padding around its children, like a panel. +const Panel = createBlockSpec( + { type: "panel" as const, propSchema: {}, content: "none", layoutOnly: true }, + { + renderFrame: () => { + const dom = document.createElement("div"); + dom.style.padding = "16px"; + dom.style.border = "1px solid gray"; + return { dom, slot: dom }; + }, + }, +)(); + +const schema = withMultiColumn(BlockNoteSchema.create()).extend({ + blockSpecs: { panel: Panel }, +}); type Editor = BlockNoteEditor< typeof schema.blockSchema, typeof schema.inlineContentSchema, @@ -597,41 +613,56 @@ describe("side menu hover: engaging a block", () => { // The drag handle is the drag source: if the menu hid or moved during the // drag, React would remove the handle, its `dragend` would never fire, and the -// drag image would stay in the document. The drop changes the document, which -// re-resolves the block under the pointer while the drag is still on. +// drag image would stay in the document (it then broke the next toggle test). // Real mouse drags are only emulated reliably in Chromium. describe.skipIf(browserName !== "chromium")("side menu hover: dragging", () => { test("keeps the drag handle mounted until the drag ends", async () => { await render( , ); await waitForSelector(".bn-editor"); + const chevron = getRect(await waitForSelector(".bn-toggle-button")); + await mouseSequence([ + { + type: "click", + x: chevron.x + chevron.width / 2, + y: chevron.y + chevron.height / 2, + }, + ]); + await sleep(100); await hoverBlock("drag"); expect(menuBlock()).toBe("drag"); const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR)); - const target = blockRect("a"); + const target = getRect( + await waitForSelector(".bn-toggle-add-block-button"), + ); await walk([ { x: handle.x + handle.width / 2, y: handle.y + handle.height / 2 }, ]); await mouseSequence([{ type: "down" }]); await sleep(100); - // Onto the top of "a": the block goes before it. await mouseSequence([ - { type: "move", x: target.x + 20, y: target.y + 2, steps: 5 }, + { + type: "move", + x: target.x + target.width / 2, + y: target.y + target.height / 2, + steps: 5, + }, ]); await sleep(100); await mouseSequence([{ type: "up" }]); await sleep(100); - expect(editors.main.document.map((block) => block.id)).toEqual([ - "drag", - "a", - "b", - ]); + expect( + editors.main.getBlock("t")!.children.map((child) => child.id), + ).toEqual(["drag"]); expect(document.querySelector(".bn-drag-preview")).toBeNull(); }); @@ -889,6 +920,43 @@ describe("side menu hover: wide container padding", () => { }); }); +describe("side menu hover: layout blocks", () => { + const panelDocument: PartialBlock[] = [ + paragraph("before"), + { + id: "panel", + type: "panel", + children: [paragraph("p1", "First in the panel"), paragraph("p2")], + } as PartialBlock, + paragraph("after"), + ]; + + function panelRect() { + return document + .querySelector('[data-editor-name="main"] [data-node-type="panel"]')! + .getBoundingClientRect(); + } + + test("shows the layout block's own menu in its padding above its children", async () => { + await render(); + await waitForSelector(".bn-editor"); + + const panel = panelRect(); + await walk([{ x: panel.x + panel.width / 2, y: panel.top + 6 }]); + expect(menuBlock()).toBe("panel"); + }); + + test("shows a child's menu in the layout block's padding beside it", async () => { + await render(); + await waitForSelector(".bn-editor"); + + const panel = panelRect(); + const child = blockRect("p1"); + await walk([{ x: panel.left + 6, y: child.y + child.height / 2 }]); + expect(menuBlock()).toBe("p1"); + }); +}); + describe("side menu hover: elements over the editor", () => { // A modal (any opaque element) over part of the editor: the editor gets no // menu where it is covered, and its usual menu where it is not. diff --git a/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx new file mode 100644 index 0000000000..bc2ac6e0ad --- /dev/null +++ b/tests/src/end-to-end/toggleblocks/toggleblocks.test.tsx @@ -0,0 +1,266 @@ +import { + type BlockNoteEditor, + BlockNoteSchema, + type PartialBlock, +} from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; +import { + multiColumnDropCursor, + withMultiColumn, +} from "@blocknote/xl-multi-column"; +import { beforeEach, describe, expect, test } from "vite-plus/test"; +import { render } from "vitest-browser-react"; +import { + DRAG_HANDLE_ADD_SELECTOR, + DRAG_HANDLE_SELECTOR, + EDITOR_SELECTOR, +} from "../../utils/const.js"; +import { browserName, page, userEvent } from "../../utils/context.js"; +import { sleep, waitForSelector } from "../../utils/editor.js"; +import { + getRect, + mouseSequence, + moveMouseOverElement, +} from "../../utils/mouse.js"; + +// UI behaviour of the built-in toggle blocks (toggle heading and toggle list +// item) that needs the full editor view: drag and drop, the side menu and the +// placeholder. Keyboard and open-state behaviour is covered by +// `packages/core/src/blocks/ToggleWrapper/toggleBlocks.browser.test.ts`. +// Tests for bugs that are still open use `test.fails`: they state the +// expected behaviour, so they start failing - and must be switched to `test` +// - once the bug is fixed. + +const schema = withMultiColumn(BlockNoteSchema.create()); + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +function ToggleApp(props: { content: PartialBlock[]; width?: number }) { + editor = useCreateBlockNote({ + schema, + dropCursor: multiColumnDropCursor, + initialContent: props.content, + }); + + return ( +
+ +
+ ); +} + +const kinds = [ + { + name: "toggle heading", + toggle: (children: PartialBlock[] = []): PartialBlock => ({ + id: "t", + type: "heading", + props: { level: 2, isToggleable: true }, + content: "Toggle", + children, + }), + }, + { + name: "toggle list item", + toggle: (children: PartialBlock[] = []): PartialBlock => ({ + id: "t", + type: "toggleListItem", + content: "Toggle", + children, + }), + }, +]; + +beforeEach(() => { + // The open state of a toggle is kept in `localStorage`, keyed by block id. + localStorage.clear(); +}); + +async function openToggle() { + const block = document.querySelector(`.bn-block[data-id="t"]`)!; + const button = [...block.querySelectorAll(".bn-toggle-button")].find( + (element) => element.closest(".bn-block") === block, + ); + if (!button) { + throw new Error("The toggle has no chevron"); + } + await userEvent.click(button); +} + +const DROP_CURSOR_SELECTOR = '[class*="prosemirror-dropcursor"]'; + +/** An element of the toggle itself, not of a toggle nested in it. */ +async function ownElement(selector: string) { + const block = await waitForSelector(`.bn-block[data-id="t"]`); + const element = [...block.querySelectorAll(selector)].find( + (candidate) => candidate.closest(".bn-block") === block, + ); + if (!element) { + throw new Error(`The toggle has no ${selector}`); + } + return element; +} + +/** + * Drags block `drag` by its handle to the center of `target`, and holds it + * there. `mouseSequence([{ type: "up" }])` drops it. + */ +async function dragOnto(target: Element) { + // Not `getByText`: a previous drag leaves its drag image, a copy of the + // dragged block, in the document. + await moveMouseOverElement( + await waitForSelector(`.bn-block[data-id="drag"] .bn-inline-content`), + ); + const handle = getRect(await waitForSelector(DRAG_HANDLE_SELECTOR)); + const rect = getRect(target); + // The pauses let the browser start the drag, as in `dragAndDropBlock`. + await mouseSequence([ + { + type: "move", + x: handle.x + handle.width / 2, + y: handle.y + handle.height / 2, + steps: 5, + }, + ]); + await sleep(100); + await mouseSequence([{ type: "down" }]); + await sleep(100); + await mouseSequence([ + { + type: "move", + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + steps: 5, + }, + ]); +} + +async function dropCursorTop() { + return getRect(await waitForSelector(DROP_CURSOR_SELECTOR)).top; +} + +describe.each(kinds)("$name", ({ toggle }) => { + // BLO-956: the only way to drop a block into an empty toggle is onto the + // toggle itself, e.g. its "Add block" button. This test uses a real mouse + // drag, which Playwright only emulates reliably in Chromium. All drop + // targets are tested in every browser in `toggleBlocks.browser.test.ts`, + // with synthetic drag events. + describe.skipIf(browserName !== "chromium")("drop onto the toggle", () => { + test("onto 'Add block', into the empty toggle (BLO-956)", async () => { + await render( + , + ); + await waitForSelector(EDITOR_SELECTOR); + await openToggle(); + + await dragOnto(await ownElement(".bn-toggle-add-block-button")); + // The drop cursor shows the place of the children: below the title. + const title = getRect(await ownElement(".bn-block-content")); + await expect.poll(dropCursorTop).toBeGreaterThan(title.bottom - 6); + expect(await dropCursorTop()).toBeLessThan(title.bottom + 6); + + await mouseSequence([{ type: "up" }]); + + // The drop cursor does not stay after the drop. + await expect + .poll(() => document.querySelector(DROP_CURSOR_SELECTOR)) + .toBeNull(); + expect(editor.getBlock("t")!.children.map((child) => child.id)).toEqual([ + "drag", + ]); + }); + }); + + // BLO-1030: the side menu of a block in the left column of a column list + // inside a toggle was reported to disappear when the mouse moves onto it. + // This did not reproduce here (on `main` either), in any browser. The test + // keeps the behaviour the issue asks for. + test("keeps the side menu of a block in a column usable (BLO-1030)", async () => { + await render( + , + ); + await waitForSelector(EDITOR_SELECTOR); + await openToggle(); + + await moveMouseOverElement(page.getByText("Left").element()); + const add = getRect(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR)); + await mouseSequence([ + { + type: "move", + x: add.x + add.width / 2, + y: add.y + add.height / 2, + steps: 10, + }, + ]); + await userEvent.click(await waitForSelector(DRAG_HANDLE_ADD_SELECTOR)); + + const leftColumn = editor.getParentBlock("left")!; + expect(leftColumn.children).toHaveLength(2); + expect(leftColumn.children[0].id).toBe("left"); + }); + + // BLO-967: on a narrow screen, a placeholder that wraps must take up space, + // so it doesn't overlap the block below it. + test("gives a wrapping placeholder in its body its own space (BLO-967)", async () => { + await render( + , + ); + await waitForSelector(EDITOR_SELECTOR); + await openToggle(); + + // The default placeholder shows in the empty block that has the caret. + editor.setTextCursorPosition("empty"); + editor.focus(); + + const content = (id: string) => + getRect(`.bn-block[data-id="${id}"] > .bn-block-content`); + const oneLine = content("filled").height; + expect(content("empty").height).toBeGreaterThan(oneLine * 1.5); + expect(content("below").top).toBeGreaterThanOrEqual( + content("empty").bottom, + ); + }); +}); diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutChildToSiblingAfter.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutChildToSiblingAfter.html new file mode 100644 index 0000000000..6d7fd77628 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutChildToSiblingAfter.html @@ -0,0 +1,7 @@ +
+
+

Callout child 2

+
+ +
+

After callout

\ No newline at end of file diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutChildren.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutChildren.html new file mode 100644 index 0000000000..b106351d86 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutChildren.html @@ -0,0 +1,7 @@ +
+
+

Callout child 1

+

Callout child 2

+
+ +
\ No newline at end of file diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutNestedChild.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutNestedChild.html new file mode 100644 index 0000000000..65339aa967 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/layoutNestedChild.html @@ -0,0 +1 @@ +Inner child \ No newline at end of file diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutChildToSiblingAfter.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutChildToSiblingAfter.md new file mode 100644 index 0000000000..94e29d44e1 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutChildToSiblingAfter.md @@ -0,0 +1,3 @@ +Callout child 2 + +UI LABELAfter callout diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutChildren.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutChildren.md new file mode 100644 index 0000000000..8d71d6bc10 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutChildren.md @@ -0,0 +1,5 @@ +Callout child 1 + +Callout child 2 + +UI LABEL diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutNestedChild.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutNestedChild.md new file mode 100644 index 0000000000..72d309cf51 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/layoutNestedChild.md @@ -0,0 +1 @@ +Inner child diff --git a/tests/src/unit/core/clipboard/copy/copyTestInstances.ts b/tests/src/unit/core/clipboard/copy/copyTestInstances.ts index 4bd34489c0..db582521b5 100644 --- a/tests/src/unit/core/clipboard/copy/copyTestInstances.ts +++ b/tests/src/unit/core/clipboard/copy/copyTestInstances.ts @@ -722,6 +722,80 @@ export const copyTestInstancesHTML: TestInstance< }, executeTest: testCopyHTML, }, + { + // The whole of a layout block's children, selected from inside it. + testCase: { + name: "layoutChildren", + document: [ + { + type: "callout", + children: [ + { type: "paragraph", content: "Callout child 1" }, + { type: "paragraph", content: "Callout child 2" }, + ], + }, + ], + getCopySelection: (doc) => { + const startPos = getPosOfTextNode(doc, "Callout child 1"); + const endPos = getPosOfTextNode(doc, "Callout child 2", true); + + return TextSelection.create(doc, startPos, endPos); + }, + }, + executeTest: testCopyHTML, + }, + { + // A selection that leaves the layout block partway through, so the copied + // fragment is cut open on one side. + testCase: { + name: "layoutChildToSiblingAfter", + document: [ + { + type: "callout", + children: [ + { type: "paragraph", content: "Callout child 1" }, + { type: "paragraph", content: "Callout child 2" }, + ], + }, + { type: "paragraph", content: "After callout" }, + ], + getCopySelection: (doc) => { + const startPos = getPosOfTextNode(doc, "Callout child 2"); + const endPos = getPosOfTextNode(doc, "After callout", true); + + return TextSelection.create(doc, startPos, endPos); + }, + }, + executeTest: testCopyHTML, + }, + { + // A single block two layout blocks deep, so the fragment is cut open on both + // sides at two different levels. + testCase: { + name: "layoutNestedChild", + document: [ + { + type: "callout", + props: { flavor: "warning" }, + children: [ + { type: "paragraph", content: "Outer child" }, + { + type: "callout", + props: { flavor: "info" }, + children: [{ type: "paragraph", content: "Inner child" }], + }, + ], + }, + ], + getCopySelection: (doc) => { + const startPos = getPosOfTextNode(doc, "Inner child"); + const endPos = getPosOfTextNode(doc, "Inner child", true); + + return TextSelection.create(doc, startPos, endPos); + }, + }, + executeTest: testCopyHTML, + }, ]; // text/plain payloads — exercises the same selections as above but snapshots diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html index d751178338..b9dfbb8404 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/complex/misc.html @@ -1,6 +1,12 @@
-
+
-
+
-
-
-
- +
+ +
+

Toggle Heading

-
-
-
-
-
-
-

Child content

+
+
+
+
+

Child content

+
+
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/basic.html new file mode 100644 index 0000000000..4f27651cf2 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/basic.html @@ -0,0 +1,14 @@ +
+
+
+
+
+
+

Callout child

+
+
+
+
+ +
+
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/emptyChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/emptyChildren.html new file mode 100644 index 0000000000..9561cb622b --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/emptyChildren.html @@ -0,0 +1,6 @@ +
+
+
+ +
+
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/nested.html new file mode 100644 index 0000000000..3e798ab0f3 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/layoutBlock/nested.html @@ -0,0 +1,26 @@ +
+
+
+
+
+
+

Nested heading

+
+
+
+
+
+
+
+
+

Inner callout child

+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html index c58d0153ab..521c42d103 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/basic.html @@ -61,20 +61,26 @@
-
-
-
- +
+ +
+

Toggle List Item 1

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html index 389ff4c34b..8a8df53393 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/nested.html @@ -58,20 +58,26 @@
-
-
-
- +
+ +
+

Toggle List Item 1

diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html index 018c41520e..f486a6087a 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/lists/toggleWithChildren.html @@ -1,36 +1,42 @@
-
-
-
- +
+ +
+

Toggle List Item

-
-
-
-
-
-
-

Toggle Child 1

+
+
+
+
+

Toggle Child 1

+
+
-
-
-
-
-
-

Toggle Child 2

+
+
+
+

Toggle Child 2

+
+
@@ -39,34 +45,40 @@
-
-
-
- +
+ +
+

Toggle Heading

-
-
-
-
-
-
-

Heading Child 1

+
+
+
+
+

Heading Child 1

+
+
diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html index b0e77ba748..240de27aa7 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/paragraph/styled.html @@ -1,6 +1,12 @@
-
+
+
+
+
+ +
+
+
Heads up
+
+
+
+
+
+

First

+
+
+
+
+
+
+

Second

+
+
+
+
+
+
+
+
+
+
+
+
Title
+
+
+
+
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/basic.html new file mode 100644 index 0000000000..57c67815b6 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/basic.html @@ -0,0 +1,6 @@ +
+
+

Callout child

+
+ +
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/emptyChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/emptyChildren.html new file mode 100644 index 0000000000..f49412498c --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/emptyChildren.html @@ -0,0 +1,4 @@ +
+
+ +
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/nested.html new file mode 100644 index 0000000000..9695192716 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/layoutBlock/nested.html @@ -0,0 +1,12 @@ +
+
+

Nested heading

+
+
+

Inner callout child

+
+ +
+
+ +
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/titledBlock/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/titledBlock/basic.html new file mode 100644 index 0000000000..a4516c0d1b --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/titledBlock/basic.html @@ -0,0 +1,9 @@ +
+ +
+
Heads up
+

First

+

Second

+
+
+
Title
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/basic.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/basic.md new file mode 100644 index 0000000000..6ce86c1ffa --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/basic.md @@ -0,0 +1,3 @@ +Callout child + +UI LABEL diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/emptyChildren.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/emptyChildren.md new file mode 100644 index 0000000000..79f979cb57 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/emptyChildren.md @@ -0,0 +1 @@ +UI LABEL diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/nested.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/nested.md new file mode 100644 index 0000000000..f2a5876e69 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/layoutBlock/nested.md @@ -0,0 +1,5 @@ +# Nested heading + +Inner callout child + +UI LABELUI LABEL diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/titledBlock/basic.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/titledBlock/basic.md new file mode 100644 index 0000000000..4129a6dea1 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/titledBlock/basic.md @@ -0,0 +1,5 @@ +UI LABELHeads upFirst + +Second + +Title diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/basic.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/basic.json new file mode 100644 index 0000000000..cd4b7fa368 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/basic.json @@ -0,0 +1,33 @@ +[ + { + "attrs": { + "flavor": "tip", + "id": "1", + }, + "content": [ + { + "attrs": { + "id": "2", + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Callout child", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "callout", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/emptyChildren.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/emptyChildren.json new file mode 100644 index 0000000000..7b02725a5d --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/emptyChildren.json @@ -0,0 +1,27 @@ +[ + { + "attrs": { + "flavor": "tip", + "id": "1", + }, + "content": [ + { + "attrs": { + "id": "1", + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "callout", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/nested.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/nested.json new file mode 100644 index 0000000000..4a45ff2fa9 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/layoutBlock/nested.json @@ -0,0 +1,66 @@ +[ + { + "attrs": { + "flavor": "warning", + "id": "1", + }, + "content": [ + { + "attrs": { + "id": "2", + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "isToggleable": false, + "level": 1, + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Nested heading", + "type": "text", + }, + ], + "type": "heading", + }, + ], + "type": "blockContainer", + }, + { + "attrs": { + "flavor": "info", + "id": "3", + }, + "content": [ + { + "attrs": { + "id": "4", + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Inner callout child", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "callout", + }, + ], + "type": "callout", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/titledBlock/basic.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/titledBlock/basic.json new file mode 100644 index 0000000000..44e5e45d26 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/titledBlock/basic.json @@ -0,0 +1,85 @@ +[ + { + "attrs": { + "id": "1", + }, + "content": [ + { + "content": [ + { + "text": "Heads up", + "type": "text", + }, + ], + "type": "alert", + }, + { + "content": [ + { + "attrs": { + "id": "2", + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "First", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + { + "attrs": { + "id": "3", + }, + "content": [ + { + "attrs": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "content": [ + { + "text": "Second", + "type": "text", + }, + ], + "type": "paragraph", + }, + ], + "type": "blockContainer", + }, + ], + "type": "blockGroup", + }, + ], + "type": "blockContainer", + }, + { + "attrs": { + "id": "4", + }, + "content": [ + { + "content": [ + { + "text": "Title", + "type": "text", + }, + ], + "type": "alert", + }, + ], + "type": "blockContainer", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/exportTestInstances.ts b/tests/src/unit/core/formatConversion/export/exportTestInstances.ts index 1d901c81a9..a3e5600295 100644 --- a/tests/src/unit/core/formatConversion/export/exportTestInstances.ts +++ b/tests/src/unit/core/formatConversion/export/exportTestInstances.ts @@ -3107,6 +3107,91 @@ export const exportTestInstancesBlockNoteHTML: TestInstance< }, executeTest: testExportBlockNoteHTML, }, + { + testCase: { + name: "layoutBlock/basic", + content: [ + { + type: "callout", + children: [ + { + type: "paragraph", + content: "Callout child", + }, + ], + }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, + { + testCase: { + name: "layoutBlock/nested", + content: [ + { + type: "callout", + props: { flavor: "warning" }, + children: [ + { + type: "heading", + content: "Nested heading", + }, + { + type: "callout", + props: { flavor: "info" }, + children: [ + { + type: "paragraph", + content: "Inner callout child", + }, + ], + }, + ], + }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, + { + // Partial input omits the required child. Export snapshots record this + // raw form; full-HTML equality tests normalize it to a valid block first. + testCase: { + name: "layoutBlock/emptyChildren", + content: [ + { + type: "callout", + }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, + { + // A titled block: inline content (the title) plus a body of two + // paragraphs. The title is rendered once, by the block's own output; the + // children follow it inside the frame rather than being duplicated. + testCase: { + name: "titledBlock/basic", + content: [ + { + type: "alert", + content: "Heads up", + children: [ + { + type: "paragraph", + content: "First", + }, + { + type: "paragraph", + content: "Second", + }, + ], + }, + // Omitted children still reach the frame callback as an empty array. + { type: "alert", content: "Title" }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, ]; export const exportTestInstancesHTML: TestInstance< diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutBlock.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutBlock.json new file mode 100644 index 0000000000..9c1e864bd8 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutBlock.json @@ -0,0 +1,29 @@ +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Callout child", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "1", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutEmptyChildren.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutEmptyChildren.json new file mode 100644 index 0000000000..3333d9ac16 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutEmptyChildren.json @@ -0,0 +1,23 @@ +[ + { + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "1", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutExternalHTML.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutExternalHTML.json new file mode 100644 index 0000000000..7d0dc770cb --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutExternalHTML.json @@ -0,0 +1,58 @@ +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Nested heading", + "type": "text", + }, + ], + "id": "1", + "props": { + "backgroundColor": "default", + "isToggleable": false, + "level": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "heading", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Inner callout child", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "flavor": "info", + }, + "type": "callout", + }, + ], + "content": undefined, + "id": "1", + "props": { + "flavor": "warning", + }, + "type": "callout", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutNested.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutNested.json new file mode 100644 index 0000000000..f26c42c127 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/layoutNested.json @@ -0,0 +1,58 @@ +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Nested heading", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "isToggleable": false, + "level": 1, + "textAlignment": "left", + "textColor": "default", + }, + "type": "heading", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Inner callout child", + "type": "text", + }, + ], + "id": "4", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "3", + "props": { + "flavor": "info", + }, + "type": "callout", + }, + ], + "content": undefined, + "id": "1", + "props": { + "flavor": "warning", + }, + "type": "callout", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/__snapshots__/html/titledBlock.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/titledBlock.json new file mode 100644 index 0000000000..756e05dfa7 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/titledBlock.json @@ -0,0 +1,50 @@ +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "First", + "type": "text", + }, + ], + "id": "2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Second", + "type": "text", + }, + ], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Heads up", + "type": "text", + }, + ], + "id": "1", + "props": {}, + "type": "alert", + }, +] \ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts index 0eae5ff642..33b00531c5 100644 --- a/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts +++ b/tests/src/unit/core/formatConversion/parse/parseTestInstances.ts @@ -1119,6 +1119,125 @@ l'utilisateur (bouton bleu en haut à droite de la conversation) +
+
+
+
+
+

Callout child

+
+
+
+
+
+
`, + }, + executeTest: testParseHTML, + }, + { + // A layout block nested inside another, with a non-default prop on each. + testCase: { + name: "layoutNested", + content: `
+
+
+
+
+
+

Nested heading

+
+
+
+
+
+
+
+
+

Inner callout child

+
+
+
+
+
+
+
+
`, + }, + executeTest: testParseHTML, + }, + { + // An empty layout block children holder is filled to its schema minimum + // when parsed into a valid document. + testCase: { + name: "layoutEmptyChildren", + content: `
+
+
+
+
`, + }, + executeTest: testParseHTML, + }, + { + // Layout block HTML without the `data-frame-slot` marker our own + // serializers add: what a layout block looks like once it has been through + // another app, or has come from an older version. The parse rule has to + // fall back to reading the whole element as the children region. + testCase: { + name: "layoutExternalHTML", + content: `
+
+

Nested heading

+
+
+

Inner callout child

+
+
+
+
`, + }, + executeTest: testParseHTML, + }, + { + // The internal (`blocksToFullHTML`) form of a titled block: the title in + // the block's own content, the body in its nested `blockGroup`. Parses + // back to the alert with its title and both children, in order. + testCase: { + name: "titledBlock", + content: `
+
+
+
+
Heads up
+
+
+
+
+
+

First

+
+
+
+
+
+
+

Second

+
+
+
+
+
+
+
`, + }, + executeTest: testParseHTML, + }, ]; export const parseTestInstancesMarkdown: TestInstance< diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index ee48987244..c28f2c2cf5 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -1,4 +1,25 @@ { + "alert": { + "config": { + "content": "inline", + "propSchema": {}, + "type": "alert", + }, + "extensions": undefined, + "implementation": { + "keyboard": [Function], + "meta": { + "code": false, + "defining": true, + "draggable": true, + "isolating": true, + "selectable": true, + }, + "node": null, + "render": [Function], + "renderFrame": [Function], + }, + }, "audio": { "config": { "content": "none", @@ -23,10 +44,16 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "fileBlockAccept": [ "audio/*", ], + "isolating": true, + "selectable": true, }, "node": null, "parse": [Function], @@ -63,8 +90,13 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], @@ -73,6 +105,39 @@ "toExternalHTML": [Function], }, }, + "callout": { + "config": { + "children": { + "allow": "blocks", + }, + "content": "none", + "layoutOnly": true, + "propSchema": { + "flavor": { + "default": "tip", + "values": [ + "tip", + "info", + "warning", + ], + }, + }, + "type": "callout", + }, + "extensions": undefined, + "implementation": { + "keyboard": [Function], + "meta": { + "code": false, + "defining": true, + "draggable": true, + "isolating": false, + "selectable": true, + }, + "node": null, + "renderFrame": [Function], + }, + }, "checkListItem": { "config": { "content": "inline", @@ -103,8 +168,13 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], @@ -130,11 +200,14 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "code": true, "defining": true, + "draggable": true, "highlight": [Function], "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], @@ -167,6 +240,14 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], + "meta": { + "code": false, + "defining": true, + "draggable": true, + "isolating": true, + "selectable": true, + }, "node": null, "parse": [Function], "render": [Function], @@ -183,13 +264,17 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], "render": [Function], - "toExternalHTML": [Function], }, }, "file": { @@ -213,10 +298,16 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "fileBlockAccept": [ "*/*", ], + "isolating": true, + "selectable": true, }, "node": null, "parse": [Function], @@ -265,13 +356,20 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, + "dropsIntoChildren": [Function], "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], "parseContent": [Function], "render": [Function], + "renderFrame": [Function], "runsBefore": [ "toggleListItem", ], @@ -315,10 +413,16 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "fileBlockAccept": [ "image/*", ], + "isolating": true, + "selectable": true, }, "node": null, "parse": [Function], @@ -359,8 +463,13 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], @@ -377,6 +486,14 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], + "meta": { + "code": false, + "defining": true, + "draggable": true, + "isolating": true, + "selectable": true, + }, "node": null, "parse": [Function], "render": [Function], @@ -409,8 +526,13 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], @@ -439,8 +561,13 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], @@ -472,9 +599,16 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], + "meta": { + "code": false, + "defining": true, + "draggable": true, + "isolating": true, + "selectable": true, + }, "node": null, "render": [Function], - "toExternalHTML": [Function], }, }, "simpleImage": { @@ -514,9 +648,16 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], + "meta": { + "code": false, + "defining": true, + "draggable": true, + "isolating": true, + "selectable": true, + }, "node": null, "render": [Function], - "toExternalHTML": [Function], }, }, "table": { @@ -534,6 +675,14 @@ [Function], ], "implementation": { + "keyboard": [Function], + "meta": { + "code": false, + "defining": true, + "draggable": true, + "isolating": true, + "selectable": true, + }, "node": null, "render": [Function], "toExternalHTML": [Function], @@ -565,13 +714,20 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, + "dropsIntoChildren": [Function], "isolating": false, + "selectable": true, }, "node": null, "parse": [Function], "parseContent": [Function], "render": [Function], + "renderFrame": [Function], "runsBefore": [ "bulletListItem", ], @@ -615,10 +771,16 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { + "code": false, + "defining": true, + "draggable": true, "fileBlockAccept": [ "video/*", ], + "isolating": true, + "selectable": true, }, "node": null, "parse": [Function], diff --git a/tests/src/unit/core/testSchema.ts b/tests/src/unit/core/testSchema.ts index eca37363fa..2e958ef243 100644 --- a/tests/src/unit/core/testSchema.ts +++ b/tests/src/unit/core/testSchema.ts @@ -27,7 +27,7 @@ const SimpleImage = createBlockSpec( ), { render(block, editor) { - return createImageBlockSpec().implementation.render.call( + return createImageBlockSpec().implementation.render!.call( this, block as any, editor as any, @@ -99,6 +99,96 @@ const SimpleCustomParagraph = createBlockSpec( }, ); +// A layout block: it holds no inline content of its own, and its frame's +// `slot` is where its child blocks go. Covers layout blocks in the format-conversion, +// clipboard and selection matrices, which otherwise never see one. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip" as const, + values: ["tip", "info", "warning"] as const, + }, + }, + content: "none", + layoutOnly: true, + children: { + allow: "blocks", + }, + }, + { + renderFrame() { + const callout = document.createElement("div"); + callout.className = "callout"; + // The serializer must overwrite author attributes with the block props. + callout.setAttribute("data-flavor", "author-value"); + + const body = document.createElement("div"); + body.className = "callout-body"; + callout.appendChild(body); + + // Chrome outside the children region. It is in the HTML snapshots, and + // the full-HTML equality matrix shows that it does not parse back as + // document content. + const chrome = document.createElement("button"); + chrome.contentEditable = "false"; + chrome.textContent = "UI LABEL"; + callout.append(chrome); + + return { + dom: callout, + slot: body, + }; + }, + }, +); + +// A titled block: an ordinary block with inline content (the title) whose +// `children` are a body that belongs to it. Covers titled blocks in the +// format-conversion, clipboard and selection matrices, which otherwise never +// see one (the `callout` above only covers layout blocks). +const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + }, + { + render: () => { + const alert = document.createElement("div"); + alert.className = "alert"; + + return { + dom: alert, + contentDOM: alert, + }; + }, + renderFrame: (block) => { + if (block.children.length === 0) { + return undefined; + } + const frame = document.createElement("div"); + frame.className = "alert-frame"; + + // Chrome outside the slot, like the callout's: it must not parse back + // as document content either. + const chrome = document.createElement("button"); + chrome.contentEditable = "false"; + chrome.textContent = "UI LABEL"; + + const slot = document.createElement("div"); + slot.className = "alert-slot"; + frame.append(chrome, slot); + + return { + dom: frame, + slot, + }; + }, + }, +); + // INLINE CONTENT -------------------------------------------------------------- const Mention = createInlineContentSpec( @@ -222,6 +312,8 @@ export const testSchema = BlockNoteSchema.create().extend({ customParagraph: CustomParagraph(), simpleCustomParagraph: SimpleCustomParagraph(), simpleImage: SimpleImage(), + callout: Callout(), + alert: Alert(), }, inlineContentSpecs: { mention: Mention, diff --git a/tests/src/unit/react/blockElementForNode.test.tsx b/tests/src/unit/react/blockElementForNode.test.tsx new file mode 100644 index 0000000000..39a8420a96 --- /dev/null +++ b/tests/src/unit/react/blockElementForNode.test.tsx @@ -0,0 +1,203 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + createBlockSpec, + getBlockElementForNode, +} from "@blocknote/core"; +import { BlockNoteViewRaw, createReactBlockSpec } from "@blocknote/react"; +import { withMultiColumn } from "@blocknote/xl-multi-column"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it } from "vite-plus/test"; + +// `getBlockElementForNode` finds a block's own element (the element with +// `data-node-type` and `data-id`) for every kind of block: regular blocks with +// and without a frame, layout blocks, vanilla and React. + +const VanillaFrame = createBlockSpec( + { type: "vanillaFrame", propSchema: {}, content: "inline" }, + { + render: () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }, + renderFrame: () => { + const dom = document.createElement("section"); + const slot = document.createElement("div"); + dom.append(slot); + return { dom, slot }; + }, + }, +); + +const VanillaLayout = createBlockSpec( + { + type: "vanillaLayout", + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { + renderFrame: () => { + const dom = document.createElement("section"); + const slot = document.createElement("div"); + dom.append(slot); + return { dom, slot }; + }, + }, +); + +const createReactContent = createReactBlockSpec( + { type: "reactContent", propSchema: {}, content: "inline" }, + { render: (props) =>
}, +); + +const createReactFrame = createReactBlockSpec( + { type: "reactFrame", propSchema: {}, content: "inline" }, + { + render: (props) =>
, + renderFrame: (props) => ( +
+
+
+ ), + }, +); + +const createReactLayout = createReactBlockSpec( + { type: "reactLayout", propSchema: {}, content: "none", layoutOnly: true }, + { + renderFrame: (props) => ( +
+ +
+
+ ), + }, +); + +const schema = withMultiColumn( + BlockNoteSchema.create().extend({ + blockSpecs: { + vanillaFrame: VanillaFrame(), + vanillaLayout: VanillaLayout(), + reactContent: createReactContent(), + reactFrame: createReactFrame(), + reactLayout: createReactLayout(), + }, + }), +); + +const child = (id: string) => ({ id, type: "paragraph", content: id }); + +describe("getBlockElementForNode", () => { + it("finds the block's own element for every kind of block", async () => { + const editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [ + { + id: "paragraph", + type: "paragraph", + content: "P", + children: [child("nested")], + }, + { + id: "heading", + type: "heading", + props: { isToggleable: true }, + content: "H", + children: [child("headingChild")], + }, + { + id: "toggle", + type: "toggleListItem", + content: "T", + children: [child("toggleChild")], + }, + { + id: "table", + type: "table", + content: { type: "tableContent", rows: [{ cells: ["a"] }] }, + }, + { + id: "vanillaFrame", + type: "vanillaFrame", + content: "V", + children: [child("vanillaFrameChild")], + }, + { + id: "vanillaLayout", + type: "vanillaLayout", + children: [child("vanillaLayoutChild")], + }, + { + id: "reactContent", + type: "reactContent", + content: "R", + children: [child("reactContentChild")], + }, + { + id: "reactFrame", + type: "reactFrame", + content: "F", + children: [child("reactFrameChild")], + }, + { + id: "reactLayout", + type: "reactLayout", + children: [child("reactLayoutChild")], + }, + { + id: "columns", + type: "columnList", + children: [ + { + id: "column1", + type: "column", + children: [child("columnChild1")], + }, + { + id: "column2", + type: "column", + children: [child("columnChild2")], + }, + ], + }, + ] as any, + }) as BlockNoteEditor; + const div = document.createElement("div"); + document.body.appendChild(div); + const root = createRoot(div); + try { + flushSync(() => { + root.render(); + }); + // React draws its node views after the editor's own render. + await new Promise((resolve) => setTimeout(resolve, 0)); + const view = editor.prosemirrorView; + const found: string[] = []; + view.state.doc.descendants((node, pos) => { + if (!node.type.isInGroup("bnBlock")) { + return true; + } + const id = node.attrs.id as string; + const element = getBlockElementForNode(view, pos, id); + expect(element, id).toBeDefined(); + expect(element!.getAttribute("data-id"), id).toBe(id); + expect(element!.getAttribute("data-node-type"), id).toBe( + node.type.name, + ); + // Another block's id finds nothing. + expect(getBlockElementForNode(view, pos, "other"), id).toBeUndefined(); + found.push(id); + return true; + }); + expect(found).toHaveLength(22); + } finally { + root.unmount(); + document.body.removeChild(div); + editor._tiptapEditor.destroy(); + } + }); +}); diff --git a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html index df960b11f1..d330d912eb 100644 --- a/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html +++ b/tests/src/unit/react/formatConversion/export/__snapshots__/blocknoteHTML/customParagraph/styled.html @@ -1,6 +1,12 @@
-
+
-
+
+ +
+ + ); + }, + }, +); + +const FrameContext = createContext("outside"); +let activeFrames = 0; +const alertConfig = { + type: "frameAlert", + propSchema: { flavor: { default: "tip" }, framed: { default: true } }, + content: "inline", +} as const; + +function FrameChrome(props: { children: ReactNode; flavor: string }) { + return ( +
+ {props.children} +
+ ); +} + +function AlertFrame(props: ReactCustomBlockFrameProps) { + const label = useContext(FrameContext); + const [clicks, setClicks] = useState(0); + useEffect(() => { + activeFrames++; + return () => { + activeFrames--; + }; + }, []); + if (!props.block.props.framed) { + return null; + } + return ( + + + +
+ + ); +} + +const createFrameAlert = createReactBlockSpec(alertConfig, { + render: (props) =>
, + renderFrame: AlertFrame, +}); + +// Framing alone must not change how the block's children nest. +const createToggle = createReactBlockSpec( + { type: "frameToggle", propSchema: {}, content: "inline" }, + { + render: (props) =>
, + renderFrame: (props) => ( +
+ ), + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + frameBox: createFrameBox(), + frameAlert: createFrameAlert(), + frameToggle: createToggle(), + }, +}); + +let root: Root | undefined; +let div: HTMLDivElement | undefined; +let editor: BlockNoteEditor | undefined; +/** Render-phase errors. React 19 reports rather than rethrows these. */ +let uncaught: unknown[] = []; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function mountEditor(initialContent: any[]) { + div = document.createElement("div"); + document.body.appendChild(div); + + editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent, + }) as BlockNoteEditor; + + uncaught = []; + root = createRoot(div, { + onUncaughtError: (error) => uncaught.push(error), + onCaughtError: (error) => uncaught.push(error), + }); + // Mount during a React commit as applications do. Frame construction must + // not depend on a nested synchronous React render succeeding. + flushSync(() => { + root!.render( + + + , + ); + }); + + return editor; +} + +afterEach(() => { + root?.unmount(); + root = undefined; + if (div) { + document.body.removeChild(div); + div = undefined; + } + editor?._tiptapEditor.destroy(); + editor = undefined; + expect(activeFrames).toBe(0); +}); + +describe("React renderFrame", () => { + it("keeps layout block identity and child DOM through prop and author-root changes", async () => { + const mounted = mountEditor([ + { + id: "box-0", + type: "frameBox", + props: { flavor: "warning" }, + children: [{ id: "box-child", type: "paragraph", content: "Child" }], + }, + ]); + await tick(); + const child = div!.querySelector('[data-id="box-child"]'); + const slot = div!.querySelector(".frame-slot"); + expect(slot?.textContent).toBe("Child"); + mounted.updateBlock("box-0", { props: { flavor: "success" } }); + await vi.waitFor(() => + expect( + div!.querySelector(".frame-box")?.getAttribute("data-flavor"), + ).toBe("success"), + ); + expect(div!.querySelector(".frame-slot")).toBe(slot); + flushSync(() => + div!.querySelector(".swap-root")!.click(), + ); + const box = div!.querySelector("section.frame-box")!; + expect(box).not.toBeNull(); + expect(box.getAttribute("data-id")).toBe("box-0"); + expect(box.getAttribute("data-node-type")).toBe("frameBox"); + expect(box.getAttribute("data-flavor")).toBe("success"); + expect(box.querySelector('[data-id="box-child"]')).toBe(child); + expect(mounted.getBlock("box-child")?.content).toEqual([ + { type: "text", text: "Child", styles: {} }, + ]); + expect(uncaught).toEqual([]); + }); + + it("never gives one React layout block's frame to another layout block", async () => { + // ProseMirror offers a view whose node is gone to the next node of the + // same type. A frame shows one block, so its state must not move to + // another block. + const mounted = mountEditor([ + { id: "a", type: "frameBox", children: [{ type: "paragraph" }] }, + { id: "b", type: "frameBox", children: [{ type: "paragraph" }] }, + ]); + await tick(); + // `a`'s frame switches its root to a
(its own state). + flushSync(() => + div! + .querySelector('[data-id="a"]')! + .querySelector(".swap-root")! + .click(), + ); + expect(div!.querySelector('section[data-id="a"]')).not.toBeNull(); + mounted.transact(() => { + mounted.updateBlock("b", { props: { flavor: "warning" } }); + mounted.removeBlocks(["a"]); + }); + await tick(); + // `b` keeps its own frame: a
, not `a`'s
. + expect(div!.querySelector('div[data-id="b"]')).not.toBeNull(); + expect(uncaught).toEqual([]); + }); + + it("finds a React layout block's own element, not React's wrappers", async () => { + const mounted = mountEditor([ + { + id: "box-0", + type: "frameBox", + children: [{ id: "box-child", type: "paragraph", content: "Child" }], + }, + ]); + await tick(); + const { posBeforeNode } = getNodeById( + "box-0", + mounted.prosemirrorState.doc, + )!; + const element = getBlockElementForNode( + mounted.prosemirrorView, + posBeforeNode, + "box-0", + ); + expect(element).toBe(div!.querySelector(".frame-box")); + expect(uncaught).toEqual([]); + }); + + it("draws a React-framed block again when its type changes", async () => { + const mounted = mountEditor([ + { + id: "alert-0", + type: "frameAlert", + content: "Heads up", + children: [{ id: "alert-child", type: "paragraph", content: "Body" }], + }, + ]); + await tick(); + expect(div!.querySelector(".alert-frame")).not.toBeNull(); + mounted.updateBlock("alert-0", { type: "paragraph" }); + await tick(); + expect(div!.querySelector(".alert-frame")).toBeNull(); + expect(div!.textContent).toContain("Heads up"); + expect(div!.textContent).toContain("Body"); + expect(uncaught).toEqual([]); + }); + + it("renders plain nesting when the frame mounts no slot", async () => { + mountEditor([ + { + id: "alert-0", + type: "frameAlert", + props: { framed: false }, + content: "Heads up", + children: [{ id: "alert-child", type: "paragraph", content: "Body" }], + }, + ]); + await tick(); + + expect(div!.querySelector(".alert-frame")).toBeNull(); + expect(div!.textContent).toContain("Heads up"); + expect(div!.textContent).toContain("Body"); + expect(uncaught).toEqual([]); + }); + + it("keeps React state, context, and child DOM through frame updates", async () => { + const mounted = mountEditor([ + { + id: "alert", + type: "frameAlert", + content: "Title", + children: [{ id: "child", type: "paragraph", content: "Body" }], + }, + ]); + await vi.waitFor(() => + expect(div!.querySelector(".frame-counter")?.textContent).toBe( + "inside: 0", + ), + ); + expect(div!.querySelector(".alert-slot")?.textContent).toBe("TitleBody"); + const child = div!.querySelector('[data-id="child"]'); + const title = div!.querySelector(".alert-title"); + div!.querySelector(".frame-counter")!.click(); + await vi.waitFor(() => + expect(div!.querySelector(".frame-counter")?.textContent).toBe( + "inside: 1", + ), + ); + div!.querySelector(".frame-flavor")!.click(); + await vi.waitFor(() => + expect( + div!.querySelector(".alert-frame")?.getAttribute("data-flavor"), + ).toBe("warning"), + ); + root!.render( + + + , + ); + await vi.waitFor(() => + expect(div!.querySelector(".frame-counter")?.textContent).toBe( + "updated: 1", + ), + ); + expect(div!.querySelector('[data-id="child"]')).toBe(child); + expect(div!.querySelector(".alert-title")).toBe(title); + expect(mounted.getBlock("child")?.content).toEqual([ + { type: "text", text: "Body", styles: {} }, + ]); + expect(uncaught).toEqual([]); + }); + + it("switches framing off and on without losing title or child node views", async () => { + const mounted = mountEditor([ + { + id: "alert", + type: "frameAlert", + content: "Title", + children: [{ id: "child", type: "paragraph", content: "Body" }], + }, + ]); + await vi.waitFor(() => + expect(div!.querySelector(".alert-slot")?.textContent).toBe("TitleBody"), + ); + const child = div!.querySelector('[data-id="child"]'); + mounted.updateBlock("alert", { props: { framed: false } }); + await vi.waitFor(() => + expect(div!.querySelector(".alert-frame")).toBeNull(), + ); + expect(div!.textContent).toContain("TitleBody"); + expect(div!.querySelector('[data-id="child"]')).toBe(child); + mounted.updateBlock("alert", { props: { framed: true } }); + await vi.waitFor(() => + expect(div!.querySelector(".alert-slot")?.textContent).toBe("TitleBody"), + ); + expect(div!.querySelector('[data-id="child"]')).toBe(child); + expect(uncaught).toEqual([]); + }); + + it("unmounts the frame component when its block is removed", async () => { + const mounted = mountEditor([ + { id: "alert", type: "frameAlert", content: "Title" }, + { id: "after", type: "paragraph", content: "After" }, + ]); + await vi.waitFor(() => expect(activeFrames).toBe(1)); + mounted.removeBlocks(["alert"]); + await vi.waitFor(() => expect(activeFrames).toBe(0)); + expect(div!.querySelector(".alert-frame")).toBeNull(); + expect(uncaught).toEqual([]); + }); + + it("exports a titled React frame around its content and children", () => { + // Without an explicit `toExternalHTML`, external HTML keeps the frame, + // with the content and the children in its slot. + const headless = BlockNoteEditor.create({ schema }); + try { + const html = headless.blocksToHTMLLossy([ + { + type: "frameAlert", + content: "Title", + children: [{ type: "paragraph", content: "Body" }], + }, + ]); + const dom = document.createElement("div"); + dom.innerHTML = html; + const slot = dom.querySelector(".alert-frame .alert-slot"); + expect(slot?.querySelector(".alert-title")?.textContent).toBe("Title"); + expect(slot?.textContent).toContain("Body"); + expect(activeFrames).toBe(0); + } finally { + headless._tiptapEditor.destroy(); + } + }); + + it("exports a declined frame as plain content and disposes its effects", () => { + const headless = BlockNoteEditor.create({ schema }); + try { + const html = headless.blocksToHTMLLossy([ + { + type: "frameAlert", + props: { framed: false }, + content: "Title", + children: [{ type: "paragraph", content: "Body" }], + }, + ]); + expect(html).not.toContain("alert-frame"); + expect(html).toContain("Title"); + expect(html).toContain("Body"); + expect(activeFrames).toBe(0); + } finally { + headless._tiptapEditor.destroy(); + } + }); + + it("keeps ordinary Shift-Tab behavior inside a frame without declared children", async () => { + const mounted = mountEditor([ + { + id: "toggle", + type: "frameToggle", + content: "Title", + children: [{ id: "child", type: "paragraph", content: "Body" }], + }, + ]); + await vi.waitFor(() => + expect(div!.querySelector(".toggle-frame")?.textContent).toBe( + "TitleBody", + ), + ); + mounted.setTextCursorPosition("child", "start"); + const event = new KeyboardEvent("keydown", { + key: "Tab", + code: "Tab", + keyCode: 9, + shiftKey: true, + bubbles: true, + }); + mounted._tiptapEditor.view.dom.dispatchEvent(event); + expect(mounted.getParentBlock("child")).toBeUndefined(); + expect(mounted.getBlock("toggle")?.children).toHaveLength(0); + expect(uncaught).toEqual([]); + }); +}); diff --git a/tests/src/unit/react/reactToExternalHTML.test.tsx b/tests/src/unit/react/reactToExternalHTML.test.tsx new file mode 100644 index 0000000000..984dde7f73 --- /dev/null +++ b/tests/src/unit/react/reactToExternalHTML.test.tsx @@ -0,0 +1,119 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + createBlockSpec, +} from "@blocknote/core"; +import { createReactBlockSpec } from "@blocknote/react"; +import { describe, expect, it } from "vite-plus/test"; + +// A React `toExternalHTML` places the block's children with `childrenRef`, +// like core's `toExternalHTML` does with `childrenDOM`. + +const createDisclosure = createReactBlockSpec( + { type: "disclosure", propSchema: {}, content: "inline" }, + { + render: (props) =>
, + toExternalHTML: (props) => ( +
+ +
+
+ ), + }, +); + +const createShelf = createReactBlockSpec( + { type: "shelf", propSchema: {}, content: "none", layoutOnly: true }, + { + renderFrame: (props) =>
, + toExternalHTML: (props) => ( +
+

Shelf

+
+
+ ), + }, +); + +// Layout blocks whose own export doesn't place their children. +const createPlainShelf = createReactBlockSpec( + { type: "plainShelf", propSchema: {}, content: "none", layoutOnly: true }, + { + renderFrame: (props) =>
, + toExternalHTML: () =>
, + }, +); + +const VanillaShelf = createBlockSpec( + { type: "vanillaShelf", propSchema: {}, content: "none", layoutOnly: true }, + { + renderFrame: () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + toExternalHTML: () => { + const dom = document.createElement("section"); + dom.className = "vanilla-shelf"; + return { dom }; + }, + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + disclosure: createDisclosure(), + shelf: createShelf(), + plainShelf: createPlainShelf(), + vanillaShelf: VanillaShelf(), + }, +}); + +function exportHTML(blocks: any[]) { + const editor = BlockNoteEditor.create({ schema }); + try { + const dom = document.createElement("div"); + dom.innerHTML = editor.blocksToHTMLLossy(blocks); + return dom; + } finally { + editor._tiptapEditor.destroy(); + } +} + +describe("React toExternalHTML childrenRef", () => { + it("places a block's children where its export puts them", () => { + const dom = exportHTML([ + { + type: "disclosure", + content: "Title", + children: [{ type: "paragraph", content: "Child" }], + }, + ]); + expect(dom.querySelector("summary")?.textContent).toBe("Title"); + expect(dom.querySelector(".disclosure-children")?.textContent).toBe( + "Child", + ); + }); + + it("places a layout block's children where its export puts them", () => { + const dom = exportHTML([ + { + type: "shelf", + children: [{ type: "paragraph", content: "Child" }], + }, + ]); + expect(dom.querySelector("section > h2")?.textContent).toBe("Shelf"); + expect(dom.querySelector(".shelf-children")?.textContent).toBe("Child"); + }); + + it("keeps a layout block's children after it when its export places them nowhere", () => { + for (const type of ["plainShelf", "vanillaShelf"]) { + const dom = exportHTML([ + { type, children: [{ type: "paragraph", content: "Child" }] }, + ]); + expect(dom.firstElementChild?.tagName).toBe("SECTION"); + expect(dom.firstElementChild?.nextElementSibling?.textContent).toBe( + "Child", + ); + } + }); +}); diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx index 71101c7fa7..b207acd752 100644 --- a/tests/src/unit/react/useNodeViewBlock.test.tsx +++ b/tests/src/unit/react/useNodeViewBlock.test.tsx @@ -27,8 +27,20 @@ const createReproBlock = createReactBlockSpec( { render: (props) =>

}, ); +// A layout block, whose node view's node is itself the bnBlock, resolved +// directly instead of by position. +const createBoxBlock = createReactBlockSpec( + { + type: "box", + propSchema: {}, + content: "none", + layoutOnly: true, + }, + { renderFrame: (props) =>

}, +); + const schema = BlockNoteSchema.create().extend({ - blockSpecs: { repro: createReproBlock() }, + blockSpecs: { repro: createReproBlock(), box: createBoxBlock() }, }); let editor: BlockNoteEditor; @@ -43,6 +55,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 +91,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-layout) block's node shape; layout block +// 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 +186,14 @@ describe("useNodeViewBlock", () => { expect(resolved.id).toBe(target.id); expect(resolved).not.toBe(seed); }); + + it("resolves layout blocks directly without consulting their position", () => { + const box = editor.document.find((block) => block.type === "box")!; + const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!; + const getPos = vi.fn(() => undefined); + const resolved = renderHook(makeProps(getPos, node), editor.document[0]); + + expect(resolved).toEqual(box); + expect(getPos).not.toHaveBeenCalled(); + }); }); diff --git a/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts b/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts index 9ac5b7df5f..c6bc9f3f15 100644 --- a/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts +++ b/tests/src/unit/shared/formatConversion/exportParseEquality/exportParseEqualityTestExecutors.ts @@ -26,17 +26,24 @@ export const testExportParseEqualityBlockNoteHTML = < addIdsToBlocks(testCase.content); - const exported = editor.blocksToFullHTML(testCase.content); - if (testCase.name.startsWith("malformed/")) { - // We purposefully are okay with malformed response, we know they won't match + const exported = editor.blocksToFullHTML(testCase.content); + // Malformed partial input is intentionally not a lossless document. expect(editor.tryParseHTMLToBlocks(exported)).not.toStrictEqual( partialBlocksToBlocksForTesting(editor.schema, testCase.content), ); } else { - expect(editor.tryParseHTMLToBlocks(exported)).toStrictEqual( - partialBlocksToBlocksForTesting(editor.schema, testCase.content), + // Round-trip valid blocks, including schema-generated required children. + // The shorthand helper defaults all children to [], which is invalid for + // layout blocks with a minimum child count. + const blocks = testCase.content.map((block) => + nodeToBlock( + blockToNode(block, editor.pmSchema), + editor.prosemirrorState.doc, + ), ); + const exported = editor.blocksToFullHTML(blocks); + expect(editor.tryParseHTMLToBlocks(exported)).toStrictEqual(blocks); } }; diff --git a/tests/src/utils/mouse.ts b/tests/src/utils/mouse.ts index 792d15fe0a..6e110ea8df 100644 --- a/tests/src/utils/mouse.ts +++ b/tests/src/utils/mouse.ts @@ -63,15 +63,11 @@ export async function moveMouseOverElement( } /** - * Drags a block onto another via its drag handle. Hovers the drag target to - * reveal the handle, presses on the handle, then drops on the left edge - * (`dropAbove`) or right edge of the drop target. + * Starts dragging a block by its drag handle: hovers the block to reveal the + * handle, presses on it, and moves the mouse a little, so the browser starts + * the drag. Finish it with `dropDraggedBlock`. */ -export async function dragAndDropBlock( - dragTarget: string | Element, - dropTarget: string | Element, - dropAbove: boolean, -) { +export async function startBlockDrag(dragTarget: string | Element) { await moveMouseOverElement(dragTarget); await sleep(100); @@ -83,7 +79,19 @@ export async function dragAndDropBlock( await sleep(100); await runMouse([{ type: "down" }]); await sleep(100); + await runMouse([ + { type: "move", x: handleCenter.x + 10, y: handleCenter.y + 10, steps: 5 }, + ]); +} +/** + * Drops the block that `startBlockDrag` drags on the left edge (`dropAbove`) + * or right edge of the drop target. + */ +export async function dropDraggedBlock( + dropTarget: string | Element, + dropAbove: boolean, +) { const dropRect = getRect(dropTarget); const dropCoords = dropAbove ? { x: dropRect.x + 1, y: dropRect.y + dropRect.height / 2 } @@ -96,3 +104,17 @@ export async function dragAndDropBlock( { type: "up" }, ]); } + +/** + * Drags a block onto another via its drag handle. Hovers the drag target to + * reveal the handle, presses on the handle, then drops on the left edge + * (`dropAbove`) or right edge of the drop target. + */ +export async function dragAndDropBlock( + dragTarget: string | Element, + dropTarget: string | Element, + dropAbove: boolean, +) { + await startBlockDrag(dragTarget); + await dropDraggedBlock(dropTarget, dropAbove); +}