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/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx new file mode 100644 index 0000000000..51364c7888 --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,201 @@ +--- +title: Container Blocks +description: Learn how to create custom blocks that contain other blocks +--- + +# Container Blocks + +You can create custom blocks that contain other blocks, such as panels, callouts, and column layouts. 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. + + + +## Creating a Container Block + +Use the `createReactBlockSpec` function to create a container block, just like a [Custom Block](/docs/features/custom-schemas/custom-blocks). For the panel below, we set `content` to `"none"` and `container` 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", + container: true, + }, + { + render: (props) => ( +
+ ), + }, +); +``` + +### Block Config + +The block config defines the content and child blocks your container can hold: + +`content:` Must be `"none"` for a container: its node holds nothing but its child blocks. For a block with its own text and child blocks, see [Combining Content and Child Blocks](#combining-content-and-child-blocks). + +`container:` Set to `true` to put the block's child blocks inside it. Without it, a block's child blocks are indented below it. A container accepts any block by default. You can also restrict it to specific container types, as explained in [Restricting Children](#restricting-children). + +`propSchema:` Defines the container's props, just like for other custom blocks. Use these to customize its appearance or behavior. + +### Block Implementation + +`render:` Your React component defines how the block should look. For a container, attach `contentRef` where the child blocks should appear. With `content: "inline"` or `"plain"`, attach it to the block's own editable text. You can add icons, buttons, or other elements around it: + +```tsx +render: (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 Container Blocks to the Editor + +Add your container 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. + +## Combining Content and Child Blocks + +A block can have both its own text and child blocks. Use this for a question followed by hints, a checklist item with detailed instructions, a code sample followed by explanatory blocks, or a callout with a heading. In the demo below, we use the block's text as a callout title: + + + +Set `content` to `"inline"` for rich text or `"plain"` for unstyled text. Every block with content can have child blocks, so there's nothing to declare for them. Use `render` for the block's own text, `renderFrame` to style that content and its child blocks together, and `keyboard` to keep the child blocks inside the block: + +```tsx +import { createReactBlockSpec } from "@blocknote/react"; + +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: {}, + content: "inline", + }, + { + experimental_keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, + render: (props) => ( +
+ ), + renderFrame: (props) => ( +
+
+
+ ), + }, +); +``` + +`render:` Attach `contentRef` to the block's own editable text. With `content: "inline"`, users can format it and add links, just like in a paragraph. In this example, we style it as a callout title. + +For plain text, set `content: "plain"` and use a `
` element to display line breaks and spacing:
+
+```tsx
+render: (props) => 
,
+```
+
+The child blocks can still contain rich text, images, and other block types.
+
+`renderFrame:` An optional React component for styling the block and its children together, such as giving the callout a shared border or background. It receives `block`, `editor`, and `contentRef`, just like `render`. Attach `contentRef` where the block's content and children should appear. You can use the block's props to customize the frame, add interactive controls, or return `null` to show the block without a frame.
+
+`experimental_keyboard:` Without it, child blocks behave like any indented blocks. This API is experimental and may change. Here, `enter: "into-children"` makes Enter in the title add a first child block, `childrenCanOutdent: false` keeps Shift-Tab from moving blocks out of the callout, and `emptyChildEnter: "exit-at-end"` makes Enter in an empty last block leave the callout. See [Custom Blocks](/docs/features/custom-schemas/custom-blocks) for all keyboard settings.
+
+For a container with `content: "none"`, like the panel above, add the surrounding styling directly in `render`.
+
+Add `callout: createCallout()` to your schema, then use `content` for the title and `children` for the blocks inside it:
+
+```typescript
+{
+  type: "callout",
+  content: "Before you start",
+  children: [
+    { type: "paragraph", content: "Make sure you have an account." },
+  ],
+}
+```
+
+Pressing Enter at the end of the title adds a paragraph inside the callout, and Enter in an empty last paragraph leaves the callout. Moving the callout moves its title and child blocks together.
+
+To add blocks to an existing container, see [Inserting Blocks](/docs/reference/editor/manipulating-content#inserting-blocks).
+
+## Restricting Children
+
+For structured layouts, you can limit a container to specific container types. For example, a column layout should only contain columns, while each column can contain any block.
+
+Use an array of container type names for `children.allow`, and `min` to set the minimum number of children:
+
+```typescript
+// Column layout config:
+container: true,
+children: { allow: ["column"], min: 2 },
+```
+
+On the column itself, set `placeable` to `"namedOnly"` so it can only be used inside a container that explicitly allows it:
+
+```typescript
+// Column config:
+container: true,
+placeable: "namedOnly",
+```
+
+`children.allow:` Accepts `"blocks"` (the default) or an array of container type names. You cannot list regular block types such as `"paragraph"` individually.
+
+`children.min:` The minimum number of children. Defaults to `1`.
+
+`placeable:` Set to `"namedOnly"` to restrict a container to parents that name it in `children.allow`. Defaults to `"anywhere"`.
+
+These options need `container: 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).
diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index ff25cf838c..19ae567950 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -56,6 +56,12 @@ type BlockConfig = {
   type: string;
   content: "inline" | "plain" | "none";
   readonly propSchema: PropSchema;
+  container?: true; // only with content: "none"
+  children?: {
+    allow?: "blocks" | string[];
+    min?: number;
+  };
+  placeable?: "anywhere" | "namedOnly";
 };
 ```
 
@@ -72,6 +78,14 @@ type BlockConfig = {
   alert, so we set `content` to `"inline"`._
 
 
+
+  _Every block can have child blocks, indented below it. A block without
+  content can also hold them inside itself with `container: true`. See
+  [Container Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
+`container?:` Puts the block's child blocks inside it. `children?:` Restricts which child blocks a container can hold. `placeable?:` Controls where a container block can be used. See [Container Blocks](/docs/features/custom-schemas/container-blocks) for the supported configurations.
+
 `propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior.
 
 ```typescript
@@ -133,8 +147,19 @@ 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;
+  //   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;
@@ -171,9 +196,25 @@ 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"`.
 
-- `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.
+- `splitKeepsType?:` Whether the block created by splitting this one with Enter has the same type, as in lists. Defaults to `false`.
+
+- `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 container 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.
+
+- `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 container 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.
 
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..0485b0839b 100644
--- a/docs/content/docs/features/export/typst.mdx
+++ b/docs/content/docs/features/export/typst.mdx
@@ -120,6 +120,26 @@ 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).
 
+### Blocks that place their children
+
+By default, a mapping renders only its block, and the exporter places the
+block's children after it, indented. A block whose children are part of it -
+a [container block](/docs/features/custom-schemas/container-blocks), or a
+callout with a body - uses a `{ withChildren }` mapping instead: the exporter renders
+the children first and passes them in as its last argument, and the mapping
+decides where they go. Container blocks must use a `{ withChildren }` mapping, and a
+container without one is an error rather than a silent omission, since
+dropping it would drop its children too.
+
+```typescript
+myContainer: {
+  withChildren: (block, exporter, nestingLevel, numberedListIndex, children) =>
+    `#rect(width: 100%)[${children.join("\n\n")}]`,
+},
+```
+
+Separate the children with a blank line, as above, if each should stay its own
+block — 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..af4cc0134b 100644
--- a/docs/content/docs/reference/editor/manipulating-content.mdx
+++ b/docs/content/docs/reference/editor/manipulating-content.mdx
@@ -165,14 +165,16 @@ editor.insertBlocks(
   "after",
 );
 
-// Insert a paragraph as the last child of an existing block
+// Insert a paragraph as the last child of a container block
 editor.insertBlocks(
   [{ type: "paragraph", content: "Nested paragraph" }],
-  "existing-block-id",
+  "container-block-id",
   "last-child",
 );
 ```
 
+For [container blocks](/docs/features/custom-schemas/container-blocks), `"first-child"` inserts at the beginning of the container and `"last-child"` inserts at the end. Use `"before"` or `"after"` to insert next to the container instead.
+
 ### Updating Blocks
 
 #### Modifying Existing Blocks
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/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/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/App.tsx b/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx
deleted file mode 100644
index 81877de7e4..0000000000
--- a/examples/06-custom-schema/06-toggleable-blocks/src/App.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
-import "@blocknote/core/fonts/inter.css";
-import { BlockNoteView } from "@blocknote/mantine";
-import "@blocknote/mantine/style.css";
-import { useCreateBlockNote } from "@blocknote/react";
-
-import { ToggleBlock } from "./Toggle";
-
-// Our schema with block specs, which contain the configs and implementations for
-// blocks that we want our editor to use.
-const schema = BlockNoteSchema.create({
-  blockSpecs: {
-    // Adds all default blocks.
-    ...defaultBlockSpecs,
-    // Adds the Toggle block.
-    toggle: ToggleBlock(),
-  },
-});
-
-export default function App() {
-  // Creates a new editor instance.
-  const editor = useCreateBlockNote({
-    schema,
-    initialContent: [
-      {
-        type: "paragraph",
-        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",
-        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.",
-          },
-        ],
-      },
-      {
-        type: "paragraph",
-        content: "Click the '>' icon to show/hide its children",
-      },
-    ],
-  });
-
-  // Renders the editor instance.
-  return ;
-}
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-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json new file mode 100644 index 0000000000..3de7330631 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/.bnexample.json @@ -0,0 +1,15 @@ +{ + "playground": true, + "docs": true, + "author": "nickthesick", + "tags": [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu" + ], + "dependencies": { + "react-icons": "^5.5.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md new file mode 100644 index 0000000000..5ecd226989 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/README.md @@ -0,0 +1,18 @@ +# Container 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 `container: true` on `BlockConfig`, which makes it a container: its child blocks mount into the rendered content region (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `render`, which re-renders live when props change. + +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:** + +- [Container Blocks](/docs/features/custom-schemas/container-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/06-toggleable-blocks/index.html b/examples/06-custom-schema/09-container-block/index.html similarity index 89% rename from examples/06-custom-schema/06-toggleable-blocks/index.html rename to examples/06-custom-schema/09-container-block/index.html index bdac14de54..34d12680d0 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/index.html +++ b/examples/06-custom-schema/09-container-block/index.html @@ -5,7 +5,7 @@ name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" /> - Toggleable Custom Blocks + Container Block diff --git a/examples/06-custom-schema/06-toggleable-blocks/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx similarity index 100% rename from examples/06-custom-schema/06-toggleable-blocks/main.tsx rename to examples/06-custom-schema/09-container-block/main.tsx diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json new file mode 100644 index 0000000000..29778f9255 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx new file mode 100644 index 0000000000..c93bbe6f87 --- /dev/null +++ b/examples/06-custom-schema/09-container-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 container 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, as `min` defaults to 1. +function insertPanel(editor: typeof schema.BlockNoteEditor) { + return { + title: "Panel", + subtext: "Container block that wraps other blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "panel", + }), + aliases: ["panel", "container", "callout", "alert", "note", "tip", "info"], + group: "Basic blocks", + icon: , + }; +} + +export default function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: "Welcome! This demo shows the new container 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-container-block/src/Panel.tsx b/examples/06-custom-schema/09-container-block/src/Panel.tsx new file mode 100644 index 0000000000..49a623fa31 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Panel.tsx @@ -0,0 +1,16 @@ +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +export const createPanel = createReactBlockSpec( + { + type: "panel", + propSchema: {}, + content: "none", + container: true, + }, + { + // With no content of its own, contentRef receives the child blocks. + render: (props) =>

, + }, +); diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css new file mode 100644 index 0000000000..ed2f1b8b6b --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/styles.css @@ -0,0 +1,10 @@ +.panel { + border-left: 4px solid #507aff; + border-radius: 6px; + background-color: var(--panel-bg, #e6ebff); + padding: 12px 16px; +} + +[data-color-scheme="dark"] .panel { + --panel-bg: #1e2a5c; +} diff --git a/examples/06-custom-schema/06-toggleable-blocks/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json similarity index 100% rename from examples/06-custom-schema/06-toggleable-blocks/tsconfig.json rename to examples/06-custom-schema/09-container-block/tsconfig.json diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite-env.d.ts b/examples/06-custom-schema/09-container-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-container-block/vite-env.d.ts diff --git a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts b/examples/06-custom-schema/09-container-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-container-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-container-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-callout-block/.bnexample.json b/examples/06-custom-schema/13-callout-block/.bnexample.json new file mode 100644 index 0000000000..3de7330631 --- /dev/null +++ b/examples/06-custom-schema/13-callout-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/13-callout-block/README.md b/examples/06-custom-schema/13-callout-block/README.md new file mode 100644 index 0000000000..2e0a9c7fcf --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/README.md @@ -0,0 +1,20 @@ +# Callout Block + +In this example, we create a custom `Callout` block with a real rich-text title and child blocks inside it (a titled block), like a Notion-style callout. + +The block has `content: "inline"`: the title is ordinary inline content — formatting, links, and multiplayer cursors all work — and its child blocks live on `block.children` at runtime. Its `keyboard` settings keep the child blocks inside the callout: 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 callout. `render` draws the title row and `renderFrame` draws the box around the title and child blocks together. + +We also wire up a Slash Menu item to insert the callout. + +**Try it out:** + +- Press Enter at the end of the callout's title to add a block inside the callout. +- Press Enter in an empty last block inside the callout to leave the callout. +- Press Backspace at the start of the first block inside the callout to merge it back into the title. +- Press "/" inside the callout and add a code block, heading, or list. + +**Relevant Docs:** + +- [Container Blocks](/docs/features/custom-schemas/container-blocks) +- [Custom Blocks](/docs/features/custom-schemas/custom-blocks) +- [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/06-custom-schema/13-callout-block/index.html b/examples/06-custom-schema/13-callout-block/index.html new file mode 100644 index 0000000000..bb1b66ec1e --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/index.html @@ -0,0 +1,17 @@ + + + + + Callout Block + + + +
+ + + diff --git a/examples/06-custom-schema/13-callout-block/main.tsx b/examples/06-custom-schema/13-callout-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/13-callout-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/06-toggleable-blocks/package.json b/examples/06-custom-schema/13-callout-block/package.json similarity index 85% rename from examples/06-custom-schema/06-toggleable-blocks/package.json rename to examples/06-custom-schema/13-callout-block/package.json index caed51c711..9a61db5e93 100644 --- a/examples/06-custom-schema/06-toggleable-blocks/package.json +++ b/examples/06-custom-schema/13-callout-block/package.json @@ -1,5 +1,5 @@ { - "name": "@blocknote/example-custom-schema-toggleable-blocks", + "name": "@blocknote/example-custom-schema-callout-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/13-callout-block/src/App.tsx b/examples/06-custom-schema/13-callout-block/src/App.tsx new file mode 100644 index 0000000000..842b59e344 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/src/App.tsx @@ -0,0 +1,89 @@ +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 { createCallout } from "./Callout"; +import "./styles.css"; + +// Schema with the default blocks plus our custom Callout titled block. +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + callout: createCallout(), + }, +}); + +// Slash menu item to insert a Callout. +function insertCallout(editor: typeof schema.BlockNoteEditor) { + return { + title: "Callout", + subtext: "Titled container block that wraps other blocks", + onItemClick: () => + insertOrUpdateBlockForSlashMenu(editor, { + type: "callout", + }), + aliases: ["callout", "container", "alert", "note", "tip", "info"], + group: "Basic blocks", + icon: , + }; +} + +export default function App() { + const editor = useCreateBlockNote({ + schema, + initialContent: [ + { + type: "paragraph", + content: + "Welcome! This demo shows a titled block: a rich-text title with child blocks inside it.", + }, + { + type: "callout", + content: "A callout with a real title", + children: [ + { + type: "paragraph", + content: + "The title is ordinary inline content: formatting, links, and multiplayer cursors all work.", + }, + { + type: "paragraph", + content: + "Press Enter at the end of the title to add a block inside the callout, or Backspace at the start of the first block inside to merge it back.", + }, + ], + }, + { + type: "paragraph", + content: "Press '/' anywhere to insert a new Callout.", + }, + { + type: "paragraph", + }, + ], + }); + + return ( + + + filterSuggestionItems( + [...getDefaultReactSlashMenuItems(editor), insertCallout(editor)], + query, + ) + } + /> + + ); +} diff --git a/examples/06-custom-schema/13-callout-block/src/Callout.tsx b/examples/06-custom-schema/13-callout-block/src/Callout.tsx new file mode 100644 index 0000000000..96794b232f --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/src/Callout.tsx @@ -0,0 +1,38 @@ +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +// The Callout block: a titled block. `content: "inline"` gives the block its +// own rich-text title, and its child blocks sit inside the callout. The +// `experimental_keyboard` settings keep them inside: Enter in the title adds a first child +// block, child blocks can't be outdented out, and Enter in an empty last child +// block leaves the callout. `render` draws the title row (the title mounts +// into `contentRef`), while `renderFrame` draws the box around the title and +// the child blocks together. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: {}, + content: "inline", + }, + { + experimental_keyboard: { + enter: "into-children", + childrenCanOutdent: false, + emptyChildEnter: "exit-at-end", + }, + render: (props) => ( +
+ + ! + + +
+ ), + renderFrame: (props) => ( +
+
+
+ ), + }, +); diff --git a/examples/06-custom-schema/13-callout-block/src/styles.css b/examples/06-custom-schema/13-callout-block/src/styles.css new file mode 100644 index 0000000000..c4ca0071da --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/src/styles.css @@ -0,0 +1,42 @@ +.callout { + border-radius: 6px; + padding: 12px 16px; + border-left: 4px solid var(--callout-accent, #507aff); + background-color: var(--callout-bg, #e6ebff); +} + +[data-color-scheme="dark"] .callout { + --callout-bg: #1e2a5c; +} + +.callout-title-row { + display: flex; + align-items: flex-start; + gap: 8px; + margin-bottom: 4px; +} + +.callout-badge { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 9999px; + background-color: var(--callout-accent, #507aff); + color: #fff; + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + margin-top: 2px; +} + +.callout-title { + flex-grow: 1; + min-width: 0; + font-weight: 600; +} + +.callout-slot { + min-width: 0; +} diff --git a/examples/06-custom-schema/13-callout-block/tsconfig.json b/examples/06-custom-schema/13-callout-block/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/13-callout-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/13-callout-block/vite-env.d.ts b/examples/06-custom-schema/13-callout-block/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/13-callout-block/vite.config.ts b/examples/06-custom-schema/13-callout-block/vite.config.ts new file mode 100644 index 0000000000..cbf6ff2ffc --- /dev/null +++ b/examples/06-custom-schema/13-callout-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..b00fcfa71c 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,50 @@ 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. `render` +// only has to exist for `createBlockSpec` to accept the spec. +const container = (type: string, config: Record) => + createBlockSpec({ type, propSchema: {}, ...config } as any, { + render: () => { + throw new Error("not rendered in this suite"); + }, + })(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + // Why `"first-child"`/`"last-child"` exist: a container that may legally + // hold nothing has no child block to address, so `"before"`/`"after"` + // cannot reach inside it. + box: container("box", { + content: "none", + container: true, + children: { allow: "blocks", min: 0 }, + }), + // A container that only accepts other containers, so an insertion has to + // descend a level to find a place for a regular block. + grid: container("grid", { + content: "none", + container: true, + children: { allow: ["cell"], min: 2 }, + }), + cell: container("cell", { + content: "none", + container: true, + placeable: "namedOnly", + }), + } as const, +}); let editor: BlockNoteEditor; beforeAll(() => { - editor = BlockNoteEditor.create() as any; + editor = BlockNoteEditor.create({ schema }) as any; }); afterAll(() => { @@ -28,21 +66,25 @@ 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("inserts into a childless container", () => { + editor.replaceBlocks(editor.document, [ + { id: "b-0", type: "box" }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + expect(editor.getBlock("b-0")!.children).toHaveLength(0); editor.insertBlocks( [{ id: "first", type: "paragraph" }], - "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", "last", ]); @@ -51,63 +93,146 @@ describe('insertBlocks "first-child" / "last-child"', () => { it("prepends and appends around existing children", () => { editor.replaceBlocks(editor.document, [ { - id: "p-0", - type: "paragraph", - content: "Paragraph 0", + 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("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("descends into a nested container that accepts the block", () => { + editor.replaceBlocks(editor.document, [ + { + id: "g-0", + type: "grid", + children: [ + { id: "c-0", type: "cell" }, + { id: "c-1", type: "cell" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); - expect(editor.document.map((block) => block.id)).toEqual([ - "after", - "before", + // `grid` itself only accepts `cell`s, so both placements have to find the + // leading/trailing cell rather than giving up. + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "g-0", + "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", - "sibling", + "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", + ]); + }); + + 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/); + }); + + 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: "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([ "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..d687be7527 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 { containerSchema } from "../../containers/containers.fixture.js"; import { mergeBlocksCommand } from "./mergeBlocks.js"; const getEditor = setupTestEnv(); @@ -98,19 +100,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 +148,87 @@ describe("Test mergeBlocks", () => { expect(ret).toBeFalsy(); }); }); + +describe("Test mergeBlocks at container boundaries", () => { + const getContainerEditor = setupTestEnv({ + schema: containerSchema, + 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 mergeContainerBlocks(posBetweenBlocks: number) { + return getContainerEditor()._tiptapEditor.commands.command( + mergeBlocksCommand(posBetweenBlocks), + ); + } + + function getPosBefore(id: string) { + return getContainerEditor().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 container's first child has no previous sibling, so there is nothing to + // merge it into. The block above it on screen sits outside the container. + it("Does not merge a container's first child out of the container", () => { + const originalDocument = getContainerEditor().document; + const ret = mergeContainerBlocks(getPosBefore("callout-child-0")); + + expect(ret).toBeFalsy(); + expect(getContainerEditor().document).toEqual(originalDocument); + }); + + // A container has no content of its own, so there is nothing to merge. + it("Does not merge a container into the block above it", () => { + const originalDocument = getContainerEditor().document; + const ret = mergeContainerBlocks(getPosBefore("callout-0")); + + expect(ret).toBeFalsy(); + expect(getContainerEditor().document).toEqual(originalDocument); + }); + + // `mergeBlocksCommand` treats a container like any other block with children + // and merges into its last descendant, which puts the merged text inside the + // container. Backspace never produces this, because + // `KeyboardShortcutsExtension` bails out when the previous sibling has no + // inline content and moves the block into the container 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 container above it", () => { + const ret = mergeContainerBlocks(getPosBefore("after-callout")); + + expect(ret).toBeTruthy(); + + const document = getContainerEditor().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..88bbdb4c02 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,39 @@ 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. Tiptap's chainable state + // returns the shared transaction, so the lift is included in dispatch. if (dispatch && nextBlockInfo.children) { const childBlocksRange = state.doc .resolve(nextBlockInfo.children.childrenStart) @@ -156,10 +175,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..2699217643 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 { containerSchema } from "../../containers/containers.fixture.js"; +import { setupTestEnv, testDocument } from "../../setupTestEnv.js"; import { moveBlocksDown, moveBlocksUp, moveSelectedBlocksAndSelection, } from "./moveBlocks.js"; -const getEditor = setupTestEnv(); +const getEditor = setupTestEnv< + typeof containerSchema.blockSchema, + typeof containerSchema.inlineContentSchema, + typeof containerSchema.styleSchema +>({ + schema: containerSchema, + 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..1cb6f2a53c 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, + isContainerNode, + 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 + * container that holds it, so it can't land anywhere a regular block goes — + * moving one out of its container moves its children instead. Every other + * block passes through as itself. + */ +function dissolveContainerOnlyBlocks( + blocks: Block[], + pmSchema: Schema, +): Block[] { + return blocks.flatMap((block) => + isNamedOnly(pmSchema.nodes[block.type]) + ? dissolveContainerOnlyBlocks(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, [], { fixContainers: false }); insertBlocks( tr, - flattenColumns(blocks), + dissolveContainerOnlyBlocks(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 container-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 = dissolveContainerOnlyBlocks([block], editor.pmSchema)[0]; + const type = first && editor.pmSchema.nodes[first.type]; + return type && isContainerNode(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..cf24ec0556 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 { containerSchema } from "../../containers/containers.fixture.js"; /** * Custom test setup with a document designed to reproduce nesting/unnesting bugs. @@ -646,6 +647,223 @@ describe("unnestBlock / liftListItem", () => { }); }); +// A second editor, on a schema that has container blocks. `setupNestTestEnv` +// builds a default-schema editor, which can't express any of the cases below. +function setupContainerNestTestEnv() { + let editor: BlockNoteEditor; + const div = document.createElement("div"); + + beforeAll(() => { + editor = BlockNoteEditor.create({ schema: containerSchema }); + 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 containers. +describe("canNestBlock / canUnnestBlock around containers", () => { + const withContainerEditor = setupContainerNestTestEnv(); + + it("Reports that a block cannot be nested under a container sibling", () => { + const editor = withContainerEditor([ + { + 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 container's child cannot be unnested out of it", () => { + const editor = withContainerEditor([ + { + 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 = withContainerEditor([ + { 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 container's children", () => { + const editor = withContainerEditor([ + { + 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); + }); +}); + +// 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 container's children", () => { + const withContainerEditor = setupContainerNestTestEnv(); + + 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 = withContainerEditor([ + { 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 = withContainerEditor([ + { + 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 = withContainerEditor([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..0f28679300 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 ); } @@ -175,6 +178,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 +188,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 +211,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 +227,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 container 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..5d693850ee 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, + getAncestorContainers, +} 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 { fixContainersById } from "../../containers/fixContainer.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +25,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixContainers?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,13 +46,21 @@ export function removeAndInsertBlocks< ), ); const removedBlocks: Block[] = []; - const columnListPositions = new Set(); + // Ancestor containers of removed blocks, to repair afterwards. Tracked by + // node id (not position) since the removals and earlier repairs shift + // positions; recorded with their depth so repairs run deepest-first. + const containersToFix: { id: string; depth: number }[] = []; const idOfFirstBlock = typeof blocksToRemove[0] === "string" ? 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 container of getAncestorContainers($pos.doc, $pos.pos)) { + if (!containersToFix.some((c) => c.id === container.id)) { + containersToFix.push(container); + } } + // When the block is the only child of a nested `blockGroup`, delete the + // group with it (`blockGroup` acting as a `min: 1` container that unwraps + // when emptied). This can't route through `fixContainer`: 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 containers the removed blocks lived in (e.g. collapses + // emptied columns/columnLists), deepest-first. Callers where the removal + // isn't a deletion can opt out, e.g. `moveBlocks` re-inserts the blocks + // elsewhere and deliberately leaves emptied containers as-is. + if (options.fixContainers !== false) { + fixContainersById(tr, containersToFix); } // Converts the nodes created from `blocksToInsert` into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts deleted file mode 100644 index 3097851f47..0000000000 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Slice, type Node } from "prosemirror-model"; -import { type Transaction } from "prosemirror-state"; -import { ReplaceAroundStep } from "prosemirror-transform"; - -/** - * Checks if a `column` node is empty, i.e. if it has only a single empty - * paragraph. - * @param column The column to check. - * @returns Whether the column is empty. - */ -export function isEmptyColumn(column: Node) { - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - const blockContainer = column.firstChild; - if (!blockContainer) { - throw new Error("Invalid column: does not have child node."); - } - - const blockContent = blockContainer.firstChild; - if (!blockContent) { - throw new Error("Invalid blockContainer: does not have child node."); - } - - return ( - column.childCount === 1 && - blockContainer.childCount === 1 && - blockContent.type.name === "paragraph" && - blockContent.content.content.length === 0 - ); -} - -/** - * Removes all empty `column` nodes in a `columnList`. A `column` node is empty - * if it has only a single empty block. If, however, removing the `column`s - * leaves the `columnList` that has fewer than two, ProseMirror will re-add - * empty columns. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos The position just before the `columnList` node. - */ -export function removeEmptyColumns(tr: Transaction, columnListPos: number) { - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - for ( - let columnIndex = columnList.childCount - 1; - columnIndex >= 0; - columnIndex-- - ) { - const columnPos = tr.doc - .resolve($columnListPos.pos + 1) - .posAtIndex(columnIndex); - const $columnPos = tr.doc.resolve(columnPos); - const column = $columnPos.nodeAfter; - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - if (isEmptyColumn(column)) { - tr.delete(columnPos, columnPos + column.nodeSize); - } - } -} - -/** - * Fixes potential issues in a `columnList` node after a - * `blockContainer`/`column` node is (re)moved from it: - * - * - Removes all empty `column` nodes. A `column` node is empty if it has only - * a single empty block. - * - If all but one `column` nodes are empty, replaces the `columnList` with - * the content of the non-empty `column`. - * - If all `column` nodes are empty, removes the `columnList` entirely. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos - * @returns The position just before the `columnList` node. - */ -export function fixColumnList(tr: Transaction, columnListPos: number) { - removeEmptyColumns(tr, columnListPos); - - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - if (columnList.childCount > 2) { - // Do nothing if the `columnList` has more than two non-empty `column`s. In - // the case that the `columnList` has exactly two columns, we may need to - // still remove it, as it's possible that one or both columns are empty. - // This is because after `removeEmptyColumns` is called, if the - // `columnList` has fewer than two `column`s, ProseMirror will re-add empty - // `column`s until there are two total, in order to fit the schema. - return; - } - - if (columnList.childCount < 2) { - // Throw an error if the `columnList` has fewer than two columns. After - // `removeEmptyColumns` is called, if the `columnList` has fewer than two - // `column`s, ProseMirror will re-add empty `column`s until there are two - // total, in order to fit the schema. So if there are fewer than two here, - // either the schema, or ProseMirror's internals, must have changed. - throw new Error("Invalid columnList: contains fewer than two children."); - } - - const firstColumnBeforePos = columnListPos + 1; - const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); - const firstColumn = $firstColumnBeforePos.nodeAfter; - - const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; - const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); - const lastColumn = $lastColumnAfterPos.nodeBefore; - - if (!firstColumn || !lastColumn) { - throw new Error("Invalid columnList: does not contain children."); - } - - const firstColumnEmpty = isEmptyColumn(firstColumn); - const lastColumnEmpty = isEmptyColumn(lastColumn); - - if (firstColumnEmpty && lastColumnEmpty) { - // Removes `columnList` - tr.delete(columnListPos, columnListPos + columnList.nodeSize); - - return; - } - - if (firstColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of last `column`. - lastColumnAfterPos - lastColumn.nodeSize + 1, - lastColumnAfterPos - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } - - if (lastColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of first `column`. - firstColumnBeforePos + 1, - firstColumnBeforePos + firstColumn.nodeSize - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } -} diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap index 8cd297eaee..f76031684d 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/__snapshots__/splitBlock.test.ts.snap @@ -621,23 +621,6 @@ exports[`Test splitBlocks > Block has children 1`] = ` }, "type": "paragraph", }, - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Para", - "type": "text", - }, - ], - "id": "paragraph-with-children", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, { "children": [ { @@ -676,6 +659,23 @@ exports[`Test splitBlocks > Block has children 1`] = ` "type": "paragraph", }, ], + "content": [ + { + "styles": {}, + "text": "Para", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], "content": [ { "styles": {}, 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..670839a66a 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 { containerSchema } from "../../containers/containers.fixture.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { splitBlockCommand } from "./splitBlock.js"; @@ -147,3 +148,185 @@ 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 container's children holder is a +// different node type than `blockGroup`, so these pin that the split lands +// inside the container rather than tearing it open. +describe("Test splitBlocks inside containers", () => { + const getContainerEditor = setupTestEnv({ + schema: containerSchema, + 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 splitContainerBlock(blockId: string, offset: number) { + const editor = getContainerEditor(); + + 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 container 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), + ); + } + + function textOf(block: { content?: any }) { + return (block.content as { text: string }[]).map((c) => c.text).join(""); + } + + it("Splits a block inside a container in place", () => { + expect(splitContainerBlock("callout-child-0", 7)).toBe(true); + + const document = getContainerEditor().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(() => + getContainerEditor().prosemirrorState.doc.check(), + ).not.toThrow(); + }); + + it("Keeps the block's children on the first half of the split", () => { + expect(splitContainerBlock("callout-child-1", 7)).toBe(true); + + const callout = getContainerEditor().document[1]; + + expect(callout.children.map(textOf)).toEqual([ + "Callout child", + "Callout", + " heading", + ]); + // The children stay with the first half, as they do at the top level. + expect(callout.children[2].children).toEqual([]); + expect(callout.children[1].children.map((child) => child.id)).toEqual([ + "nested-child", + ]); + + expect(() => + getContainerEditor().prosemirrorState.doc.check(), + ).not.toThrow(); + }); + + it("Splits a block inside a nested container", () => { + expect(splitContainerBlock("cell-0-p", 4)).toBe(true); + + const grid = getContainerEditor().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(() => + getContainerEditor().prosemirrorState.doc.check(), + ).not.toThrow(); + }); + + it("Does not split a container block itself", () => { + const before = getContainerEditor().document; + + expect(splitContainerBlock("callout-0", 0)).toBe(false); + + expect(getContainerEditor().document).toEqual(before); + }); +}); + +describe("split child ownership", () => { + it.each(["start", "middle", "end"] as const)( + "keeps children with the intended half when splitting at the %s", + (where) => { + 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); + const index = editor.document.findIndex((block) => block.id === id); + const [first, second] = editor.document.slice(index, index + 2); + expect((where === "start" ? second : first).children).toEqual(children); + expect((where === "start" ? first : second).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..74ffcd7067 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -55,7 +55,23 @@ export const splitBlockTr = ( }, ]; + // At the start, children follow the entire title to the second block. + // Otherwise they belong to the first half, all within the same undo step. + const children = posInBlock === info.contentStart ? undefined : info.children; + 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..40a2537cd9 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 { containerSchema } from "../../containers/containers.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,257 @@ describe("Test updateBlock minimal steps", () => { expect(() => editor._tiptapEditor.state.doc.check()).not.toThrow(); }); }); + +// Changing a block's type across the content/container 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 getContainerEditor = setupTestEnv({ + schema: containerSchema, + 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, and the + // rebuilt node is minted a fresh ID. That is long-standing behaviour, not + // something the container work introduced, but converting a paragraph into a + // container is a far more ordinary action than the paragraph/column + // conversions that used to be the only way to reach this path. These tests + // therefore address blocks by position, and the first one pins the ID loss so + // that fixing it shows up as a deliberate change. + it("Moves inline content into a child paragraph when becoming a container", () => { + const editor = getContainerEditor(); + 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).not.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 container's children when there is no content to carry", () => { + const editor = getContainerEditor(); + 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 paragraphs for the + // pair's `min: 2`. + const block = editor.document[1] as any; + expect(block.type).toBe("pair"); + expect(block.children).toHaveLength(2); + expect(block.children.map((child: any) => child.content)).toEqual([[], []]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Puts carried content before existing children", () => { + const editor = getContainerEditor(); + 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 container", () => { + const editor = getContainerEditor(); + // Table content isn't an inline array, so there is no sensible paragraph to + // wrap it in. It's dropped, and the container 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 = getContainerEditor(); + 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 container", () => { + const editor = getContainerEditor(); + 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 existing children when a container drops the content", () => { + const editor = getContainerEditor(); + // The carried content has nowhere to go, but the block's own children are + // regular blocks the pair can still hold. + expect(() => + editor.transact((tr) => + updateBlock(tr, "paragraph-with-text-and-children", { + type: "pair", + }), + ), + ).not.toThrow(); + + const block = editor.document[2] as any; + expect(block.type).toBe("pair"); + // The kept child survives; the pair's `min: 2` is met by an empty fill. + expect(block.children.map((child: any) => child.id)).toContain( + "existing-child", + ); + expect(block.children).toHaveLength(2); + expect( + block.children.find((child: any) => child.id === "existing-child") + .content, + ).toEqual([{ type: "text", text: "Existing child", styles: {} }]); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Keeps a container's children when changing between container types", () => { + const editor = getContainerEditor(); + editor.transact((tr) => updateBlock(tr, "callout-0", { type: "pair" })); + + // Neither container holds content of its own, so the children move across + // untouched rather than being re-seeded; the pair's `min: 2` is met by an + // empty fill. + const block = editor.document[4] as any; + expect(block.type).toBe("pair"); + expect(block.children.map((child: any) => child.id)).toContain( + "callout-child", + ); + expect(block.children).toHaveLength(2); + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + }); + + it("Keeps a container's children alongside the content it gains", () => { + const editor = getContainerEditor(); + editor.transact((tr) => + updateBlock(tr, "callout-0", { + type: "heading", + content: "Now a heading", + }), + ); + + // The container 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 container's props without rebuilding it", () => { + const editor = getContainerEditor(); + editor.transact((tr) => + updateBlock(tr, "callout-0", { props: { flavor: "warning" } }), + ); + + // No shape change, so this is an in-place attribute update: the container + // 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 container's children and invents no content when becoming a block", () => { + const editor = getContainerEditor(); + editor.transact((tr) => + updateBlock(tr, "callout-0", { type: "paragraph" }), + ); + + const block = editor.document[4] as any; + expect(block.type).toBe("paragraph"); + 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..5014b45184 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, + isContainerNode, +} 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,56 @@ 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 container'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 (isContainerNode(newNodeType)) { + children.unshift({ type: "paragraph", content: existingBlock.content }); + } + } + const replacementNode = blockToNode( { - children: existingBlock.children, // if no children are passed in, use existing children + ...(content ? { content } : {}), + // Omit empty children so a new container 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 +194,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 +212,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 +241,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 +265,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 +550,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 +561,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. (Containers 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 +669,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/containers/containers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts new file mode 100644 index 0000000000..9d96276b73 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts @@ -0,0 +1,264 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { containerSchema } from "./containers.fixture.js"; + +// Keymap tests for container blocks, split off from the node-environment +// `containers.test.ts`. tiptap can only reach `handleKeyDown` through a +// mounted view, so the editor is mounted and focused here and the keys are +// pressed for real. + +const schema = containerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +let div: HTMLElement; + +beforeAll(() => { + div = document.createElement("div"); + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +/** Puts the caret at the given position and presses the key. */ +async function pressKey( + key: string, + at: { block: string; placement: "start" | "end" }, +) { + editor.setTextCursorPosition(at.block, at.placement); + editor.focus(); + await userEvent.keyboard(`{${key}}`); +} + +describe("children keyboard handling", () => { + it("Enter on an empty last child escapes the container", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "Hello" }, + { id: "c-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "c-p-1", placement: "end" }); + + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]); + expect(editor.document.map((block) => block.type)).toEqual([ + "callout", + "paragraph", + "paragraph", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "c-0", + "c-p-1", + "trailing", + ]); + // The caret moves out with the block. + expect(editor.getTextCursorPosition().block.id).toBe("c-p-1"); + }); + + it("Enter escape ascends past levels that can't hold the block", async () => { + // A grid holds only cells, so a block escaping the last cell can't stop + // at the grid level. It lands below the grid itself. + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "g-c-0", + children: [{ id: "g-p-0", type: "paragraph", content: "A" }], + }, + { + type: "gridCell", + id: "g-c-1", + children: [ + { id: "g-p-1", type: "paragraph", content: "B" }, + { id: "g-p-2", type: "paragraph", content: "" }, + ], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + await pressKey("Enter", { block: "g-p-2", placement: "end" }); + + expect(editor.getBlock("g-c-1")!.children.map((child) => child.id)).toEqual( + ["g-p-1"], + ); + expect(editor.document.map((block) => block.id)).toEqual([ + "g-0", + "g-p-2", + "trailing", + ]); + expect(editor.getTextCursorPosition().block.id).toBe("g-p-2"); + }); + + it("Enter on an empty block mid-container stays inside", async () => { + // The escape only fires at the end of the container. An empty block with + // siblings after it never ejects. + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "Hello" }, + { id: "c-p-1", type: "paragraph", content: "" }, + { id: "c-p-2", type: "paragraph", content: "World" }, + ], + }, + ]); + + await pressKey("Enter", { block: "c-p-1", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + expect(editor.getBlock("c-0")!.children).toHaveLength(4); + }); + + it("Backspace at the start of a container's first child moves it out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Backspace", { block: "c-p-0", placement: "start" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "c-p-0", + "c-0", + ]); + expect(editor.getBlock("c-p-0")!.content).toEqual([ + { type: "text", text: "First", styles: {} }, + ]); + }); + + it("Backspace at the start of a block after a container moves it inside", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Backspace", { block: "after", placement: "start" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + expect(editor.getBlock("after")!.content).toEqual([ + { type: "text", text: "After", styles: {} }, + ]); + }); + + it("Delete at the end of a block before a container pulls its first child out", async () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + + await pressKey("Delete", { block: "before", placement: "end" }); + + expect(editor.document.map((block) => block.id)).toEqual([ + "before", + "c-p-0", + "c-0", + ]); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + }); + + it("Delete at the end of a container's last child pulls the next block in", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + + await pressKey("Delete", { block: "c-p-0", placement: "end" }); + + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + expect(editor.document.map((block) => block.id)).toEqual(["c-0"]); + }); +}); + +// HTML round-trips (full, external, clipboard) live with the parse rules in +// `schema/blocks/containerParse.browser.test.ts`. +describe("children conversion", () => { + it("flattens containers to their children in markdown export", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "In callout" }, + { id: "c-p-1", type: "heading", content: "Heading in callout" }, + ], + }, + ]); + + const markdown = editor.blocksToMarkdownLossy(editor.document); + expect(markdown).toContain("In callout"); + expect(markdown).toContain("# Heading in callout"); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts new file mode 100644 index 0000000000..c0ec673e3b --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -0,0 +1,101 @@ +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + container: true, + }, + { render: renderDiv }, +)(); + +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + container: true, + children: { + allow: ["gridCell"], + min: 2, + }, + }, + { render: renderDiv }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + container: true, + placeable: "namedOnly", + }, + { render: renderDiv }, +)(); + +// A container that requires two children, so dropping below `min` dissolves +// it into the survivors. +const Pair = createBlockSpec( + { + type: "pair" as const, + propSchema: {}, + content: "none", + container: true, + children: { min: 2 }, + }, + { render: renderDiv }, +)(); + +// 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 containerSchema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + alert: Alert, + grid: Grid, + gridCell: GridCell, + pair: Pair, + } as const, +}); diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts new file mode 100644 index 0000000000..faf537dc7b --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,690 @@ +// @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 { containerSchema } from "./containers.fixture.js"; + +type PartialBlock = (typeof containerSchema)["PartialBlock"]; + +// Document-model behaviour of container 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 +// `containers.browser.test.ts`. + +const schema = containerSchema; + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; + +beforeAll(() => { + editor = BlockNoteEditor.create({ schema }); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +describe("children insertion & filling", () => { + it.each([ + { block: { type: "callout" }, childType: "paragraph", min: 1 }, + { + block: { type: "callout", children: [] }, + childType: "paragraph", + min: 1, + }, + { block: { type: "pair" }, childType: "paragraph", 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: "container" }], "p-1", "after"); + const children = editor.getBlock("container")!.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 container round-tripped through the document", () => { + editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"); + const inserted = editor.getBlock("c-0")!; + + // `nodeToBlock` always emits an array, so a round-trip must not read an + // empty one as "unspecified" and 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 containers", () => { + editor.insertBlocks( + [ + { + type: "callout", + id: "c-0", + children: [ + { type: "heading", content: "In callout" }, + { + type: "callout", + id: "c-1", + children: [{ type: "paragraph", content: "Nested" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.type)).toEqual([ + "heading", + "callout", + ]); + expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph"); + }); + + it("enforces a restricted container's allow list", () => { + editor.insertBlocks( + [ + { + type: "grid", + id: "g-0", + children: [{ type: "gridCell" }, { type: "gridCell" }], + }, + ], + "p-1", + "after", + ); + expect(editor.getBlock("g-0")!.children.map((child) => child.type)).toEqual( + ["gridCell", "gridCell"], + ); + + expect(() => + editor.insertBlocks( + [ + { + type: "grid", + children: [ + { type: "paragraph", content: "not a cell" }, + { type: "paragraph", content: "not a cell" }, + ], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); + + // The `allow: "blocks"` wildcard compiles to the regular blocks plus the + // containers placeable anywhere, so a namedOnly block only fits where a + // parent names it explicitly: not at the root, and not under a wildcard + // container. + 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("container keyboard defaults", () => { + // A container's children can't be outdented, so its defaults say so: an + // empty last child leaves the container instead. + it("defaults to leaving the container 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("container nodes", () => { + // No container 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 container and wraps the content in a spurious + // `blockGroup`, corrupting the document. + it("leaves every container 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 container to after it, paste it back over itself, + // and the document must come back unchanged. Marking the container + // `isolating` instead re-nests the whole fragment a level too deep. + it.each(["callout", "pair"])( + "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 container below its `min` stayed there for the life of the +// document. +describe("initialContent enforcement", () => { + const createWith = (initialContent: PartialBlock[]) => { + return BlockNoteEditor.create({ schema, initialContent }); + }; + + it("fills an explicitly empty `children` array up to `min`", () => { + const loaded = createWith([{ type: "callout", id: "c-0", children: [] }]); + + const callout = loaded.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + + loaded._tiptapEditor.destroy(); + }); + + it("fills a container 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 container 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 container 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 + // container 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 container unwraps to", () => { + editor.replaceBlocks(editor.document, [ + { + type: "pair", + id: "pair-0", + children: [ + { id: "pair-a", type: "paragraph", content: "A" }, + { id: "pair-b", type: "paragraph", content: "Survivor" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.setTextCursorPosition("pair-b", "end"); + const offsetBefore = editor.prosemirrorState.selection.$from.parentOffset; + + editor.removeBlocks(["pair-a"]); + + expect(editor.getBlock("pair-0")).toBeUndefined(); + expect(editor.getTextCursorPosition().block.id).toBe("pair-b"); + 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 container is dropped even when the container + // 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 container 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("repair edge cases", () => { + // A container that explicitly allows being empty. `min: 0` compiles to a + // `*` content expression, so ProseMirror never pads it and repair leaves + // it alone. + const Tray = createBlockSpec( + { + type: "tray" as const, + propSchema: {}, + content: "none" as const, + container: true, + children: { allow: "blocks", min: 0 }, + }, + { + render: () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }, + }, + )(); + + it("keeps a `min: 0` container with zero children", () => { + const trayEditor = BlockNoteEditor.create({ + schema: containerSchema.extend({ + blockSpecs: { tray: Tray }, + }), + }); + try { + trayEditor.replaceBlocks(trayEditor.document, [ + { + type: "tray", + id: "t-0", + children: [{ id: "t-p-0", type: "paragraph", content: "" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // Removing its only (empty) child leaves zero children, which `min: 0` + // allows: the tray stays instead of dissolving, and nothing is padded + // back. + trayEditor.removeBlocks(["t-p-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 container 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 *container* 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` container left with one surviving child", () => { + editor.replaceBlocks(editor.document, [ + { + type: "pair", + id: "s-0", + children: [ + { id: "s-a", type: "paragraph", content: "A" }, + { id: "s-b", 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 `fixContainer`: 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 container", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "c-p-0"); + + // Previously threw "unexpected" for any partial selection touching a + // container (breaking comments/AI selection handling). + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.length).toBeGreaterThanOrEqual(1); + expect(result.blocks.map((block) => block.id)).toContain("before"); + }); +}); + +// Every mutation that can empty a container records its ancestors with +// `getAncestorContainers` and hands them to `fixContainersById`, which repairs +// them deepest-first. Removing a block therefore repairs the whole chain it +// sat in, not just the container directly holding it. +describe("ancestor container repair", () => { + it("repairs every container 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: "paragraph", content: "Kept" }, + { id: "s-p-1", type: "paragraph", content: "Removed" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // Blocks from two different containers in one call: each container is + // recorded once and repaired on its own terms. + editor.removeBlocks(["c-p-0", "s-p-1"]); + + // Both containers 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 container", () => { + 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 containers 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 + // container 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/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts new file mode 100644 index 0000000000..8206bea42e --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,164 @@ +import { Fragment, type Node, Slice } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; + +import { + isContainerNode, + isNamedOnly, +} from "../../../schema/blocks/children.js"; +import { getNodeById } from "../../nodeUtil.js"; + +/** + * Whether `node` is a container child the user has emptied out: a container + * (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 isEmptyContainerChild(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 (isContainerNode(node.type)) { + if (node.childCount === 1) { + return isEmptyContainerChild(node.firstChild!); + } + // A container left with no children at all — its last child was removed + // and no fill happened on the way. A `min: 0` container in this state is + // valid and stays; any other is broken structure. + if (node.childCount === 0) { + return (node.type.spec.blockConfig?.children?.min ?? 1) >= 1; + } + return false; + } + return false; +} + +/** + * Repairs the container at `containerPos` after children were (re)moved from + * it: drops the ones the user emptied, and dissolves the container when too + * few are left for it to mean anything (a column list with one column is just + * that column's blocks). + * + * A container that only exists inside another container (a column) is left to + * its parent, which is the thing that decides whether it still belongs. + * + * @param containerPos The position just before the container node. + * @internal + */ +export function fixContainer(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + // A `namedOnly` container (a column) keeps existing: it is its parent's + // decision whether the container still belongs, and ProseMirror pads it + // back up to its minimum when children are removed. + if (isNamedOnly(container.type)) { + return; + } + + const min = container.type.spec.blockConfig?.children?.min ?? 1; + const survivors: { node: Node; offset: number }[] = []; + const emptied: { from: number; to: number }[] = []; + container.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + survivors.push({ node: child, offset }); + } else if (isContainerNode(child.type)) { + const from = containerPos + 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 container 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 = containerPos + 1 + offset; + // A `namedOnly` survivor is lifted out of two wrappers at once - the column + // and the container - and any other survivor out of just the container. + const gap = isNamedOnly(survivor.type) + ? { from: survivorPos + 1, to: survivorPos + survivor.nodeSize - 1 } + : { from: survivorPos, to: survivorPos + survivor.nodeSize }; + + tr.step( + new ReplaceAroundStep( + containerPos, + containerPos + container.nodeSize, + gap.from, + gap.to, + Slice.empty, + 0, + // Not a pure unwrap: emptied siblings sit outside the gap and go with + // the container, 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 container 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( + containerPos, + containerPos + container.nodeSize, + Fragment.from(replacement), + ); +} + +/** + * Runs {@link fixContainer} on each of the given containers, looked up by ID + * in `tr`'s current doc. Containers are repaired deepest-first so that an + * inner repair (e.g. a column emptying out) is observed by the outer + * container's repair (e.g. its columnList unwrapping) in the same pass. + * Containers that no longer exist by the time their turn comes are skipped — + * an earlier repair may have removed them. + */ +export function fixContainersById( + tr: Transaction, + containers: { id: string; depth: number }[], +) { + [...containers] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (target && isContainerNode(target.node.type)) { + fixContainer(tr, target.posBeforeNode); + } + }); +} diff --git a/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts b/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts new file mode 100644 index 0000000000..8672da659d --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/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 { containerSchema } from "./containers.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 = containerSchema.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 container 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 container = editor.document[0]; + expect(text(editor, container.children[0].id)).toBe("First\nSecond"); + expect(container.children[1].id).toBe("body"); + editor.prosemirrorState.doc.check(); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts b/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts new file mode 100644 index 0000000000..a151e1e33a --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/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 { isContainerNode } from "../../../schema/blocks/children.js"; +import { getBlockInfoAt } from "../../getBlockInfoFromPos.js"; +import { getNodeById } from "../../nodeUtil.js"; +import { containerSchema } from "./containers.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 pure container) is the control. + +const schema = containerSchema; +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(isContainerNode(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 pure container", () => { + 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 container 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 container 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/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..c381722789 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -8,6 +8,8 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { isContainerNode } from "../../../../schema/blocks/children.js"; +import { containerRootDOM } from "../../../../schema/blocks/createSpec.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -224,10 +226,11 @@ function serializeBlock< const blockImplementation = editor.blockImplementations[block.type as any] .implementation as BlockImplementation; + const blockWithDefaults = { ...block, props, children: block.children ?? [] }; const ret = blockImplementation.toExternalHTML?.call( {}, - { ...block, props } as any, + blockWithDefaults as any, editor as any, { nestingLevel, @@ -235,16 +238,25 @@ function serializeBlock< ) || blockImplementation.render.call( {}, - { ...block, props } as any, + blockWithDefaults as any, editor as any, ); const elementFragment = doc.createDocumentFragment(); - if ((ret.dom as HTMLElement).classList.contains("bn-block-content")) { + // React renders can return a fragment around the root element. + const rootElement = containerRootDOM(ret); + + const blockContentRoot = rootElement?.classList.contains("bn-block-content") + ? rootElement + : ret.contentDOM?.closest(".bn-block-content"); + + elementFragment.append(ret.dom); + + if (blockContentRoot) { const blockContentDataAttributes = [ ...attrs, - ...Array.from((ret.dom as HTMLElement).attributes), + ...Array.from(blockContentRoot.attributes), ].filter( (attr) => attr.name.startsWith("data") && @@ -256,26 +268,32 @@ 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); + (blockContentRoot.firstChild! as HTMLElement).setAttribute( + attr.name, + attr.value, + ); } - addAttributesAndRemoveClasses(ret.dom.firstChild! as HTMLElement); + addAttributesAndRemoveClasses(blockContentRoot.firstChild! as HTMLElement); if (nestingLevel > 0) { - (ret.dom.firstChild! as HTMLElement).setAttribute( + (blockContentRoot.firstChild! as HTMLElement).setAttribute( "data-nesting-level", nestingLevel.toString(), ); } - elementFragment.append(...Array.from(ret.dom.childNodes)); + // Unwrap the content in place, preserving any surrounding frame. + blockContentRoot.replaceWith(...Array.from(blockContentRoot.childNodes)); } else { - elementFragment.append(ret.dom); + if (isContainerNode(editor.pmSchema.nodes[block.type as any])) { + // Pasted external HTML gets fresh IDs; scope parsing to actual children. + rootElement?.removeAttribute("data-id"); + const childrenDOM = + ("childrenDOM" in ret && ret.childrenDOM) || ret.contentDOM; + childrenDOM?.setAttribute("data-children-of", block.type!); + } if (nestingLevel > 0) { - (ret.dom as HTMLElement).setAttribute( - "data-nesting-level", - nestingLevel.toString(), - ); + rootElement?.setAttribute("data-nesting-level", nestingLevel.toString()); } } @@ -301,11 +319,9 @@ function serializeBlock< // tables) fill their `contentDOM` with child blocks later on, and code // blocks would turn the placeholder into literal content. const blockNodeType = editor.pmSchema.nodes[block.type as any]; - if ( - blockNodeType?.inlineContent && - !blockNodeType.spec.code && - ret.contentDOM.childNodes.length === 0 - ) { + const needsPlaceholder = + !!blockNodeType?.inlineContent && !blockNodeType.spec.code; + if (needsPlaceholder && ret.contentDOM.childNodes.length === 0) { ret.contentDOM.appendChild(doc.createTextNode(EMPTY_BLOCK_PLACEHOLDER)); } } diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..1ea7605bab 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 { isContainerNode } from "../../../../schema/blocks/children.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, @@ -14,6 +15,7 @@ import { } from "../../../nodeConversions/blockToNode.js"; import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js"; +import { setBlockColorAttributes } from "../../../../pm-nodes/BlockContainer.js"; export function serializeInlineContentInternalHTML< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -159,6 +161,8 @@ function serializeBlock< editor as any, ); + const isContainer = isContainerNode(editor.pmSchema.nodes[block.type as any]); + if (ret.contentDOM && block.content) { const ic = serializeInlineContentInternalHTML( editor, @@ -170,9 +174,15 @@ function serializeBlock< ret.contentDOM.appendChild(ic); } - const pmType = editor.pmSchema.nodes[block.type as any]; - - if (pmType.isInGroup("bnBlock")) { + if (isContainer) { + // Mark where the children live so the container's round-trip parse rule + // can scope itself to this element (`contentElement` in `getParseRules`). + // A render is free to put non-content UI text elsewhere in its DOM + // (button labels, captions, ...), and without the marker that text would + // parse back as document content. + if (ret.contentDOM) { + ret.contentDOM.setAttribute("data-children-of", block.type!); + } if (block.children && block.children.length > 0) { const fragment = serializeBlocks( editor, @@ -196,11 +206,34 @@ function serializeBlock< dom: HTMLElement; contentDOM?: HTMLElement; }; + if (bc.contentDOM) { + setBlockColorAttributes(bc.contentDOM, props); + } - bc.contentDOM?.appendChild(ret.dom); + // Frames wrap the content and its child group in static HTML too. The DOM + // render context lets interactive frames export without browser view state. + const renderFrame = impl.renderFrame; + const frame = renderFrame?.call( + { + renderType: "dom", + props: undefined, + blockContentDOMAttributes: + editor._tiptapEditor.extensionManager.extensions.find( + (extension) => extension.name === block.type, + )?.options.domAttributes?.blockContent || {}, + propSchema: editor.schema.blockSchema[block.type!].propSchema, + }, + { ...block, props, children }, + editor, + ); + if (frame) { + bc.contentDOM?.appendChild(frame.dom); + } + const contentDOM = frame?.slot ?? bc.contentDOM; + contentDOM?.appendChild(ret.dom); if (block.children && block.children.length > 0) { - bc.contentDOM?.appendChild( + contentDOM?.appendChild( serializeBlocksInternalHTML(editor, block.children, serializer, options), ); } diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts index 3e45c14330..ddd0d00b7c 100644 --- a/packages/core/src/api/getBlockInfoFromPos.test.ts +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -1,10 +1,13 @@ +import { containerSchema } from "./blockManipulation/containers/containers.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 { + getAncestorContainers, 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,139 @@ describe("docToBlocks round trip with suggested deletions", () => { expect(new Set(ids).size).toBe(ids.length); }); }); + +describe("block info for containers", () => { + let editor: BlockNoteEditor< + typeof containerSchema.blockSchema, + typeof containerSchema.inlineContentSchema, + typeof containerSchema.styleSchema + >; + beforeEach(() => { + editor = BlockNoteEditor.create({ schema: containerSchema }); + }); + 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 container children", () => { + // Regression: `getParentBlockInfo` used to skip the container level for + // container 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 container 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 container 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 = getAncestorContainers(tr.doc, deep.posBeforeNode); + + // Only the container nodes: the `blockGroup`/`blockContainer` levels + // between them are not containers and must not be repaired. + expect(ancestors.map(({ id }) => id)).toEqual(["c-0", "cell-a", "g-0"]); + // Depths shrink outwards, which is what `fixContainersById` sorts on. + expect(ancestors.map(({ depth }) => depth)).toEqual( + [...ancestors.map(({ depth }) => depth)].sort((a, b) => b - a), + ); + + // A top-level block has no container ancestors at all. + const trailing = getNodeById("trailing", tr.doc)!; + expect(getAncestorContainers(tr.doc, trailing.posBeforeNode)).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index a9512f0f6a..822fc14044 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, + isContainerNode, +} from "../schema/blocks/children.js"; import type { BlockConfig } from "../schema/blocks/types.js"; /** @@ -319,7 +323,7 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { afterPos: beforePos + node.nodeSize, }; - if (node.type.isInGroup("bnBlock") && node.type.isInGroup("childContainer")) { + if (isContainerNode(node.type)) { return { hasContent: false, block, @@ -452,7 +456,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 +587,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 || !isContainerNode(child.type)) { break; } info = getBlockInfoFromNode( @@ -599,3 +597,71 @@ export function getInsertionPos( } return null; } + +/** + * Resolves a block to its first leaf block: the block itself when it is not a + * container, otherwise the first leaf of its first child. Returns `null` for + * an empty container. + */ +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 containers until it reaches a position where `nodeType` fits. + * `side` picks which edge of each climbed container to land on: `"before"` for + * moves that put a block above the containers it leaves (Backspace move-out), + * `"after"` for moves that put it below them (Enter-exit). + * + * 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 && isContainerNode(parent.type)) { + pos = side === "before" ? $pos.before() : $pos.after(); + continue; + } + return undefined; + } +} + +/** + * The container ancestors of a position, outermost last, each with its block + * id and resolution depth. Used to re-run container repair (`fixContainersById`) + * on every container a mutation may have emptied. Position-based for the same + * reason as `ascendToInsertablePos`: selections and mapped positions are the + * natural inputs. + */ +export function getAncestorContainers( + doc: Node, + pos: number, +): { id: string; depth: number }[] { + const $pos = doc.resolve(pos); + const containers: { id: string; depth: number }[] = []; + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if (isContainerNode(ancestor.type) && ancestor.attrs.id) { + containers.push({ id: ancestor.attrs.id, depth }); + } + } + return containers; +} diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index af5c0ba1b7..5f46f4b418 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, + isContainerNode, +} 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 (isContainerNode(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..101c87020a 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,63 +1,44 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { isContainerNode, isNamedOnly } 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 flattenContainer = + isContainerNode(node.type) && + (!childrenConfig || + isNamedOnly(node.type) || + node.childCount < (childrenConfig.min ?? 1)); - 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 containers that cannot stand alone contribute + // their children. Complete blocks already include their descendants. + if ( + incompleteBlock || + flattenContainer || + !node.type.isInGroup("bnBlock") + ) { + node.forEach((child) => visit(child, flattenContainer ? 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..ff890c09ac 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,5 +1,6 @@ import { Mark, Node, Slice } from "@tiptap/pm/model"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { isContainerNode } from "../../schema/blocks/children.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { BlockSchema, @@ -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,7 +559,9 @@ export function prosemirrorSliceToSlicedBlocks< blockCutAtStart: string | undefined; blockCutAtEnd: string | undefined; } { - if (node.type.name !== "blockGroup") { + // Both `blockGroup` and container nodes (columnList, column, callout, + // ...) hold block children directly, so both can be processed here. + if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) { throw new Error("unexpected"); } const blocks: Block[] = []; @@ -568,37 +569,46 @@ export function prosemirrorSliceToSlicedBlocks< 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, - ); - } - 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 isContainer = isContainerNode(blockContainer.type); + if (!isContainer) { + if (blockContainer.type.name !== "blockContainer") { throw new Error("unexpected"); } + if (blockContainer.childCount === 0) { + return; + } + if (blockContainer.childCount > 2) { + throw new Error( + "unexpected, blockContainer.childCount: " + + blockContainer.childCount, + ); + } + } + + const omittedParent = + !isContainer && blockContainer.firstChild!.type.name === "blockGroup"; + if (omittedParent && !isFirstBlock) { + throw new Error("unexpected"); + } + + // Open containers and regular parents whose content was cut away both + // contribute their selected children, without their own wrapper. + if ( + omittedParent || + (isContainer && + ((isFirstBlock && openStart > 0) || (isLastBlock && openEnd > 0))) + ) { const ret = processNode( - blockContainer.firstChild!, - Math.max(0, openStart - 1), + isContainer ? blockContainer : blockContainer.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,6 +616,13 @@ export function prosemirrorSliceToSlicedBlocks< return; } + if (isContainer) { + blocks.push( + nodeToBlock(blockContainer, slice.content.firstChild!), + ); + return; + } + const block = nodeToBlock(blockContainer, slice.content.firstChild!); const childGroup = blockContainer.childCount > 1 ? blockContainer.child(1) : undefined; diff --git a/packages/core/src/blocks/Heading/block.ts b/packages/core/src/blocks/Heading/block.ts index 6b14204cc8..d523888601 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,27 @@ 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", + emptyChildEnter: open ? "stay" : "outdent", + }; + } + : undefined, parse(e) { if (allowToggleHeadings && e.tagName === "DETAILS") { const summary = e.querySelector(":scope > summary"); @@ -126,19 +156,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 +199,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 +216,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..031203dd5a 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,22 @@ 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", + 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 +87,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 +115,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/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..cb508b1360 --- /dev/null +++ b/packages/core/src/editor/blockColors.browser.test.ts @@ -0,0 +1,129 @@ +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(container: HTMLElement) { + const child = container.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 container = mount(type); + + expect(getComputedStyle(childContent(container)).color).toBe(RED); + expect(backgroundBehind(childContent(container))).toBe(BLUE_BACKGROUND); + }); + + it("follow changes to the block's props", () => { + const container = mount(type); + + editor!.updateBlock("parent", { + props: { textColor: "green", backgroundColor: "default" }, + }); + + expect(getComputedStyle(childContent(container)).color).toBe(GREEN); + expect(backgroundBehind(childContent(container))).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..c1626636b4 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -39,6 +39,7 @@ import { UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; +import { isContainerConfig } from "../../../schema/blocks/children.js"; import type { BlockNoteEditor, BlockNoteEditorOptions, @@ -62,7 +63,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 pure containers own their ID; titled blocks use blockContainer. + ...Object.values(editor.schema.blockSpecs) + .filter((spec) => isContainerConfig(spec.config)) + .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..b498498769 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 container block (`container: true`), and an exporter +// whose mappings return strings. +const box = createBlockSpec( + { + type: "box", + propSchema: {}, + content: "none", + container: true, + }, + { + render: () => { + const dom = document.createElement("div"); + return { dom, contentDOM: 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 container 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..e82e5ea026 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,26 @@ 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 container'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 containers (`createBlockSpec` doesn't keep `container: true` in the + // config type yet). + if (this.schema.blockSpecs[block.type]?.config.container === true) { + throw new Error( + `The mapping for container 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..dceb584600 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. Container 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..453c704c81 --- /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 112803b95d..5d4da252ce 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -20,8 +20,16 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; -import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; +import { + CONTAINER_SELECTOR, + getBlockFromElement, + getDraggableBlockFromElement, +} from "../blockDOM.js"; import { dragStart, unsetDragImage } from "./dragging.js"; +import { + getNestedBlockAtCursor, + getDirectChildBlocks, +} from "./sideMenuContainerGeometry.js"; export type SideMenuState< BSchema extends BlockSchema, @@ -37,7 +45,6 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250; function getBlockFromCoords( view: EditorView, coords: { left: number; top: number }, - adjustForColumns = true, ) { const elements = view.root.elementsFromPoint(coords.left, coords.top); @@ -46,21 +53,15 @@ function getBlockFromCoords( // probably a ui overlay like formatting toolbar etc continue; } - if (adjustForColumns) { - const column = element.closest("[data-node-type=columnList]"); - if (column) { - return getBlockFromCoords( - view, - { - // TODO can we do better than this? - left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself - top: coords.top, - }, - false, - ); - } - } - return getDraggableBlockFromElement(element, view); + const block = getBlockFromElement(element, view); + return block + ? { + ...block, + // Controls in a container's chrome belong to that container, even + // when they share a row with one of its child blocks. + isControl: !!element.closest('[contenteditable="false"]'), + } + : undefined; } return undefined; } @@ -71,6 +72,7 @@ function getBlockFromMousePos( y: number; }, view: EditorView, + isDraggable: (type: string) => boolean, ): { node: HTMLElement; id: string } | undefined { // Editor itself may have padding or other styling which affects // size/position, so we get the boundingRect of the first child (i.e. the @@ -101,6 +103,10 @@ function getBlockFromMousePos( return undefined; } + if (referenceBlock.isControl) { + return getDraggableBlockFromElement(referenceBlock.node, view, isDraggable); + } + /** * Because blocks may be nested, we need to check the right edge of the parent block: * ``` @@ -109,17 +115,26 @@ function getBlockFromMousePos( * ``` * Hovering at position x (left edge of BlockB) would return BlockA. * Instead, we check at position y (right edge of BlockA) to correctly identify BlockB. + * `elementsFromPoint` returns the deepest element at a point, so this single + * probe descends through any depth of regular nesting. + * + * For a container block, the probe is + * aimed at the innermost child under the cursor instead of the container + * itself. The container's own padding can exceed the probe inset, which + * would keep resolving the container even though the cursor is aligned with + * one of its children (making the child's menu jump away as the cursor + * moves towards it). */ - const referenceBlocksBoundingBox = - referenceBlock.node.getBoundingClientRect(); - return getBlockFromCoords( - view, - { - left: referenceBlocksBoundingBox.right - 10, - top: mousePos.y, - }, - false, - ); + const probeTarget = getNestedBlockAtCursor(referenceBlock.node, mousePos); + const target = getBlockFromCoords(view, { + left: probeTarget.getBoundingClientRect().right - 10, + top: mousePos.y, + }); + // Resolve layout before applying drag policy: columns have no handle, but + // their children do, and their gutter still needs to resolve those children. + return target + ? getDraggableBlockFromElement(target.node, view, isDraggable) + : undefined; } /** @@ -135,8 +150,6 @@ export class SideMenuView< private mousePos: { x: number; y: number } | undefined; - private hoveredBlock: HTMLElement | undefined; - public menuFrozen = false; public isDragOrigin = false; @@ -214,7 +227,11 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const blockSpecs = this.editor.schema.blockSpecs; + function isDraggable(type: string) { + return blockSpecs[type].implementation.meta?.draggable !== false; + } + const block = getBlockFromMousePos(this.mousePos, this.pmView, isDraggable); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -227,51 +244,48 @@ export class SideMenuView< } // Doesn't update if the menu is already open and the mouse cursor is still hovering the same block. - if ( - this.state?.show && - this.hoveredBlock?.hasAttribute("data-id") && - this.hoveredBlock?.getAttribute("data-id") === block.id - ) { + if (this.state?.show && this.state.block.id === block.id) { return; } - this.hoveredBlock = block.node; - - // Shows or updates elements. - if (this.editor.isEditable) { - const blockContentBoundingBox = block.node.getBoundingClientRect(); - const column = block.node.closest("[data-node-type=column]"); - const sideMenuBlock = this.editor.getBlock( - this.hoveredBlock!.getAttribute("data-id")!, - ); - if (!sideMenuBlock) { - if (this.state?.show) { - this.state.show = false; - this.hoveredBlock = undefined; - this.emitUpdate(this.state); - } - return; + const blockContentBoundingBox = block.node.getBoundingClientRect(); + // The closest container ancestor (a column, callout, ...), excluding + // the hovered block itself, which may be a draggable container. Blocks + // inside a container anchor the side menu to the container's block + // area rather than the editor's left edge, which would put the menu + // over unrelated content (or off-screen inside columns). + const container = block.node.parentElement?.closest(CONTAINER_SELECTOR); + const sideMenuBlock = this.editor.getBlock(block.id); + if (!sideMenuBlock) { + if (this.state?.show) { + this.state.show = false; + this.emitUpdate(this.state); } - this.state = { - show: true, - referencePos: new DOMRect( - column - ? // We take the first child as column elements have some default - // padding. This is a little weird since this child element will - // be the first block, but since it's always non-nested and we - // only take the x coordinate, it's ok. - column.firstElementChild!.getBoundingClientRect().x - : ( - this.pmView.dom.firstChild as HTMLElement - ).getBoundingClientRect().x, - blockContentBoundingBox.y, - blockContentBoundingBox.width, - blockContentBoundingBox.height, - ), - block: sideMenuBlock, - }; - this.updateState(this.state); + return; } + this.state = { + show: true, + referencePos: new DOMRect( + container + ? // We anchor to the container's first child block (rather than + // the container itself, which may have padding or its own + // chrome around the block area). This is a little weird since + // this element is the first block, but since it's always + // non-nested and we only take the x coordinate, it's ok. + ( + getDirectChildBlocks(container)[0] ?? + container.firstElementChild ?? + container + ).getBoundingClientRect().x + : (this.pmView.dom.firstChild as HTMLElement).getBoundingClientRect() + .x, + blockContentBoundingBox.y, + blockContentBoundingBox.width, + blockContentBoundingBox.height, + ), + block: sideMenuBlock, + }; + this.updateState(this.state); }; /** diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts new file mode 100644 index 0000000000..7d7ab91d09 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + getNestedBlockAtCursor, + getDirectChildBlocks, +} from "./sideMenuContainerGeometry.js"; + +// Exercise container hit testing with real layout, including nested columns. + +/** Attaches a tree to the document so the browser actually lays it out. */ +function mount(el: T): T { + document.body.appendChild(el); + mounted.push(el); + return el; +} + +let mounted: HTMLElement[] = []; + +afterEach(() => { + mounted.forEach((el) => el.remove()); + mounted = []; +}); + +function el(nodeType: string): HTMLElement { + const node = document.createElement("div"); + node.setAttribute("data-node-type", nodeType); + node.setAttribute("data-id", crypto.randomUUID()); + return node; +} + +/** The `blockOuter > blockContainer` chrome BlockNote renders around every + * regular block, with real text in it so it has a real height. */ +function regularChild(text = "block"): { + outer: HTMLElement; + blockContainer: HTMLElement; +} { + const outer = el("blockOuter"); + const blockContainer = el("blockContainer"); + blockContainer.textContent = text; + outer.append(blockContainer); + return { outer, blockContainer }; +} + +/** + * A column list laid out the way the real one is: a flex row of two columns, + * each holding one block. Nothing declares "horizontal". The browser puts the + * columns side by side and the module has to notice. + */ +function buildColumnList() { + const columnList = el("columnList"); + columnList.style.display = "flex"; + columnList.style.width = "400px"; + + const columnA = el("column"); + const columnB = el("column"); + for (const column of [columnA, columnB]) { + column.style.flex = "1"; + } + + const childA = regularChild("A"); + const childB = regularChild("B"); + columnA.append(childA.outer); + columnB.append(childB.outer); + columnList.append(columnA, columnB); + mount(columnList); + + return { columnList, columnA, columnB, childA, childB }; +} + +/** A callout: an ordinary block-flow container, so its children stack. */ +function buildVerticalContainer() { + const callout = el("callout"); + callout.style.width = "400px"; + const first = regularChild("first"); + const second = regularChild("second"); + callout.append(first.outer, second.outer); + mount(callout); + + return { callout, first, second }; +} + +describe("getDirectChildBlocks", () => { + it("returns direct child blocks, skipping nested grandchildren", () => { + const { columnList, columnA, columnB } = buildColumnList(); + + // The blocks inside each column must not come back as the list's own + // children. The `closest` check stops the walk one level down. + expect(getDirectChildBlocks(columnList)).toEqual([columnA, columnB]); + }); + + it("sees through blockOuter wrappers to the blockContainer child", () => { + const { columnA, childA } = buildColumnList(); + + // The column's own direct child is the wrapped blockContainer, not the + // blockOuter chrome (which isn't a block in the selector's sense). + expect(getDirectChildBlocks(columnA)).toEqual([childA.blockContainer]); + }); +}); + +describe("getNestedBlockAtCursor", () => { + it("keeps a regular block as the probe target", () => { + const { childA } = buildColumnList(); + expect( + getNestedBlockAtCursor(childA.blockContainer, { x: 10, y: 10 }), + ).toBe(childA.blockContainer); + }); + + it("descends into the hovered column's block", () => { + const { columnList, childA, childB } = buildColumnList(); + for (const child of [childA, childB]) { + const rect = child.blockContainer.getBoundingClientRect(); + expect( + getNestedBlockAtCursor(columnList, { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }), + ).toBe(child.blockContainer); + } + }); + + it("finds a stacked child from the container gutter", () => { + const { callout, second } = buildVerticalContainer(); + const rect = second.blockContainer.getBoundingClientRect(); + expect( + getNestedBlockAtCursor(callout, { + x: rect.left - 20, + y: rect.top + rect.height / 2, + }), + ).toBe(second.blockContainer); + }); + + it("finds a framed block's child when moving into the side-menu gutter", () => { + const parent = regularChild(""); + const frame = document.createElement("div"); + frame.style.padding = "12px 16px"; + const title = document.createElement("div"); + title.textContent = "Callout title"; + const child = regularChild("Callout body"); + frame.append(title, child.outer); + parent.blockContainer.append(frame); + parent.outer.style.width = "400px"; + mount(parent.outer); + + const rect = child.blockContainer.getBoundingClientRect(); + for (const x of [rect.left + 20, rect.left - 12, rect.left - 40]) { + expect( + getNestedBlockAtCursor(parent.blockContainer, { + x, + y: rect.top + rect.height / 2, + }), + ).toBe(child.blockContainer); + } + const titleRect = title.getBoundingClientRect(); + expect( + getNestedBlockAtCursor(parent.blockContainer, { + x: titleRect.left, + y: titleRect.top + titleRect.height / 2, + }), + ).toBe(parent.blockContainer); + }); + + it("keeps the container when the cursor misses its children", () => { + const { callout } = buildVerticalContainer(); + const rect = callout.getBoundingClientRect(); + expect( + getNestedBlockAtCursor(callout, { x: rect.left, y: rect.bottom + 10 }), + ).toBe(callout); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts new file mode 100644 index 0000000000..926271b5a1 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + rectIndexAtCursor, + type BlockRect, +} from "./sideMenuContainerGeometry.js"; + +// Hit testing works for both side-by-side and stacked children. + +const rect = ( + top: number, + bottom: number, + left: number, + right: number, +): BlockRect => ({ top, bottom, left, right }); + +// Two columns of a column list: same vertical band, adjacent horizontally. +const SIDE_BY_SIDE = [rect(0, 100, 0, 100), rect(0, 100, 100, 200)]; +// Two blocks of a callout: same horizontal band, stacked vertically. +const STACKED = [rect(0, 40, 0, 200), rect(50, 90, 0, 200)]; + +describe("rectIndexAtCursor", () => { + it("returns the rect whose x range contains the cursor (side-by-side)", () => { + // Both rects share the y range, so only x distinguishes them. The + // vertical-only fallback recorded for the first must not win over an x + // match found later in the list; otherwise hovering the second column of + // a row would resolve to its neighbour. + expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 150, y: 50 })).toBe(1); + expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 10, y: 50 })).toBe(0); + }); + + it("falls back to the first vertical match when x is in the gutter", () => { + // The cursor's y is in the first block's band but its x is left of it (the + // side-menu gutter). The first vertical match wins. + expect(rectIndexAtCursor(STACKED, { x: -20, y: 20 })).toBe(0); + }); + + it("returns undefined when the cursor misses every rect vertically", () => { + expect(rectIndexAtCursor(STACKED, { x: 10, y: 999 })).toBeUndefined(); + expect(rectIndexAtCursor(STACKED, { x: 10, y: -999 })).toBeUndefined(); + expect(rectIndexAtCursor([], { x: 10, y: 10 })).toBeUndefined(); + }); + + it("includes the rect edges", () => { + const single = [rect(0, 40, 0, 200)]; + expect(rectIndexAtCursor(single, { x: 0, y: 0 })).toBe(0); + expect(rectIndexAtCursor(single, { x: 200, y: 40 })).toBe(0); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts new file mode 100644 index 0000000000..dc4ae46c40 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts @@ -0,0 +1,56 @@ +import { BLOCK_SELECTOR } from "../blockDOM.js"; + +export function getDirectChildBlocks(container: Element): Element[] { + const children: Element[] = []; + for (const child of container.querySelectorAll(BLOCK_SELECTOR)) { + if (child.parentElement?.closest(BLOCK_SELECTOR) === container) { + children.push(child); + } + } + return children; +} + +export type BlockRect = { + top: number; + bottom: number; + left: number; + right: number; +}; + +// X-match wins over y-only match (disambiguates side-by-side children). +export function rectIndexAtCursor( + rects: BlockRect[], + mousePos: { x: number; y: number }, +): number | undefined { + let verticalMatch: number | undefined = undefined; + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if (mousePos.y < rect.top || mousePos.y > rect.bottom) { + continue; + } + if (mousePos.x >= rect.left && mousePos.x <= rect.right) { + return i; + } + verticalMatch = verticalMatch ?? i; + } + return verticalMatch; +} + +// Descend through child blocks, including regular blocks with padded frames. +export function getNestedBlockAtCursor( + element: Element, + mousePos: { x: number; y: number }, +): Element { + while (element.matches(BLOCK_SELECTOR)) { + const children = getDirectChildBlocks(element); + const index = rectIndexAtCursor( + children.map((child) => child.getBoundingClientRect()), + mousePos, + ); + if (index === undefined) { + break; + } + element = children[index]; + } + return element; +} diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts index c9e8648861..845d41b99f 100644 --- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts +++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts @@ -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/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/blockDOM.test.ts b/packages/core/src/extensions/blockDOM.test.ts new file mode 100644 index 0000000000..27f6fa1de6 --- /dev/null +++ b/packages/core/src/extensions/blockDOM.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + 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 block container 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 container block only when its type is draggable", () => { + const column = document.createElement("div"); + column.setAttribute("data-node-type", "column"); + column.setAttribute("data-id", "col"); + + expect( + getDraggableBlockFromElement(column, viewWith(column), 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"); +}); diff --git a/packages/core/src/extensions/blockDOM.ts b/packages/core/src/extensions/blockDOM.ts new file mode 100644 index 0000000000..bef930bda1 --- /dev/null +++ b/packages/core/src/extensions/blockDOM.ts @@ -0,0 +1,69 @@ +import type { EditorView } from "prosemirror-view"; + +// 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"])'; +export const CONTAINER_SELECTOR = `${BLOCK_SELECTOR}:not([data-node-type="blockContainer"])`; + +/** 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 { + if (!view.dom.contains(element)) { + return undefined; + } + const node = element.closest(BLOCK_SELECTOR); + if (!node || node === view.dom || !view.dom.contains(node)) { + 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 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; +} diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts deleted file mode 100644 index abc6bd2906..0000000000 --- a/packages/core/src/extensions/getDraggableBlockFromElement.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { EditorView } from "prosemirror-view"; - -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..3b089ec5cc 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 { fixContainersById } from "../../../api/blockManipulation/containers/fixContainer.js"; +import { nodeToBlock } from "../../../api/nodeConversions/nodeToBlock.js"; +import { isContainerNode } 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, + getAncestorContainers, + 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 container 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 containersToFix = getAncestorContainers(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; + fixContainersById(tr, containersToFix); + 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 container 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 container's trailing slot, or out of the + // current container 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 (!isContainerNode($pos.parent.type)) { + return false; + } + const $containerPos = tr.doc.resolve($pos.before()); + // Between columns, move into the previous column. Outside a + // container, move above the closest boundary that accepts us. + const prevSibling = $containerPos.nodeBefore; + if ( + isContainerNode($containerPos.parent.type) && + prevSibling && + isContainerNode(prevSibling.type) + ) { + target = getBlockInfoFromNode( + prevSibling, + $containerPos.pos - prevSibling.nodeSize, + ); + } else { + insertionPos = ascendToInsertablePos( + tr.doc, + $containerPos.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 container 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,34 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent || !blockInfo.children) { return false; } - const { content, children } = blockInfo; + const { children } = blockInfo; + + // A container allowed to hold no children still has a child + // container node, but 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 +420,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 +431,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 +445,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 container boundary. It may be inside + // the next sibling container, or follow the containers 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 && + isContainerNode($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 && !isContainerNode(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 +501,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 +546,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 +557,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 +589,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 +610,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 +617,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 +629,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 +661,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 +678,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 +711,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 +746,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 containers), moves the block out + // (double Enter exits the container). The block lands at the nearest enclosing position that + // accepts it. E.g. out of a column it skips the columnList, which + // holds only columns, and lands below it. Without this, Enter only + // ever creates new blocks within the container, so the cursor could + // never leave a trailing container. Shift+Enter still adds spacing + // inside a container. 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 container'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 container'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 +818,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 +872,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,19 +979,21 @@ 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 keepType = + selectionAtBlockStart || + keyboardOf(blockInfo.block.node, state.doc).splitKeepsType; chain() .deleteSelection() .command( splitBlockCommand( state.selection.from, - selectionAtBlockStart, + keepType, selectionAtBlockStart, ), ) @@ -877,42 +1008,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..16ce41df3e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,14 @@ 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 { + isContainerNode, + isContainerConfig, +} from "./schema/blocks/children.js"; +export { applyContainerAttributes } from "./schema/blocks/containerAttributes.js"; +export { + fixContainer, + isEmptyContainerChild, +} from "./api/blockManipulation/containers/fixContainer.js"; export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts index 86bd2ccb15..0fb1258e18 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -1,10 +1,91 @@ -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 { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js"; +import { isDocumentFragment } from "../schema/blocks/internal.js"; import { BlockNoteDOMAttributes } from "../schema/index.js"; +import { ignoreFrameChromeMutations } from "../schema/nodeViewMutations.js"; import { mergeCSSClasses } from "../util/browser.js"; import { suggestionMarks } from "./suggestionMarks.js"; +const dropEvents = new Set(["dragenter", "dragover", "dragleave", "drop"]); + +/** Adapts vanilla frames to the same lifecycle as framework node views. */ +function createFrameView( + props: NodeViewRendererProps, + editor: BlockNoteEditor, + fallback: HTMLElement, + blockContentDOMAttributes: Record, +): NodeView { + const type = props.node.firstChild!.type.name; + const implementation = editor.blockImplementations[type].implementation; + if (implementation.frameNodeView) { + return implementation.frameNodeView(props); + } + + const renderFrame = implementation.renderFrame< + typeof editor.schema.inlineContentSchema, + typeof editor.schema.styleSchema + >; + const frame = renderFrame?.call( + { + renderType: "nodeView", + props, + blockContentDOMAttributes, + propSchema: editor.blockImplementations[type].config.propSchema, + }, + nodeToBlock(props.node, props.view.state.doc), + editor, + ); + let dom = frame?.dom ?? fallback; + if (isDocumentFragment(dom)) { + // Node views need a stable element even when the author returns siblings. + const wrapper = document.createElement("div"); + wrapper.style.display = "contents"; + wrapper.append(dom); + dom = wrapper; + } + return { + dom, + contentDOM: frame?.slot ?? fallback, + destroy: frame?.destroy?.bind(frame), + update(node) { + if (frame?.update) { + return frame.update(nodeToBlock(node, props.view.state.doc)) !== false; + } + // Declined frames must also be reconsidered when their block changes. + return !renderFrame || node.eq(props.node); + }, + }; +} + +/** + * 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 [ + ["textColor", "data-text-color"], + ["backgroundColor", "data-background-color"], + ] as const) { + const value = props?.[prop]; + if (typeof value === "string" && value !== "default") { + element.setAttribute(attribute, value); + } else { + element.removeAttribute(attribute); + } + } +} + // Object containing all possible block attributes. const BlockAttributes: Record = { blockColor: "data-block-color", @@ -88,4 +169,65 @@ export const BlockContainer = Node.create<{ contentDOM: block, }; }, + + addNodeView() { + // Cast: this returns a plain ProseMirror node view, which tiptap's + // `NodeViewRenderer` type doesn't model. + return ((props: NodeViewRendererProps) => { + const editor = this.options.editor; + const { dom, contentDOM } = this.type.spec.toDOM!(props.node) as { + dom: HTMLElement; + contentDOM: HTMLElement; + }; + const frameView = createFrameView( + props, + editor, + contentDOM, + this.options.domAttributes?.blockContent || {}, + ); + const framed = frameView.dom !== contentDOM; + if (framed) { + contentDOM.appendChild(frameView.dom); + } + setBlockColorAttributes(contentDOM, props.node.firstChild?.attrs); + + const nodeView: NodeView = { + dom, + contentDOM: frameView.contentDOM ?? contentDOM, + update(node, decorations, innerDecorations) { + // Changing the wrapper or block type replaces the complete view. + const kept = + node.sameMarkup(props.node) && + node.firstChild?.type === props.node.firstChild?.type && + (frameView.update?.(node, decorations, innerDecorations) ?? false); + if (kept) { + // The view stays when only the content's attributes change. + setBlockColorAttributes(contentDOM, node.firstChild?.attrs); + } + return kept; + }, + stopEvent(event) { + // Author chrome handles its own events; the slot remains editable. + // Drag-and-drop events still go to ProseMirror, so a block dropped + // on the chrome drops where the drop cursor shows it. + const target = event.target; + return ( + (target instanceof globalThis.Node && + frameView.dom.contains(target) && + !nodeView.contentDOM?.contains(target) && + !dropEvents.has(event.type)) || + (frameView.stopEvent?.(event) ?? false) + ); + }, + destroy: frameView.destroy?.bind(frameView), + selectNode: frameView.selectNode?.bind(frameView), + deselectNode: frameView.deselectNode?.bind(frameView), + ignoreMutation: frameView.ignoreMutation?.bind(frameView), + }; + if (framed) { + ignoreFrameChromeMutations(nodeView); + } + 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..cf0fed0411 --- /dev/null +++ b/packages/core/src/schema/blocks/children.test.ts @@ -0,0 +1,150 @@ +// @vitest-environment node +import { describe, expect, it } from "vite-plus/test"; + +import { childrenContentExpression } from "./children.js"; +import type { ChildrenConfig } from "./types.js"; +import { validateChildrenConfigs } from "./validateChildren.js"; + +// All enforcement happens through the content expression. If this table is +// right, `allow`/`min` are enforced by ProseMirror itself. +const CASES: [string, ChildrenConfig, string][] = [ + [ + "any block or placeable container, at least one (the minimal config)", + { allow: "blocks" }, + "blockGroupChild+", + ], + ["any block, possibly none", { allow: "blocks", min: 0 }, "blockGroupChild*"], + [ + "any block, two or more", + { allow: "blocks", min: 2 }, + "blockGroupChild{2,}", + ], + ["one container type only", { allow: ["column"], min: 2 }, "column{2,}"], + [ + "several container 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({ min: 2 })).toBe("blockGroupChild{2,}"); + }); + + it("throws for an allow array that permits nothing", () => { + expect(() => childrenContentExpression({ allow: [] })).toThrow( + /permits nothing/, + ); + }); +}); + +type BlockFixture = { + content?: "none" | "inline" | "plain" | "table"; + container?: true; + children?: ChildrenConfig; + 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); + +describe("validateChildrenConfigs", () => { + it("accepts recursive containers and named-only children", () => { + expect(validate({ callout: { container: true } })).not.toThrow(); + expect( + validate({ + // gridCell is a terminating alternative to the recursive grid. + grid: { + container: true, + children: { allow: ["gridCell", "grid"], min: 2 }, + }, + gridCell: { container: true, placeable: "namedOnly" }, + }), + ).not.toThrow(); + }); + + it.each(["inline", "plain", "table"] as const)( + "rejects `container` on a block with %s content", + (content) => { + expect(validate({ alert: { content, container: 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 container", + (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: { container: true }, + }), + ).toThrow(/requires `container: true`/); + } + }, + ); + + it("does not treat a block with content as an allowed container", () => { + expect( + validate({ + box: { container: true, children: { allow: ["alert"] } }, + alert: { content: "inline" }, + }), + ).toThrow(/regular block/); + }); + + it("rejects named-only placement on a shared regular block wrapper", () => { + expect( + validate({ alert: { content: "inline", placeable: "namedOnly" } }), + ).toThrow(/requires a container node/); + }); + + it.each(["typo", "blockGroupChild", "toString"])( + "rejects an allow entry that is not a configured block: %s", + (allowed) => { + expect( + validate({ box: { container: true, children: { allow: [allowed] } } }), + ).toThrow(/not a configured block type/); + }, + ); + + it("rejects a regular block type in the allow array", () => { + expect( + validate({ box: { container: true, children: { allow: ["heading"] } } }), + ).toThrow(/not yet supported/); + }); +}); diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts new file mode 100644 index 0000000000..cf132162c3 --- /dev/null +++ b/packages/core/src/schema/blocks/children.ts @@ -0,0 +1,103 @@ +import type { Node, NodeType, Schema } from "prosemirror-model"; + +import type { ChildrenConfig } from "./types.js"; + +export const CHILD_CONTAINER_GROUP = "childContainer"; + +export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; + +/** + * Whether a block config declares a *container block* (`container: true`): + * one whose own node holds its children. + * @internal + */ +export function isContainerConfig(config: { container?: true }): boolean { + return config.container === true; +} + +// Whether `type` is a node that holds child blocks directly: a container +// block's own node. A container is a child-holding node that 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 isContainerNode(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 container declared `placeable: "namedOnly"`: one + * defined only in terms of the container 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 ( + isContainerNode(type) && type.spec.blockConfig?.placeable === "namedOnly" + ); +} + +// Below `blockContainer`'s priority (50) so PM's `fillBefore` picks +// `blockContainer` first, avoiding recursion through nested containers. +export const CONTAINER_NODE_PRIORITY = 40; + +const CONTAINER_PRIORITY_BAND = { min: 30, max: 49 }; +const DEFAULT_SPEC_PRIORITY = 101; + +// Maps `sortByDependencies` priority into the container band (30–49). +// Preserves relative order but keeps all containers below regular blocks. +export function containerNodePriority(priority: number | undefined): number { + if (priority === undefined) { + return CONTAINER_NODE_PRIORITY; + } + + const steps = Math.round((priority - DEFAULT_SPEC_PRIORITY) / 10); + + return Math.min( + CONTAINER_PRIORITY_BAND.max, + Math.max(CONTAINER_PRIORITY_BAND.min, CONTAINER_NODE_PRIORITY + steps), + ); +} + +/** + * Compiles a container'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`). + */ +export function childrenContentExpression( + children: ChildrenConfig = {}, +): string { + const { allow = "blocks", min = 1 } = children; + + let allowed: string; + if (allow === "blocks") { + // "Anything" is already a group, so use it rather than spelling out a + // union that would need rebuilding whenever the schema gains a container + // type. + allowed = BLOCK_GROUP_CHILD_GROUP; + } else { + if (allow.length === 0) { + throw new Error( + "Container `allow` permits nothing. A container must accept at least one block or container 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/containerAttributes.ts b/packages/core/src/schema/blocks/containerAttributes.ts new file mode 100644 index 0000000000..c25677c8f2 --- /dev/null +++ b/packages/core/src/schema/blocks/containerAttributes.ts @@ -0,0 +1,37 @@ +import { camelToDataKebab } from "../../util/string.js"; +import { PropSchema, Props } from "../propTypes.js"; + +/** + * Writes the attributes a container block's round-trip parse reads onto its + * root element: the `data-node-type` marker, the block's non-default props as + * `data-*` (the convention `propsToAttributes` and the generated parse rules + * use), and its id where there is one. + * Existing attributes follow the block props, including removing defaults. + * @internal + */ +export function applyContainerAttributes( + element: HTMLElement | undefined | null, + blockType: string, + blockProps: Partial>, + propSchema: PSchema, + id?: string, +) { + if (!element) { + return; + } + + for (const [prop, value] of Object.entries(blockProps)) { + const attribute = camelToDataKebab(prop); + if (value === undefined || value === propSchema[prop]?.default) { + element.removeAttribute(attribute); + } else { + element.setAttribute(attribute, String(value)); + } + } + + // 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/createSpec.browser.test.ts b/packages/core/src/schema/blocks/createSpec.browser.test.ts new file mode 100644 index 0000000000..f981a3f9bc --- /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 renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +// A pure container that recognizes its own external HTML. Before containers +// went through `getParseRules`, `parse` was silently dropped for them and this +// produced nothing at all. +const Card = createBlockSpec( + { + type: "card" as const, + propSchema: { tone: { default: "neutral" } }, + content: "none", + container: true, + }, + { + render: renderDiv, + parse: (el) => + el.classList.contains("card") + ? { tone: el.getAttribute("data-tone") ?? undefined } + : undefined, + }, +)(); + +// The same, but taking over the parsing of its own body. +const Quote = createBlockSpec( + { + type: "quote" as const, + propSchema: {}, + content: "none", + container: true, + }, + { + render: renderDiv, + 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("container `parse`", () => { + it("parses an external element into a container, children intact", () => { + const blocks = editor.tryParseHTMLToBlocks( + '

First

Second

', + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("card"); + expect(blocks[0].props.tone).toBe("warning"); + // No `getContent` is supplied, so ProseMirror parses the children with the + // normal block rules and `findWrapping` adds the `blockContainer`s. + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + "heading", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "First", styles: {} }, + ]); + }); + + it("places inline nodes returned by `parseContent` into a child block", () => { + const blocks = editor.tryParseHTMLToBlocks( + "
Quoted text
", + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("quote"); + expect(blocks[0].children.map((child: any) => child.type)).toEqual([ + "paragraph", + ]); + expect(blocks[0].children[0].content).toEqual([ + { type: "text", text: "Quoted text", styles: {} }, + ]); + }); +}); + +describe("container `runsBefore`", () => { + const ambiguous = (type: string) => + createBlockSpec( + { + type, + propSchema: {}, + content: "none", + container: true, + } as any, + { + render: renderDiv, + parse: (el: HTMLElement) => + el.classList.contains("shared") ? {} : undefined, + }, + ); + + const makeEditor = (betaRunsBefore?: string[]) => { + const alpha = ambiguous("alpha")(); + const beta = ambiguous("beta")(); + if (betaRunsBefore) { + (beta.implementation as any).runsBefore = betaRunsBefore; + } + + return BlockNoteEditor.create({ + schema: BlockNoteSchema.create().extend({ + blockSpecs: { ...defaultBlockSpecs, alpha, beta } as any, + }), + }) as BlockNoteEditor; + }; + + it("orders a container's parse rules before another container's", () => { + // Declaration order wins by default; `runsBefore` overrides it. + for (const [runsBefore, winner] of [ + [undefined, "alpha"], + [["alpha"], "beta"], + ] as const) { + const other = makeEditor(runsBefore ? [...runsBefore] : undefined); + try { + expect( + other.tryParseHTMLToBlocks('

x

')[0] + .type, + ).toBe(winner); + } finally { + other._tiptapEditor.destroy(); + } + } + }); +}); diff --git a/packages/core/src/schema/blocks/createSpec.test.ts b/packages/core/src/schema/blocks/createSpec.test.ts index c42a9151ad..59e56d93fa 100644 --- a/packages/core/src/schema/blocks/createSpec.test.ts +++ b/packages/core/src/schema/blocks/createSpec.test.ts @@ -1,3 +1,4 @@ +import { Node as TiptapNode } from "@tiptap/core"; import { describe, expect, it } from "vite-plus/test"; import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; @@ -5,6 +6,8 @@ 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 { containerRootDOM } from "./createSpec.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,259 @@ 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", + container: true, + }, + {}, + ), + }, + }), + ).toThrow(/Node name does not match block type/); + }); +}); + +describe("containerRootDOM", () => { + const element = () => document.createElement("div"); + + it("returns the dom itself when it is an element", () => { + const dom = element(); + expect(containerRootDOM({ dom })).toBe(dom); + }); + + it("unwraps a fragment wrapping exactly one element", () => { + const root = element(); + const fragment = document.createDocumentFragment(); + fragment.append(root); + expect(containerRootDOM({ dom: fragment })).toBe(root); + }); + + it("returns null for a fragment with no single element root", () => { + const empty = document.createDocumentFragment(); + expect(containerRootDOM({ dom: empty })).toBeNull(); + + const multi = document.createDocumentFragment(); + multi.append(element(), element()); + expect(containerRootDOM({ dom: multi })).toBeNull(); + + const textOnly = document.createDocumentFragment(); + textOnly.append(document.createTextNode("text")); + expect(containerRootDOM({ dom: textOnly })).toBeNull(); + }); +}); + +describe("container children parsing", () => { + const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }; + + const MixedBox = createBlockSpec( + { + type: "mixedBox" as const, + propSchema: {}, + content: "none", + container: true, + }, + { render: renderDiv }, + )(); + + 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("container render contract", () => { + const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }; + + const createEditorWith = (spec: any) => + BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, probed: spec }, + }), + }); + + it("rejects a container declaring neither render nor renderFrame", () => { + expect(() => + createBlockSpec( + { + // @ts-expect-error render is required on every block. + type: "probed" as const, + propSchema: {}, + content: "none" as const, + container: true, + }, + {}, + )(), + ).toThrow(/must declare `render`/); + }); + + it("rejects renderFrame alone on a block that is not a pure container", () => { + // A regular block always renders through `render`. + expect(() => + createBlockSpec( + { + // @ts-expect-error render is required on every block. + type: "probed" as const, + propSchema: {}, + content: "inline" as const, + }, + { + renderFrame: () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + }, + )(), + ).toThrow(/must declare `render`/); + + // A titled block needs its title row: `renderFrame` alone is not enough. + expect(() => + createBlockSpec( + { + // @ts-expect-error render is required on every block. + type: "probed" as const, + propSchema: {}, + content: "inline" as const, + container: true, + }, + { + renderFrame: () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + }, + )(), + ).toThrow(/must declare `render`/); + }); + + it("rejects framing a pure container, whose render already owns its box", () => { + const framed = createBlockSpec( + { + type: "probed", + propSchema: {}, + content: "none", + container: true, + }, + { + render: renderDiv, + renderFrame: () => { + const dom = document.createElement("div"); + return { dom, slot: dom }; + }, + }, + )(); + expect(() => createEditorWith(framed)).toThrow( + /requires a separate content node/, + ); + }); +}); + +it("scopes external container parsing to childrenDOM", () => { + const box = createBlockSpec( + { + type: "box", + propSchema: {}, + content: "none", + container: true, + }, + { + render() { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; + }, + toExternalHTML() { + const dom = document.createElement("div"); + const label = document.createElement("button"); + label.textContent = "Control label"; + const childrenDOM = document.createElement("div"); + dom.append(label, childrenDOM); + return { dom, childrenDOM }; + }, + }, + )(); + const editor = BlockNoteEditor.create({ + schema: BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, box }, + }), + initialContent: [ + { type: "box", children: [{ type: "paragraph", content: "Body" }] }, + ], + }); + try { + const html = editor.blocksToHTMLLossy(editor.document); + expect(html).toContain('data-children-of="box"'); + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed).toHaveLength(1); + expect(parsed[0].type).toBe("box"); + 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..8bc732e405 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, NodeViewRendererProps } from "@tiptap/core"; import { DOMParser, Fragment, @@ -6,6 +6,7 @@ import { TagParseRule, } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; +import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { Extension, @@ -13,9 +14,21 @@ import { } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; import { ignoreDarkReaderMutations } from "../nodeViewMutations.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + childrenContentExpression, + containerNodePriority, + isContainerConfig, +} from "./children.js"; +import { applyContainerAttributes } from "./containerAttributes.js"; +import { createBlockKeyboard } from "./keyboard.js"; +import { + applyDOMAttributes, getBlockFromNodeView, + isDocumentFragment, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; @@ -45,9 +58,115 @@ 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. +// Finds the element holding a serialized container block's children, marked +// `data-children-of` by the internal HTML serializer. Returns undefined when +// no marker belonging to *this* block (rather than a same-typed nested +// container) is present. +function findContainerContentElement( + el: HTMLElement, + config: { type: string }, +): HTMLElement | undefined { + const selector = `[data-children-of="${config.type}"]`; + + // The block's root may itself be the children host (a render that passes + // its own root to `contentRef`). `querySelectorAll` only sees descendants. + if (el.matches(selector)) { + return el; + } + + for (const host of el.querySelectorAll(selector)) { + // Skip hosts of same-typed *nested* containers: this block's own host is + // the one with no other container root between it and `el`. + if (host.parentElement?.closest("[data-node-type]") === el) { + return host; + } + } + + return undefined; +} + +// 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 isContainer = isContainerConfig(config); + if ( + config.content === "table" || + (isContainer && !implementation.parseContent) + ) { + // Tables use their own rules. Containers 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 (isContainer) { + 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 +175,27 @@ export function getParseRules< config: BlockConfig, implementation: BlockImplementation, ) { + const isContainer = isContainerConfig(config); + const rules: TagParseRule[] = [ - { - tag: "[data-content-type=" + config.type + "]", - contentElement: ".bn-inline-content", - }, + isContainer + ? { + tag: `[data-node-type=${config.type}]`, + // Scope the round-trip parse to the block's content region, so text + // the render puts elsewhere in its DOM (button labels, captions, + // ...) doesn't parse back as document content. The internal HTML + // serializer marks the region with `data-children-of`; HTML without + // the marker (older or hand-written) falls back to the whole + // element, the previous behavior. + contentElement: (el) => + findContainerContentElement(el as HTMLElement, config) ?? + (el as HTMLElement), + } + : { + tag: "[data-content-type=" + config.type + "]", + contentElement: ".bn-inline-content", + preserveWhitespace: config.content === "plain" ? "full" : undefined, + }, ]; if (implementation.parse) { @@ -81,279 +216,242 @@ 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. - */ -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; +export function containerRootDOM(output: { + dom: HTMLElement | DocumentFragment; +}): HTMLElement | null { + if (isDocumentFragment(output.dom)) { + // A fragment can't hold attributes, so the round-trip markers + // (`data-node-type`, prop `data-*`) would be lost with it as the root. + // When it wraps a single element (the shape a React render produces), + // that element is the block's real root. A multi-element fragment has no + // root to mark, so its container HTML can't parse back. + return output.dom.children.length === 1 + ? (output.dom.children[0] as HTMLElement) + : null; + } + return output.dom; } -/** - * 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) { - throw new Error( - "Node name does not match block type. This is a bug in BlockNote.", - ); - } +function blockNodeView< + TName extends string, + TProps extends PropSchema, + TContent extends "inline" | "none" | "table" | "plain", +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + props: NodeViewRendererProps, +): NodeView { + const isContainer = isContainerConfig(blockConfig); + const block = isContainer + ? nodeToBlock(props.node, props.view.state.doc) + : 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, + ); + + const typedNodeView = nodeView as unknown as NodeView; - // 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; + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, props.editor); } - // 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; + // 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); + + if (!isContainer) { + return typedNodeView; } - 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}".`, + applyContainerAttributes( + containerRootDOM(nodeView), + blockConfig.type, + block.props as any, + blockConfig.propSchema, + block.id, + ); + + // Mark the children host in the live DOM, mirroring what the internal HTML + // serializer emits, so the container's round-trip parse rule can scope + // itself to it (`contentElement` in `getParseRules`) when ProseMirror + // re-reads editor DOM. + if (typedNodeView.contentDOM) { + (typedNodeView.contentDOM as HTMLElement).setAttribute( + "data-children-of", + blockConfig.type, ); } + + const update = typedNodeView.update?.bind(typedNodeView); + if (update) { + typedNodeView.update = (node, decorations, innerDecorations) => { + if (node.type.name !== blockConfig.type) { + return false; + } + if (update(node, decorations, innerDecorations) === false) { + return false; + } + applyContainerAttributes( + containerRootDOM(nodeView), + blockConfig.type, + nodeToBlock(node, props.view.state.doc).props as any, + blockConfig.propSchema, + node.attrs.id, + ); + return true; + }; + } + + return typedNodeView; } -// 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)[], priority?: number, -): LooseBlockSpec { - const builtNode = - ((blockImplementation as any).node as Node) || - Node.create({ - name: blockConfig.type, - content: (blockConfig.content === "inline" +) { + const isContainer = isContainerConfig(blockConfig); + return Node.create({ + name: blockConfig.type, + content: isContainer + ? childrenContentExpression(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 isContainer + ? 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: isContainer + ? [ + "bnBlock", + CHILD_CONTAINER_GROUP, + ...(blockConfig.placeable === "namedOnly" + ? [] + : [BLOCK_GROUP_CHILD_GROUP]), + ].join(" ") + : "blockContent", + selectable: blockImplementation.meta?.selectable ?? true, + // Containers must remain open to paste across their edges; isolating + // makes ProseMirror wrap spanning slices in a spurious blockGroup. + isolating: isContainer + ? false + : (blockImplementation.meta?.isolating ?? true), + code: isContainer ? false : (blockImplementation.meta?.code ?? false), + defining: isContainer ? true : (blockImplementation.meta?.defining ?? true), + priority: isContainer ? containerNodePriority(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 (isContainer) { + 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 `container: true` builds a container node, which + // holds its children. Every other block keeps its ordinary shape. + const isContainer = isContainerConfig(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 = + (blockImplementation as any).node ?? + buildNode(blockConfig, blockImplementation, 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); + if (!blockImplementation.render) { + throw new Error(`Block "${blockConfig.type}" must declare \`render\`.`); + } + if (isContainer && blockImplementation.renderFrame) { + throw new Error( + `Container block "${blockConfig.type}" draws its box in \`render\`; \`renderFrame\` requires a separate content node.`, + ); + } // 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 +463,71 @@ export function addNodeAndExtensionsToSpec< }, }); + function serialize( + block: Parameters[0], + editor: Parameters[1], + context?: { nestingLevel: number }, + ) { + const blockContentDOMAttributes = + node.options.domAttributes?.blockContent || {}; + const external = + context && + blockImplementation.toExternalHTML?.call( + { blockContentDOMAttributes, propSchema: blockConfig.propSchema }, + block, + editor as any, + context, + ); + const output = + external ?? + blockImplementation.render.call( + { + blockContentDOMAttributes, + props: undefined, + renderType: "dom", + propSchema: blockConfig.propSchema, + }, + block, + editor as any, + ); + + if (isContainer) { + applyContainerAttributes( + containerRootDOM(output), + blockConfig.type, + block.props, + blockConfig.propSchema, + block.id, + ); + } else if (context && !external) { + // An explicit external renderer owns the complete export. Otherwise + // wrap the default content and children in the editor's frame. + const frame = blockImplementation.renderFrame?.call( + { renderType: "dom", props: undefined, blockContentDOMAttributes }, + block, + editor as any, + ); + if (frame) { + frame.slot.append(output.dom); + return { ...output, dom: frame.dom, childrenDOM: frame.slot }; + } + } + return output; + } + + // The spec holds the resolved settings, not the declared option. + const { experimental_keyboard, ...implementation } = blockImplementation; return { config: blockConfig, implementation: { - ...blockImplementation, + ...implementation, + keyboard: createBlockKeyboard(experimental_keyboard, { + isContainer, + hardBreakShortcut: blockImplementation.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 || {}; - - return ( - blockImplementation.toExternalHTML?.call( - { blockContentDOMAttributes, propSchema: blockConfig.propSchema }, - block as any, - editor as any, - context, - ) ?? - blockImplementation.render.call( - { - blockContentDOMAttributes, - renderType: "dom", - props: undefined, - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ) - ); - }, + render: serialize, + toExternalHTML: serialize, }, extensions, }; @@ -519,6 +637,15 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + // Only a block declared `container: true` is a container here. Every + // other block keeps its ordinary shape. + const isContainer = isContainerConfig(blockConfig); + + // Keep the existing render contract, including for JS callers. + if (!blockImplementation.render) { + throw new Error(`Block "${blockConfig.type}" must declare \`render\`.`); + } + return { config: blockConfig, implementation: { @@ -537,6 +664,11 @@ export function createBlockSpec< return undefined; } + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -556,6 +688,11 @@ export function createBlockSpec< editor as any, ); + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + const nodeView = wrapInBlockStructure( output, block.type, diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index cfd17b9d11..3074f4319b 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,8 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; -import { LooseBlockSpec } from "./types.js"; +import { createBlockKeyboard } from "./keyboard.js"; +import { BlockConfig, ChildrenConfig, LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -157,6 +158,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 @@ -232,6 +266,13 @@ export function createBlockSpecFromTiptapNode< node: Node; type: string; content: "inline" | "table" | "none" | "plain"; + // Declares the block's container semantics (child counts/repair etc.) + // even though the node itself is hand-written. The node's own content + // expression stays authoritative for the PM schema, while BlockNote-level + // behavior (repair, seeding, validation) reads this config. + container?: true; + children?: ChildrenConfig; + placeable?: BlockConfig["placeable"]; }, P extends PropSchema, >( @@ -244,9 +285,25 @@ export function createBlockSpecFromTiptapNode< type: config.type as T["type"], content: config.content, propSchema, + // `BlockConfig` only allows `container` and restricted `children` with + // `content: "none"`, which a generic `T["content"]` can't show; the + // hand-written node's config is validated when the schema is created. + ...(config.container !== undefined + ? { container: config.container as any } + : {}), + ...(config.children !== undefined + ? { children: config.children as any } + : {}), + ...(config.placeable !== undefined + ? { placeable: config.placeable } + : {}), }, implementation: { node: config.node, + keyboard: createBlockKeyboard(undefined, { + isContainer: config.container === true, + 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..e7824ad186 --- /dev/null +++ b/packages/core/src/schema/blocks/keyboard.ts @@ -0,0 +1,137 @@ +/** + * 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; + /** + * 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 containers. 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 container 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 container's children can never be outdented, + * so this has no effect on containers. + * @default true, or false for container 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: { + isContainer: boolean; + /** The deprecated `meta.hardBreakShortcut`, read when set. */ + hardBreakShortcut: "shift+enter" | "enter" | "none" | undefined; + }, +): (block: TBlock) => BlockKeyboard { + const { isContainer, hardBreakShortcut } = spec; + const defaults: BlockKeyboard = { + enter: hardBreakShortcut === "enter" ? "line-break" : "split", + shiftEnter: hardBreakShortcut === "none" ? "same-as-enter" : "line-break", + splitKeepsType: false, + resetsTo: { type: "paragraph" }, + emptyEnterResets: false, + // A container's children can't be outdented (the schema doesn't allow + // them outside it), so an empty last child leaves it instead. + emptyChildEnter: isContainer ? "exit-at-end" : "outdent", + childrenCanOutdent: !isContainer, + }; + 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 }); + } + } + return keyboard; +} 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..88d14e3a4d --- /dev/null +++ b/packages/core/src/schema/blocks/renderFrame.test.ts @@ -0,0 +1,447 @@ +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 { + CONTAINER_SELECTOR, + 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) { + if (block.children.length === 0) { + return undefined; + } + 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); + const fragment = document.createDocumentFragment(); + fragment.append(dom); + return { dom: fragment, slot }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + toggle: Toggle, + contentFrame: ContentFrame, + 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("re-evaluates a declined frame when children appear and disappear", () => { + const editor = editorWith([ + { id: "frame", type: "contentFrame", content: "Title" }, + ]); + try { + expect(editor.domElement!.querySelector(".content-frame")).toBeNull(); + editor.updateBlock("frame", { + children: [{ id: "child", type: "paragraph", content: "Body" }], + }); + expect( + editor + .domElement!.querySelector(".content-frame") + ?.getAttribute("data-count"), + ).toBe("1"); + editor.removeBlocks(["child"]); + expect(editor.domElement!.querySelector(".content-frame")).toBeNull(); + expect(editor.domElement!.textContent).toContain("Title"); + } 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.matches(CONTAINER_SELECTOR)).toBe(false); + } 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.blockContentDOMAttributes).toEqual({ + "data-test": "content", + }); + 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 }, + }), + domAttributes: { blockContent: { "data-test": "content" } }, + 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("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(); + } + expect(destroy).toHaveBeenCalledTimes(3); +}); + +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.blockContentDOMAttributes).toEqual({ + "data-test": "static", + }); + 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 }, + }), + domAttributes: { blockContent: { "data-test": "static" } }, + 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(); + } +}); diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index d91ee10f89..92a0c526d3 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -1,11 +1,11 @@ /** 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"; + Node, + NodeViewRenderer, + NodeViewRendererProps, +} from "@tiptap/core"; +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 +19,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 +40,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 +72,24 @@ export interface BlockConfigMeta< */ isolating?: boolean; + /** + * Whether this block type gets a side menu drag handle (and can be dragged + * by it). Applies to any block type, container or not: e.g. a + * "locked" block can opt out of dragging entirely. A block that opts out is + * skipped when looking for a drag handle, so the handle falls through to the + * nearest draggable ancestor. + * @default true + */ + draggable?: boolean; + + /** + * 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 +104,52 @@ export interface BlockConfigMeta< } /** - * BlockConfig contains the "schema" info about a Block type - * i.e. what props it supports, what content it supports, etc. + * The type name of a container block, as used in {@link ChildrenConfig.allow}. + */ +export type AllowedChildType = string; + +/** + * What may appear as a child of a container block. + * + * - `"blocks"`: any regular block, or any container 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 container 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 container block holds (`container: 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 = { + /** + * What may appear as a child. See {@link ChildrenAllow}. + * @default "blocks" + */ + allow?: ChildrenAllow; + /** + * How few children the container may hold. When children drop below the + * minimum, a container that can stand anywhere dissolves into its + * surviving children (a one-column column list is just those blocks), and + * one that only exists inside another container is topped back up with + * empty children (a column keeps existing). + * @default 1 + */ + min?: number; +}; + export interface BlockConfig< T extends string = string, PS extends PropSchema = PropSchema, @@ -106,8 +172,37 @@ export interface BlockConfig< * The content that the block supports */ content: C; - // TODO: how do you represent things that have nested content? - // e.g. tables, alerts (with title & content) + /** + * Makes the block a container: a block without content of its own + * (`content: "none"`) whose own node holds its child blocks. Its `render` + * mounts them through contentDOM (React: contentRef). Without it, a block's + * child blocks are indented below it. + */ + container?: C extends "none" ? true : never; + /** + * Which child blocks the block may have. Every block can have any child + * blocks (`{ allow: "blocks" }`, the default). Only a container + * (`container: 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"; min?: 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 container that names this type in its + * `children.allow` array (e.g. a `column` inside a `columnList`). + * + * Only meaningful for container blocks; regular blocks are always placeable + * anywhere. + */ + placeable?: "anywhere" | "namedOnly"; } declare module "prosemirror-model" { @@ -224,8 +319,10 @@ export type LooseBlockSpec< config: BlockConfig; implementation: Omit< BlockImplementation, - "render" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" | "experimental_keyboard" > & { + /** Every keyboard setting of the block, with defaults filled in. */ + keyboard: (block: any) => BlockKeyboard; // purposefully stub the types for render and toExternalHTML since they reference the block render: ( /** @@ -240,9 +337,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 | DocumentFragment; + 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,8 +392,9 @@ 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: ( /** @@ -299,9 +409,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 | DocumentFragment; + 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,12 +688,23 @@ export type BlockImplementation< | "table" | "plain", > = { + /** @internal Framework adapter for the outer blockContainer node view. */ + frameNodeView?: NodeViewRenderer; /** * 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. + */ + experimental_keyboard?: BlockKeyboardOption< + BlockFromConfig, any, any> + >; + /** + * A function that converts the block into a DOM element. */ render: ( this: @@ -603,20 +736,79 @@ 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 - * the existing DOM - or `false` to have the node view recreated via - * `render`. When omitted, ProseMirror keeps the node view and reconciles its - * `contentDOM` in place as long as the node type stays the same. + * Optional NodeView update hook. Called when the underlying ProseMirror + * node's attributes change (or its decorations change). Return `false` to + * tell ProseMirror to destroy and recreate the NodeView (i.e. re-run + * `render` from scratch). Return `true` (or `undefined`) when you have + * patched `dom` in-place and PM should keep the existing view. * - * Useful for blocks whose `render` builds custom DOM that needs to stay in - * sync with the node (e.g. a code block rendering a preview of its content). + * Only honored for container blocks (`container: true`), where + * recreating the node view would remount every child block: e.g. column + * resizing patches widths in place through this hook. Non-container + * blocks always recreate on attr changes (see + * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464). */ - update?: (node: ProsemirrorNode) => boolean; - destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; + /** + * 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 pure + * container already owns its outer DOM through `render`. + * + * `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, block changes rebuild the frame. + */ + renderFrame?: ( + this: + | Record + | ({ + blockContentDOMAttributes: Record; + propSchema?: TProps; + } & ( + | { + renderType: "nodeView"; + props: NodeViewRendererProps; + } + | { + renderType: "dom"; + props: undefined; + } + )), + block: BlockFromConfig, any, any>, + editor: BlockNoteEditor< + Record>, + I, + S + >, + ) => + | { + dom: HTMLElement | DocumentFragment; + /** 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`. @@ -711,4 +903,4 @@ export type CustomBlockImplementation< T extends string = string, PS extends PropSchema = PropSchema, C extends "inline" | "none" | "plain" = "inline" | "none" | "plain", -> = BlockImplementation; +> = Omit, "frameNodeView">; diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts new file mode 100644 index 0000000000..03dae96d5f --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -0,0 +1,75 @@ +import { isContainerConfig } from "./children.js"; +import type { BlockConfig, ChildrenConfig } from "./types.js"; + +/** Reject declarations ProseMirror would accept with different semantics. */ +export function validateChildrenConfigs( + blockSpecs: Record< + string, + { + config: Pick< + BlockConfig, + "content" | "placeable" | "container" | "children" + >; + } + >, +) { + for (const [type, { config }] of Object.entries(blockSpecs)) { + if (config.container !== undefined && config.content !== "none") { + fail( + type, + '`container: true` is only for blocks without content (`content: "none"`): a container\'s own node holds nothing but its child blocks.', + ); + } + if (config.placeable === "namedOnly" && !isContainerConfig(config)) { + fail( + type, + '`placeable: "namedOnly"` requires a container node; regular blocks share the same wrapper and cannot restrict their placement.', + ); + } + if (!config.children) { + continue; + } + + // Typed loosely: a JS caller can put anything here. + const { allow = "blocks", min } = config.children as ChildrenConfig; + + // The child blocks of a block that isn't a container share one untyped + // group, which can't enforce types or counts. + if (!isContainerConfig(config)) { + if (allow !== "blocks" || min !== undefined) { + fail( + type, + 'restricting child types or a minimum count requires `container: true`. Other blocks can always have any child blocks (`{ allow: "blocks" }`).', + ); + } + continue; + } + + // 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 (!isContainerConfig(blockSpecs[allowed].config)) { + fail( + type, + `\`allow\` contains "${allowed}", which is a regular block, not a container block. ` + + "Restricting which regular block types a container accepts is not yet supported, as every regular block is the same ProseMirror node. " + + 'Use `allow: "blocks"` to accept all regular blocks, or name only container block types.', + ); + } + } + } + } +} + +function fail(type: string, message: string): never { + throw new Error( + `Invalid \`children\` config for block "${type}": ${message}`, + ); +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..12ee25e7ce 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,6 +1,7 @@ export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.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/nodeViewMutations.ts b/packages/core/src/schema/nodeViewMutations.ts index c3eb400870..e7209ab20d 100644 --- a/packages/core/src/schema/nodeViewMutations.ts +++ b/packages/core/src/schema/nodeViewMutations.ts @@ -47,3 +47,35 @@ export function ignoreDarkReaderMutations(nodeView: NodeView): void { return !contentDOM && mutation.type !== "selection"; }; } + +// TODO(review): added while merging #3051 (with main's #3062) into #3059. +// #3062 replaced `ignoreNonContentMutations` with the Dark Reader-only rule +// for block content node views; frames still needed their chrome ignored, so +// this keeps that for frames only. Needs a proper review. No test covers it: +// with it disabled, all frame and container tests still pass. If it turns out +// to be needed, add tests (e.g. a frame whose chrome changes by itself); +// otherwise remove it. +/** + * For a frame's node view (`renderFrame`): ignores mutations to the frame's + * own chrome, which is the author's DOM outside the node view's content DOM + * (the slot), and Dark Reader's writes. Everything inside the slot (the + * block's content and its children) still reaches ProseMirror, so a native + * paragraph split there is read (#3001). + */ +export function ignoreFrameChromeMutations(nodeView: NodeView): void { + const originalIgnoreMutation = nodeView.ignoreMutation?.bind(nodeView); + const contentDOM = nodeView.contentDOM; + + nodeView.ignoreMutation = (mutation: ViewMutationRecord) => { + if ( + mutation.type !== "selection" && + (isDarkReaderMutation(mutation) || + (contentDOM && !contentDOM.contains(mutation.target))) + ) { + return true; + } + + // Defer to the node view's own `ignoreMutation` for additional filtering. + return originalIgnoreMutation ? originalIgnoreMutation(mutation) : false; + }; +} 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..42234c9b45 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`: + // container block nodes are clamped below its priority + // (`containerNodePriority`) 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/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..1d13bfef4a 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -1,4 +1,4 @@ -import { getNodeById } from "@blocknote/core"; +import { getNodeById, isContainerNode } from "@blocknote/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -28,6 +28,34 @@ export const BlockPopover = ( return undefined; } + // Containers anchor to their own root, not the child-block contentDOM. + if (isContainerNode(nodePosInfo.node.type)) { + const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode); + if (dom instanceof Element) { + // React adds two display:contents wrappers around the author root. + const root = dom.matches(".bn-container-node-view") + ? dom.querySelector(":scope > [data-node-view-wrapper]") + ?.firstElementChild + : dom; + return { element: root ?? dom }; + } + } + + // A frame's editable slot may start after interactive chrome, such as + // a toggle button. Anchor outside the whole block so the side menu + // does not cover that chrome. The blockContainer node view owns a + // boxed outer element, even when the frame returns a fragment. + const contentType = nodePosInfo.node.firstChild?.type.name; + if ( + contentType && + editor.schema.blockSpecs[contentType]?.implementation.renderFrame + ) { + const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode); + if (dom instanceof Element) { + return { element: dom }; + } + } + const { node } = editor.prosemirrorView.domAtPos( nodePosInfo.posBeforeNode + 1, ); diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index f72b760521..0805b4089e 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -111,6 +111,13 @@ width: 100%; } +/* Container blocks own their outer DOM: the block's root element is the one + its `render` returned, so the wrapper React needs around it must not be a + box of its own. */ +.bn-react-node-view-renderer.bn-container-node-view { + display: contents; +} + /* Indent line styling */ .bn-block-group .bn-block:not(:has(.bn-toggle-wrapper)) diff --git a/packages/react/src/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.container.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx new file mode 100644 index 0000000000..ac788b5d96 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx @@ -0,0 +1,228 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +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 container blocks in a real browser. + * + * Everything here needs a real DOM: the external-HTML path renders the block + * through a temporary `createRoot` (see `@util/ReactRenderUtil`), and a React + * node view only runs once `contentComponent` is set, which happens when + * `BlockNoteViewRaw` mounts the editor. Document-model behaviour of + * containers in general is covered by the core suites in + * `api/blockManipulation/containers/`. + */ + +// A container: its `contentRef` element holds its child blocks. +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { flavor: { default: "tip" } }, + content: "none", + container: true, + }, + { + render: function Callout(props) { + const [alternate, setAlternate] = useState(false); + const Tag = alternate ? "section" : "div"; + return ( + + +
+ + ); + }, + }, +); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + callout: createCallout(), + }, +}); + +describe("React container block external HTML", () => { + it("serializes the author's own root element, unwrapped", () => { + const editor = BlockNoteEditor.create({ schema }); + + const html = editor.blocksToHTMLLossy([ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }], + }, + ] as any); + + // Container blocks own their outer DOM entirely. Regression test for the + // React `toExternalHTML` path wrapping them in a spurious + // `bn-block-content` div (core's `createBlockSpec` passes them through). + // The root is the element `render` returned, with no React wrapper in + // between, so `.callout[data-*]` CSS matches it here exactly as in the + // live editor. + expect(html).not.toContain('data-content-type="callout"'); + expect(html).not.toContain("data-node-view-wrapper"); + expect(html).toContain('class="callout"'); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain("Hello"); + + editor._tiptapEditor.destroy(); + }); +}); + +let root: Root | undefined; +let div: HTMLDivElement | undefined; +let editor: BlockNoteEditor | undefined; + +afterEach(() => { + root?.unmount(); + root = undefined; + if (div) { + document.body.removeChild(div); + div = undefined; + } + editor?._tiptapEditor.destroy(); + editor = undefined; +}); + +/** Lets TipTap's deferred node-view render and React's commit run. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function mountEditor(initialContent: any[]) { + div = document.createElement("div"); + document.body.appendChild(div); + + editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent, + }) as BlockNoteEditor; + + root = createRoot(div); + flushSync(() => { + root!.render(); + }); + // TipTap only renders a node view synchronously when this is set; BlockNote + // mounts the editor itself and never does, so the first batch of node views + // takes the deferred path (see `tests/src/unit/react/staleNodeViewPos.test.tsx`). + (editor as any)._tiptapEditor.isEditorContentInitialized = true; + await tick(); + + return { editor: editor!, div: div! }; +} + +describe("React container block node view", () => { + it("anchors a container 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( + + {}; + }, + }} + > + Container menu + + , + ); + }); + await expect.poll(() => anchor).toBe(box); + expect(box.getBoundingClientRect().height).toBeGreaterThan(0); + }); + + 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 pure container's children inside its `contentRef` element", async () => { + const mounted = await mountEditor([ + { + id: "c-0", + type: "callout", + children: [{ id: "c-child", type: "paragraph", content: "Child" }], + }, + ]); + + const body = mounted.div.querySelector(".callout-body")!; + // A container with no content of its own puts its children where the + // author placed `contentRef`, not somewhere else in the node view. The + // child's own block element is a descendant, so this checks structure, + // not just text that happened to bubble up. + expect(body.querySelector('[data-id="c-child"]')).not.toBeNull(); + expect(body.textContent).toBe("Child"); + }); +}); diff --git a/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx new file mode 100644 index 0000000000..2998c4eb70 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx @@ -0,0 +1,172 @@ +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); + return { dom, slot }; + }, + }, + )(), + 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.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 5311d4e37d..0e5c9d7fb6 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -1,5 +1,8 @@ import { + applyContainerAttributes, + isContainerConfig, BlockConfig, + BlockFromConfig, BlockConfigOrCreator, BlockImplementation, BlockNoDefaults, @@ -11,6 +14,7 @@ import { ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, mergeCSSClasses, + nodeToBlock, Props, PropSchema, } from "@blocknote/core"; @@ -20,12 +24,17 @@ import { ReactNodeViewRenderer, useReactNodeView, } from "@tiptap/react"; -import { FC, ReactNode } from "react"; +import { CSSProperties, FC, ReactNode, useCallback, 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 container block's root element is the block's own element, so every +// wrapper React puts above it has to contribute no box of its own. Module +// scope so the style object is referentially stable across renders. +const DISPLAY_CONTENTS: CSSProperties = { display: "contents" }; + export type ReactCustomBlockRenderProps< B extends BlockConfigOrCreator, Config extends ExtractBlockConfigFromConfigOrCreator = @@ -33,11 +42,31 @@ export type ReactCustomBlockRenderProps< > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; -} & (Config["content"] extends "inline" | "plain" - ? { - contentRef: (node: HTMLElement | null) => void; - } - : object); + // A block gets a `contentRef` for its `render` to mount its editable region: + // its inline content, or, for a container, its child blocks. Only a + // `content: "none"` block that isn't a container (and the table block, whose + // content is managed separately) has nothing to place. +} & (Config extends { container: true } + ? { contentRef: (node: HTMLElement | null) => void } + : Config["content"] extends "inline" | "plain" + ? { contentRef: (node: HTMLElement | null) => void } + : object); + +// extend BlockConfig but use a React render function +export type ReactCustomBlockFrameProps< + B extends BlockConfigOrCreator, + Config extends ExtractBlockConfigFromConfigOrCreator = + ExtractBlockConfigFromConfigOrCreator, +> = { + block: BlockFromConfig; + editor: BlockNoteEditor, any, any>; + // A frame gets a `contentRef` for its slot: the mount for the block's + // children, or for its content and children together when the block is a + // titled block (content of its own plus `children`). Attach it with + // `ref={contentRef}` on the slot element, the same way `render` mounts + // its editable region. + contentRef: (node: HTMLElement | null) => void; +}; // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< @@ -50,9 +79,13 @@ export type ReactCustomBlockImplementation< Config["propSchema"], Config["content"] >, - "render" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" > & { render: FC>; + // The outer block node view renders this component live. Its slot holds + // the existing content node followed by the child blockGroup, regardless + // of whether those children are owned or ordinary nesting. + renderFrame?: FC>; toExternalHTML?: FC< ReactCustomBlockRenderProps & { context: { @@ -131,20 +164,20 @@ export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, const TContent extends "inline" | "none" | "plain", + // Inferred from the config object itself rather than widened to + // `BlockConfig<...>`, so `children` survives into the render props and + // `contentRef` is offered exactly when the block has an editable region. + const BlockConf extends BlockConfig, const TOptions extends Record | undefined = undefined, >( - blockConfigOrCreator: BlockConfig, + blockConfigOrCreator: BlockConf, blockImplementationOrCreator: - | ReactCustomBlockImplementation> + | ReactCustomBlockImplementation | (TOptions extends undefined - ? () => ReactCustomBlockImplementation< - BlockConfig - > + ? () => ReactCustomBlockImplementation : ( options: Partial, - ) => ReactCustomBlockImplementation< - BlockConfig - >), + ) => ReactCustomBlockImplementation), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] | (TOptions extends undefined @@ -152,7 +185,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,48 +258,148 @@ export function createReactBlockSpec< ? blockImplementationOrCreator(options as any) : blockImplementationOrCreator; + if (!blockImplementation.render) { + throw new Error(`Block "${blockConfig.type}" must declare \`render\`.`); + } + + const { renderFrame: reactRenderFrame, ...coreImplementation } = + blockImplementation; + const extensions = extensionsOrCreator ? typeof extensionsOrCreator === "function" ? extensionsOrCreator(options as any) : extensionsOrCreator : undefined; + // Container-ness 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 container node, so only one + // takes the container 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 isContainer = isContainerConfig(blockConfig); + + // Shared by the two paths that render to plain DOM (`toExternalHTML` and + // `render` outside a node view). A container block's output is the + // block's root element, with no wrapper: the attributes core stamps + // afterwards then land on the author's own element, the same element they + // land on in the live editor. + function renderStatic(args: { + BlockContent: FC; + block: any; + editor: any; + domAttributes?: Record; + isFileBlock?: boolean; + context?: any; + }) { + const { BlockContent, block, editor } = args; + + return renderToDOMSpec((refCB) => { + const content = ( + { + refCB(element); + if (element && !isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + }} + context={args.context} + /> + ); + + return isContainer ? ( + content + ) : ( + + {content} + + ); + }, editor); + } + + const Frame = reactRenderFrame; + + function FrameNodeView(props: NodeViewProps) { + // This view belongs to blockContainer itself, 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."); + } + 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. + mountContent(element ?? wrapper.current); + }, + [mountContent], + ); + const wrapperRef = useCallback( + (element: HTMLDivElement | null) => { + wrapper.current = element; + if (!slot.current) { + mountContent(element); + } + }, + [mountContent], + ); + + return ( + + ["block"] + } + editor={props.extension.options.editor} + contentRef={contentRef} + /> + + ); + } + return { config: blockConfig, implementation: { - ...blockImplementation, + ...coreImplementation, 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; + if (!blockImplementation.toExternalHTML) { + return undefined; + } + return renderStatic({ + BlockContent: blockImplementation.toExternalHTML, + block, + editor, + domAttributes: this.blockContentDOMAttributes, + isFileBlock: + blockImplementation.meta?.fileBlockAccept !== undefined, + context, + }); }, render(block, editor) { if (this.renderType === "nodeView") { @@ -268,82 +407,128 @@ export function createReactBlockSpec< // constructed (itself guarded, via `getBlockFromNodeView`). Seeds // the fallback below so there is always something to render. const initialBlock = block; + const BlockContent = blockImplementation.render; + 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 = ""; + if (!isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + return; + } - if (!ref) { - throw new Error("nodeViewContentRef is not set"); + // Refs also run when author state replaces the root or slot. + element.setAttribute("data-children-of", blockConfig.type); + const root = element.closest( + "[data-node-view-wrapper]", + )?.firstElementChild; + if (!(root instanceof HTMLElement)) { + throw new Error( + "Container content must be inside its node view wrapper.", + ); } + applyContainerAttributes( + root, + blockConfig.type, + block.props, + blockConfig.propSchema, + block.id, + ); + root.toggleAttribute("data-selected", props.selected); + } - const BlockContent = blockImplementation.render; + const content = ( + + ); + if (isContainer) { return ( - - { - ref(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - element.dataset.nodeViewContent = ""; - } - }} - /> - + + {content} + ); - }, - { - className: "bn-react-node-view-renderer", - }, - )(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, - ); - } - }} - /> + {content} ); - }, editor); - return output; + } + + const nodeView = ReactNodeViewRenderer(BlockNodeView, { + // The container class is separate because it removes the + // box the regular class relies on (see `Block.css`). + className: isContainer + ? "bn-react-node-view-renderer bn-container-node-view" + : "bn-react-node-view-renderer", + })(this.props!) as ReturnType< + NonNullable + >; + + // The container's author slot determines layout, not TipTap's host. + if (isContainer && nodeView.contentDOM) { + nodeView.contentDOM.style.display = "contents"; + } + + return nodeView; + } else { + return renderStatic({ + BlockContent: blockImplementation.render, + block, + editor, + domAttributes: this.blockContentDOMAttributes, + }); } }, + ...(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( + (contentRef) => ( + + ), + editor, + ); + return contentDOM ? { dom, slot: contentDOM } : undefined; + }, + frameNodeView: ReactNodeViewRenderer(FrameNodeView, { + className: "bn-react-node-view-renderer bn-container-node-view", + }), + } satisfies Pick< + BlockImplementation, + "renderFrame" | "frameNodeView" + >) + : {}), }, extensions: extensions, }; diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts index 02393a2fd0..2b96af297f 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; + // Container 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-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.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 8cfe741b51..888020a68b 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -1,5 +1,6 @@ import { BlockNoteSchema, + createBlockSpec, defaultBlockSpecs, createPageBreakBlockSpec, PartialBlock, @@ -416,6 +417,154 @@ describe("exporter", () => { ); }); +describe("custom container blocks", () => { + const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + container: true, + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "box"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, + )(); + + const boxSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + box: Box, + }, + }); + + const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [ + { + type: "box", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + // A titled block: inline content (the title) plus children (the body). The + // `{ withChildren }` mapping renders the title into its own paragraph and + // places the children after it, so transformBlocks must not append them a + // second time. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "alert"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + renderFrame: (_block: any) => { + const dom = document.createElement("div"); + dom.className = "alert-box"; + return { dom, slot: dom }; + }, + }, + )(); + + const alertSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + alert: Alert, + }, + }); + + const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [ + { + type: "alert", + content: "Heads up", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + it("passes children to a custom container mapping", async () => { + const exporter = new DOCXExporter( + boxSchema, + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + box: { + withChildren: ( + _block: any, + _exporter: any, + _nesting: any, + _index: any, + children: any, + ) => + new Paragraph({ + children: [new TextRun(`BOX(${children?.length ?? 0})`)], + }), + }, + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const transformed = await exporter.transformBlocks(boxDocument as any); + expect(transformed).toHaveLength(1); + const xml = JSON.stringify(transformed[0]); + expect(xml).toContain("BOX(2)"); + }); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new DOCXExporter( + alertSchema, + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + alert: { + withChildren: ( + block: any, + exporter: any, + _nesting: any, + _index: any, + children: any, + ) => [ + new Paragraph({ + children: [ + new TextRun("ALERT:"), + ...exporter.transformInlineContent(block.content), + ], + }), + ...(children ?? []), + ], + }, + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const transformed = await exporter.transformBlocks(alertDocument as any); + // Title paragraph plus the two children - handed to the mapping once, + // not appended again after it. + expect(transformed).toHaveLength(3); + const xml = JSON.stringify(transformed); + expect(xml).toContain("ALERT:"); + expect(xml.indexOf("Heads up")).toBeGreaterThan(xml.indexOf("ALERT:")); + expect(xml.indexOf("First")).toBeGreaterThan(xml.indexOf("Heads up")); + expect(xml).toContain("Second"); + }); +}); + function prettify(sourceXml: string) { let ret = xmlFormat(sourceXml); diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index f27a4b6bcb..d540c470c0 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 container'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..42f4f22a12 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 container 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..b7ce512da1 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.test.tsx @@ -3,15 +3,31 @@ import { ReactEmailExporter } from "./reactEmailExporter.jsx"; import { reactEmailDefaultSchemaMappings } from "./defaultSchema/index.js"; import { BlockNoteSchema, + createBlockSpec, createPageBreakBlockSpec, defaultBlockSpecs, } from "@blocknote/core"; +import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; +import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js"; import { testDocument } from "@shared/testDocument.js"; +// Schema including the multi-column blocks, matching the shared testDocument. +// The columns are container 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 +37,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 +49,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 +692,7 @@ describe("react email exporter", () => { it("should handle document with custom body styles", async () => { const exporter = new ReactEmailExporter( - BlockNoteSchema.create(), + fullSchema, reactEmailDefaultSchemaMappings, ); @@ -699,3 +705,83 @@ describe("react email exporter", () => { ); }); }); + +describe("titled blocks", () => { + // A titled block: inline content (the title) plus children (the body). The + // `{ withChildren }` mapping renders the title and places the children + // inside its own box, so transformBlocks must not render them after it as + // an indented sibling list. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "alert"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + renderFrame: (_block: any) => { + const dom = document.createElement("div"); + dom.className = "alert-box"; + return { dom, slot: dom }; + }, + }, + )(); + + const alertSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + alert: Alert, + }, + }); + + const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [ + { + type: "alert", + content: "Heads up", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new ReactEmailExporter(alertSchema, { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + alert: { + withChildren: ( + block: any, + exporter: any, + _nestingLevel: any, + _numberedListIndex: any, + children: any, + ) => ( +
+ {exporter.transformInlineContent(block.content)} + {children} +
+ ), + }, + }, + } as any); + + const html = await exporter.toReactEmailDocument(alertDocument as any); + + // Title and children all sit inside the mapping's own box - placed by + // the mapping, not rendered after it in an indented sibling list. + const boxIdx = html.indexOf("data-alert-box"); + expect(boxIdx).toBeGreaterThan(-1); + const titleIdx = html.indexOf("Heads up"); + expect(titleIdx).toBeGreaterThan(boxIdx); + expect(html.indexOf("First")).toBeGreaterThan(titleIdx); + expect(html).toContain("Second"); + expect(html).not.toContain("margin-left:24px"); + }); +}); diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx index fd1aa88a52..ac43ff4a53 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 isContainer = 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, + isContainer ? children : undefined, + ); + const style = isContainer + ? {} + : this.blocknoteDefaultPropsToReactEmailStyle(b.props); ret.push( @@ -279,7 +266,7 @@ export class ReactEmailExporter< ) : ( self )} - {children.length > 0 && ( + {!isContainer && 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..e621c269e8 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,28 +1,69 @@ +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", + container: true, + placeable: "namedOnly", }, { - width: { - default: 1, + meta: { + draggable: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.className = "bn-block-column"; + dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT); + + return { + dom, + contentDOM: dom, + update: (newBlock) => { + dom.style.flexGrow = String( + newBlock.attrs.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", + container: true, + children: { + allow: ["column"], + min: 2, + }, + }, + { + meta: { + draggable: false, + }, + render: () => { + const dom = document.createElement("div"); + dom.className = "bn-block-column-list"; + dom.style.display = "flex"; + + return { + dom, + contentDOM: 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..4e135aa143 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -51,12 +51,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; } @@ -116,36 +119,30 @@ 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. - throw new Error( - "Drop target column not found in the remaining columns", - ); - } - 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,9 +153,13 @@ 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); diff --git a/packages/xl-multi-column/src/pm-nodes/Column.ts b/packages/xl-multi-column/src/pm-nodes/Column.ts deleted file mode 100644 index dccf60c74b..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/Column.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { suggestionMarks } from "@blocknote/core"; -import { Node } from "@tiptap/core"; - -import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js"; - -export const Column = Node.create({ - name: "column", - group: "bnBlock childContainer", - // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "blockContainer+", - priority: 40, - defining: true, - marks() { - return suggestionMarks(this.editor); - }, - addAttributes() { - return { - width: { - // Why does each column have a default width of 1, i.e. 100%? Because - // when creating a new column, we want to make sure that existing - // column widths are preserved, while the new one also has a sensible - // width. If we'd set it so all column widths must add up to 100% - // instead, then each time a new column is created, we'd have to assign - // it a width depending on the total number of columns and also adjust - // the widths of the other columns. The same can be said for using px - // instead of percent widths and making them add to the editor width. So - // using this method is both simpler and computationally cheaper. This - // is possible because we can set the `flex-grow` property to the width - // value, which handles all the resizing for us, instead of manually - // having to set the `width` property of each column. - default: 1, - parseHTML: (element) => { - const attr = element.getAttribute("data-width"); - if (attr === null) { - return null; - } - - const parsed = parseFloat(attr); - if (isFinite(parsed)) { - return parsed; - } - - return null; - }, - renderHTML: (attributes) => { - return { - "data-width": (attributes.width as number).toString(), - style: `flex-grow: ${attributes.width as number};`, - }; - }, - }, - }; - }, - - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === this.name) { - return {}; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const column = document.createElement("div"); - column.className = "bn-block-column"; - column.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - column.setAttribute(attribute, value as any); // TODO as any - } - - return { - dom: column, - contentDOM: column, - }; - }, - - addExtensions() { - return [createColumnResizeExtension(this.options.editor)]; - }, -}); diff --git a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts deleted file mode 100644 index eeb06f4d4e..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { suggestionMarks } from "@blocknote/core"; -import { Node } from "@tiptap/core"; - -export const ColumnList = Node.create({ - name: "columnList", - group: "childContainer bnBlock blockGroupChild", - // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "column column+", // min two columns - priority: 40, // should be below blockContainer - defining: true, - marks() { - return suggestionMarks(this.editor); - }, - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === this.name) { - return {}; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const columnList = document.createElement("div"); - columnList.className = "bn-block-column-list"; - columnList.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - columnList.setAttribute(attribute, value as any); // TODO as any - } - columnList.style.display = "flex"; - - return { - dom: columnList, - contentDOM: columnList, - }; - }, -}); diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap index 476357f363..c6019e4dd8 100644 --- a/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap @@ -429,6 +429,226 @@ exports[`Test insertBlocks > Insert column with paragraph into column list 1`] = ] `; +exports[`Test insertBlocks > Insert empty column list 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "2", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [], + "id": "3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "4", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 1", + "type": "text", + }, + ], + "id": "paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 0", + "type": "text", + }, + ], + "id": "column-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 1", + "type": "text", + }, + ], + "id": "column-paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-0", + "props": { + "width": 1, + }, + "type": "column", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 2", + "type": "text", + }, + ], + "id": "column-paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Column Paragraph 3", + "type": "text", + }, + ], + "id": "column-paragraph-3", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "column-1", + "props": { + "width": 1, + }, + "type": "column", + }, + ], + "content": undefined, + "id": "column-list-0", + "props": {}, + "type": "columnList", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 2", + "type": "text", + }, + ], + "id": "paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + exports[`Test insertBlocks > Insert paragraph into column 1`] = ` [ { diff --git a/packages/xl-multi-column/src/test/commands/__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/backspace.test.ts b/packages/xl-multi-column/src/test/commands/backspace.test.ts index 87156e81a2..5737f3249b 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" 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..273b68508c --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/enter.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "@blocknote/core"; + +import { setupTestEnv } from "../setupTestEnv.js"; + +const getEditor = setupTestEnv(); + +function pressEnter(editor: BlockNoteEditor) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + keyCode: 13, + bubbles: true, + }); + view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + +// Columns have no special Enter config: like any container, an +// empty last block escapes on Enter. The generic mechanics (escape, ascent +// past levels that can't hold the block, mid-container stays) are covered in +// core's `containers.browser.test.ts`; these two tests use the real column +// schema and its interaction with the column-list repair. +describe("Enter exit from columns", () => { + it("typing then double-Enter escapes in two presses", () => { + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "cl-0", + children: [ + { + type: "column", + id: "col-1", + children: [{ id: "col1-para", type: "paragraph", content: "col1" }], + }, + { + type: "column", + id: "col-2", + children: [{ id: "col2-para", type: "paragraph", content: "col2" }], + }, + ], + }, + ]); + + editor.setTextCursorPosition("col2-para", "end"); + pressEnter(editor); + + // First press: a new empty block inside the column. + expect(editor.document.map((block) => block.id)).toEqual(["cl-0"]); + const children = editor.getBlock("col-2")!.children; + expect(children).toHaveLength(2); + const created = children[1].id; + expect(editor.getTextCursorPosition().block.id).toBe(created); + + pressEnter(editor); + + // Second press: that block moves below the column list (a block can't sit + // between columns, so the escape lands below the whole list), caret along. + expect(editor.getBlock("col-2")!.children.map((child) => child.id)).toEqual( + ["col2-para"], + ); + expect(editor.document.map((block) => block.id)).toEqual(["cl-0", created]); + expect(editor.getTextCursorPosition().block.id).toBe(created); + }); + + it("escaping a column's only block dissolves it and unwraps the list", () => { + // The exit empties the column, so the column list's `whenEmptied: "unwrap"` + // repair kicks in: the emptied column disappears, and the one-column + // list unwraps to the surviving column's blocks. + const editor = getEditor(); + editor.replaceBlocks(editor.document, [ + { + type: "columnList", + id: "cl-0", + children: [ + { + type: "column", + id: "col-1", + children: [{ id: "col1-para", type: "paragraph", content: "col1" }], + }, + { + type: "column", + id: "col-2", + children: [{ id: "col2-empty", type: "paragraph", content: "" }], + }, + ], + }, + ]); + + editor.setTextCursorPosition("col2-empty", "end"); + pressEnter(editor); + + expect(editor.document.map((block) => block.id)).toEqual([ + "col1-para", + "col2-empty", + ]); + }); +}); diff --git a/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts index 319ceda379..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..a19ea0034b --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/nestBlock.test.ts @@ -0,0 +1,71 @@ +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"], + ); + }); + + 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..9a7d18c212 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,20 @@ 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, + ); + }); }); 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__/fixContainer.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap new file mode 100644 index 0000000000..393ab225ed --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap @@ -0,0 +1,183 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Test fixContainer 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 fixContainer 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 fixContainer 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 fixContainer 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/fixContainer.test.ts b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts new file mode 100644 index 0000000000..2ae649678e --- /dev/null +++ b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { setupTestEnv } from "../../setupTestEnv.js"; +import { fixContainer, isEmptyContainerChild } from "@blocknote/core"; + +const getEditor = setupTestEnv(); + +describe("Test isEmptyContainerChild", () => { + 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(isEmptyContainerChild(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(isEmptyContainerChild(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(isEmptyContainerChild(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(isEmptyContainerChild(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(isEmptyContainerChild(column)).toBeFalsy(); + }); +}); + +describe("Test fixContainer 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); + fixContainer(tr, 1); + expect(tr.doc).toMatchSnapshot(); + }); +}); diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html index 2237513b6b..78534582b8 100644 --- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html +++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html @@ -1 +1 @@ -

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file +

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

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

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file +

Column Paragraph 0

Column Paragraph 1

Column Paragraph 2

Column Paragraph 3

\ No newline at end of file diff --git a/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts new file mode 100644 index 0000000000..1a2ed28305 --- /dev/null +++ b/packages/xl-multi-column/src/test/extensions/columnDrop.test.ts @@ -0,0 +1,95 @@ +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(); + +function dropOnColumn( + 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(dropOnColumn(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; + dropOnColumn( + ["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", + ]; + dropOnColumn(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(); + }); +}); 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-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.test.ts b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts index 4883fd0387..fd68c230c9 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.test.ts +++ b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts @@ -1,9 +1,12 @@ import { BlockNoteSchema, + createBlockSpec, createPageBreakBlockSpec, defaultBlockSpecs, } from "@blocknote/core"; import { testODTDocumentAgainstSnapshot } from "@shared/util/odtTestUtil.js"; +import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js"; +import { BlobReader, FileEntry, TextWriter, ZipReader } from "@zip.js/zip.js"; import { testDocument } from "@shared/testDocument.js"; import { beforeAll, describe, expect, it } from "vite-plus/test"; import { createElement } from "react"; @@ -101,3 +104,102 @@ describe("exporter", () => { ); }); }); + +describe("titled blocks", () => { + // A titled block: inline content (the title) plus children (the body). The + // `{ withChildren }` mapping renders the title and places the children + // inside its own section, so transformBlocks must not append them after it + // as tab-indented siblings. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "alert"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + renderFrame: (_block: any) => { + const dom = document.createElement("div"); + dom.className = "alert-box"; + return { dom, slot: dom }; + }, + }, + )(); + + const alertSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + alert: Alert, + }, + }); + + const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [ + { + type: "alert", + content: "Heads up", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new ODTExporter( + alertSchema, + { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + alert: { + withChildren: ( + block: any, + exporter: any, + _nesting: any, + _index: any, + children: any, + ) => + createElement( + "text:section", + { "text:name": "alert-body" }, + createElement( + "text:p", + null, + "ALERT:", + ...exporter.transformInlineContent(block.content), + ), + ...((children ?? []) as any[]), + ), + }, + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const odt = await exporter.toODTDocument(alertDocument as any); + const entries = await new ZipReader(new BlobReader(odt)).getEntries(); + const contentEntry = entries.find( + (entry) => entry.filename === "content.xml", + ) as FileEntry; + expect(contentEntry).toBeDefined(); + const xml = await contentEntry.getData(new TextWriter()); + + // Title and children all sit inside the mapping's own section - placed + // by the mapping, not appended after it. + const sectionOpen = xml.indexOf(""); + expect(sectionOpen).toBeGreaterThan(-1); + const titleIdx = xml.indexOf("Heads up"); + expect(titleIdx).toBeGreaterThan(sectionOpen); + const firstIdx = xml.indexOf(">First<"); + expect(firstIdx).toBeGreaterThan(titleIdx); + const secondIdx = xml.indexOf(">Second<"); + expect(secondIdx).toBeGreaterThan(firstIdx); + expect(secondIdx).toBeLessThan(sectionClose); + }); +}); diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index fc72dc1be0..018800267c 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 isContainer = this.placesChildren(block); + // Container mappings own the layout: table cells start a fresh + // indentation context instead of inheriting literal s. + const children = await this.transformBlocks( + block.children, + isContainer ? 0 : nestingLevel + 1, + ); + ret.push( + await this.mapBlock( + block, + isContainer ? 0 : nestingLevel, numberedListIndex, children, - ); - - ret.push(content); - if (children.length > 0) { - ret.push(...children); - } + ), + ); + if (!isContainer) { + 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.test.tsx b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx index 8be64289e2..b39e0fceef 100644 --- a/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx +++ b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx @@ -1,11 +1,14 @@ import { BlockNoteSchema, + createBlockSpec, createPageBreakBlockSpec, defaultBlockSpecs, } from "@blocknote/core"; import { ColumnBlock, ColumnListBlock } from "@blocknote/xl-multi-column"; -import { Text } from "@react-pdf/renderer"; +import { Text, View } from "@react-pdf/renderer"; +import { partialBlocksToBlocksForTesting } from "@shared/formatConversionTestUtil.js"; import { testDocument } from "@shared/testDocument.js"; +import { Fragment } from "react"; import reactElementToJSXString from "react-element-to-jsx-string"; import { describe, expect, it } from "vite-plus/test"; import { pdfDefaultSchemaMappings } from "./defaultSchema/index.js"; @@ -59,3 +62,90 @@ describe("exporter", () => { ); }); }); + +describe("titled blocks", () => { + // A titled block: inline content (the title) plus children (the body). The + // `{ withChildren }` mapping renders the title and places the children + // inside its own view, so transformBlocks must not wrap them after it in + // padded sibling views. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "alert"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + renderFrame: (_block: any) => { + const dom = document.createElement("div"); + dom.className = "alert-box"; + return { dom, slot: dom }; + }, + }, + )(); + + const alertSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + alert: Alert, + }, + }); + + const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [ + { + type: "alert", + content: "Heads up", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new PDFExporter(alertSchema, { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + alert: { + withChildren: ( + block: any, + exporter: any, + _nestingLevel: any, + _numberedListIndex: any, + children: any, + ) => ( + + + ALERT:{exporter.transformInlineContent(block.content)} + + {children} + + ), + }, + }, + } as any); + + const transformed = await exporter.transformBlocks(alertDocument as any); + const str = reactElementToJSXString( + {transformed as any}, + ); + + // The mapping's own view holds the title and both children - the block + // took the container branch, so no marginLeft wrapper view was added + // around the children (react-element-to-jsx-string prints the primitives + // upper case, as in the document snapshots). The children themselves + // arrive pre-wrapped in their usual padded views, exactly as column + // children do. + expect(str).not.toContain("marginLeft"); + const titleIdx = str.indexOf("Heads up"); + expect(titleIdx).toBeGreaterThan(-1); + expect(str.indexOf("First")).toBeGreaterThan(titleIdx); + expect(str).toContain("Second"); + }); +}); 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.test.ts b/packages/xl-typst-exporter/src/typstExporter.test.ts index 890545d4d2..4f313dbbc3 100644 --- a/packages/xl-typst-exporter/src/typstExporter.test.ts +++ b/packages/xl-typst-exporter/src/typstExporter.test.ts @@ -1,5 +1,6 @@ import { BlockNoteSchema, + createBlockSpec, createPageBreakBlockSpec, defaultBlockSpecs, } from "@blocknote/core"; @@ -29,6 +30,101 @@ const fullSchema = BlockNoteSchema.create({ }, }); +// A minimal custom container, standing in for a callout/card: content-less, +// holding child blocks. Its mapping is what has to place them. +const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + container: true, + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "box"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +const boxSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + box: Box, + }, +}); + +const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [ + { + type: "box", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, +] as any); + +// A titled block: inline content (the title) plus children (the body). Its +// `{ withChildren }` mapping renders the title and places the children, so +// they must arrive as the mapping's `children` argument. +const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "alert"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + renderFrame: (_block: any) => { + const dom = document.createElement("div"); + dom.className = "alert-box"; + return { dom, slot: dom }; + }, + }, +)(); + +const alertSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + alert: Alert, + }, +}); + +const alertDocument = partialBlocksToBlocksForTesting(alertSchema, [ + { + type: "alert", + content: "Heads up", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, +] as any); + +const alertMappings = { + ...typstDefaultSchemaMappings, + blockMapping: { + ...typstDefaultSchemaMappings.blockMapping, + alert: { + withChildren: ( + block: any, + exporter: any, + _nestingLevel: any, + _numberedListIndex: any, + children?: string[], + ) => + `#rect[#strong[${exporter.transformInlineContent(block.content).join("")}]\n\n${(children ?? []).join("\n\n")}]`, + }, + }, +} as any; + describe("TypstExporter", () => { it("exports a real BlockNote document to Typst", async () => { // fullSchema (incl. multi-column) matches the shared testDocument. The @@ -435,3 +531,82 @@ describe("TypstExporter", () => { ).toHaveLength(0); }); }); + +describe("container blocks", () => { + it("lays a columnList out as a grid with proportional tracks", async () => { + const exporter = new TypstExporter(fullSchema, typstDefaultSchemaMappings); + + const typ = await exporter.toTypst( + partialBlocksToBlocksForTesting(fullSchema, [ + { + type: "columnList", + children: [ + { + type: "column", + props: { width: 2 }, + children: [{ type: "paragraph", content: "Left" }], + }, + { + type: "column", + children: [{ type: "paragraph", content: "Right" }], + }, + ], + }, + ] as any), + ); + + // Each column contributes its width and body; the grid derives its + // fractional tracks from them, so the relative sizes survive. + expect(typ).toContain("(width: 2, body: ["); + expect(typ).toContain("(width: 1, body: ["); + expect(typ).toContain( + "grid(columns: cols.map(c => c.width * 1fr), column-gutter: 1em, ..cols.map(c => c.body))", + ); + // A layout grid, not a `table` - it must not be tagged as a data table. + expect(typ).not.toContain("#table("); + + // The assembled markup is real Typst, not just a plausible string. + await compileTypstForTesting(typ, { assets: exporter.assetFiles }); + }); + + it("passes its children to a custom container's mapping", async () => { + const typ = await new TypstExporter(boxSchema, { + ...typstDefaultSchemaMappings, + blockMapping: { + ...typstDefaultSchemaMappings.blockMapping, + box: { + withChildren: ( + _block: any, + _exporter: any, + _nestingLevel: any, + _numberedListIndex: any, + children?: string[], + ) => `#rect[${(children ?? []).join("\n\n")}]`, + }, + }, + } as any).toTypst(boxDocument); + + // The children sit *inside* the container's own output - the mapping + // owns their placement - rather than following it as an indented run. + expect(typ).toContain("#rect["); + expect(typ.indexOf('#"First"')).toBeGreaterThan(typ.indexOf("#rect[")); + expect(typ).toContain('#"Second"'); + expect(typ).not.toContain("#pad(left: 1.5em)"); + }); + + it("renders a titled block's title and places its children inside", async () => { + const typ = await new TypstExporter(alertSchema, alertMappings).toTypst( + alertDocument, + ); + + // The title sits inside the container's own output, and the children + // follow it there - placed by the mapping, not appended after as an + // indented run. + expect(typ).toContain("#rect["); + const titleIdx = typ.indexOf('#"Heads up"'); + expect(titleIdx).toBeGreaterThan(typ.indexOf("#rect[")); + expect(typ.indexOf('#"First"')).toBeGreaterThan(titleIdx); + expect(typ).toContain('#"Second"'); + expect(typ).not.toContain("#pad(left: 1.5em)"); + }); +}); diff --git a/packages/xl-typst-exporter/src/typstExporter.ts b/packages/xl-typst-exporter/src/typstExporter.ts index 1c8f6af33c..5dd8611b76 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 isContainer = 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)); + isContainer ? children : [], + ); + out.push(isContainer ? 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..666583b2c7 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1432,24 +1432,6 @@ export const examples = { 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)", - }, { projectSlug: "configuring-blocks", fullSlug: "custom-schema/configuring-blocks", @@ -1486,6 +1468,33 @@ export const examples = { readme: "In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)", }, + { + projectSlug: "container-block", + fullSlug: "custom-schema/container-block", + pathFromRoot: "examples/06-custom-schema/09-container-block", + config: { + playground: true, + docs: true, + author: "nickthesick", + tags: [ + "Intermediate", + "Blocks", + "Custom Schemas", + "Suggestion Menus", + "Slash Menu", + ], + dependencies: { + "react-icons": "^5.5.0", + } as any, + }, + title: "Container Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we create a custom `Panel` block that holds other blocks as its body, such as a panel containing headings and paragraphs.\n\nThe block sets `container: true` on `BlockConfig`, which makes it a container: its child blocks mount into the rendered content region (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `render`, which re-renders live when props change.\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- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + }, { projectSlug: "math-block", fullSlug: "custom-schema/math-block", @@ -1565,6 +1574,33 @@ export const examples = { readme: 'In this example, we build custom blocks on the source-with-preview pattern — the same building blocks behind BlockNote\'s math and diagram blocks. A custom "CSV table" block renders its comma-separated source as a table, and a custom "color" inline content renders a CSS color as a swatch. Both show the rendered preview in place, while the source is edited in a popup.\n\n**Try it out:** Click the table or a color chip to edit its source!\n\n**Relevant Docs:**\n\n- [Source with Preview Blocks](/docs/features/custom-schemas/source-with-preview)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content)', }, + { + projectSlug: "callout-block", + fullSlug: "custom-schema/callout-block", + pathFromRoot: "examples/06-custom-schema/13-callout-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: "Callout Block", + group: { + pathFromRoot: "examples/06-custom-schema", + slug: "custom-schema", + }, + readme: + 'In this example, we create a custom `Callout` block with a real rich-text title and child blocks inside it (a titled block), like a Notion-style callout.\n\nThe block has `content: "inline"`: the title is ordinary inline content — formatting, links, and multiplayer cursors all work — and its child blocks live on `block.children` at runtime. Its `keyboard` settings keep the child blocks inside the callout: 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 callout. `render` draws the title row and `renderFrame` draws the box around the title and child blocks together.\n\nWe also wire up a Slash Menu item to insert the callout.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to add a block inside the callout.\n- Press Enter in an empty last block inside the callout to leave the callout.\n- Press Backspace at the start of the first block inside the callout to merge it back into the title.\n- Press "/" inside the callout and add a code block, heading, or list.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + }, { projectSlug: "draggable-inline-content", fullSlug: "custom-schema/draggable-inline-content", @@ -1872,7 +1908,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 +1932,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 +1957,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..95737b68c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3386,7 +3386,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 +3429,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 +3472,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-container-block: dependencies: '@blocknote/ariakit': specifier: latest @@ -3501,6 +3501,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 +3668,52 @@ 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-callout-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': diff --git a/tests/src/end-to-end/exporters/exporterTestUtil.tsx b/tests/src/end-to-end/exporters/exporterTestUtil.tsx index bd857d6ef9..a8e740b163 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 container block from a regular one, and a container +// 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/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json b/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json index fe2cca639a..c849beeff2 100644 --- a/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json +++ b/tests/src/end-to-end/keyboardhandlers/__snapshots__/enterPreservesNestedBlocks.json @@ -25,28 +25,6 @@ "text": "H" } ] - } - ] - }, - { - "type": "blockContainer", - "attrs": { - "id": "3" - }, - "content": [ - { - "type": "paragraph", - "attrs": { - "backgroundColor": "default", - "textColor": "default", - "textAlignment": "left" - }, - "content": [ - { - "type": "text", - "text": "eading" - } - ] }, { "type": "blockGroup", @@ -102,6 +80,28 @@ ] } ] + }, + { + "type": "blockContainer", + "attrs": { + "id": "3" + }, + "content": [ + { + "type": "paragraph", + "attrs": { + "backgroundColor": "default", + "textColor": "default", + "textAlignment": "left" + }, + "content": [ + { + "type": "text", + "text": "eading" + } + ] + } + ] } ] } diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx index 77cd1cd21d..d0c44f19b2 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/ +// sideMenuContainerGeometry.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/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/containerChildToSiblingAfter.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildToSiblingAfter.html new file mode 100644 index 0000000000..d6a266cb63 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildToSiblingAfter.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/containerChildren.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildren.html new file mode 100644 index 0000000000..e9ef47b051 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerChildren.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/containerNestedChild.html b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerNestedChild.html new file mode 100644 index 0000000000..65339aa967 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/html/containerNestedChild.html @@ -0,0 +1 @@ +Inner child \ No newline at end of file diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildToSiblingAfter.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildToSiblingAfter.md new file mode 100644 index 0000000000..94e29d44e1 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildToSiblingAfter.md @@ -0,0 +1,3 @@ +Callout child 2 + +UI LABELAfter callout diff --git a/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildren.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildren.md new file mode 100644 index 0000000000..8d71d6bc10 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerChildren.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/containerNestedChild.md b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerNestedChild.md new file mode 100644 index 0000000000..72d309cf51 --- /dev/null +++ b/tests/src/unit/core/clipboard/copy/__snapshots__/text/plain/containerNestedChild.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..996d90b2c4 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 container's children, selected from inside it. + testCase: { + name: "containerChildren", + 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 container partway through, so the copied + // fragment is cut open on one side. + testCase: { + name: "containerChildToSiblingAfter", + 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 containers deep, so the fragment is cut open on both + // sides at two different levels. + testCase: { + name: "containerNestedChild", + 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 @@
-
+
-
+
+
+
+
+
+
+

Callout child

+
+
+
+
+ +
+
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/emptyChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/emptyChildren.html new file mode 100644 index 0000000000..acc0a7fb0c --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/emptyChildren.html @@ -0,0 +1,6 @@ +
+
+
+ +
+
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/nested.html new file mode 100644 index 0000000000..a77a72a56b --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/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/heading/toggleable.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html index 2982ce3673..9906681744 100644 --- a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/heading/toggleable.html @@ -1,34 +1,40 @@
-
-
-
- +
+ +
+

Toggle Heading

-
-
-
-
-
-
-

Child content

+
+
+
+
+

Child content

+
+
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..448aed69fa 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..845e7e9ea4 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..7ddd54c139 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/container/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/basic.html new file mode 100644 index 0000000000..ede3d1ad38 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/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/container/emptyChildren.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/emptyChildren.html new file mode 100644 index 0000000000..54690f4f5d --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/emptyChildren.html @@ -0,0 +1,4 @@ +
+
+ +
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/nested.html b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/nested.html new file mode 100644 index 0000000000..3a3fc433c6 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/container/nested.html @@ -0,0 +1,17 @@ +
+
+

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..fbb4149097 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/html/titledBlock/basic.html @@ -0,0 +1,8 @@ +
+
+
Heads up
+

First

+

Second

+
+
+
Title
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/basic.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/basic.md new file mode 100644 index 0000000000..6ce86c1ffa --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/basic.md @@ -0,0 +1,3 @@ +Callout child + +UI LABEL diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/emptyChildren.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/emptyChildren.md new file mode 100644 index 0000000000..79f979cb57 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/emptyChildren.md @@ -0,0 +1 @@ +UI LABEL diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/nested.md b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/nested.md new file mode 100644 index 0000000000..f2a5876e69 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/container/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..ff8d5b841b --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/markdown/titledBlock/basic.md @@ -0,0 +1,5 @@ +Heads upFirst + +Second + +Title diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/basic.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/basic.json new file mode 100644 index 0000000000..cd4b7fa368 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/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/container/emptyChildren.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/emptyChildren.json new file mode 100644 index 0000000000..7b02725a5d --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/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/container/nested.json b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/nested.json new file mode 100644 index 0000000000..4a45ff2fa9 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/nodes/container/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..84c1886fc6 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: "container/basic", + content: [ + { + type: "callout", + children: [ + { + type: "paragraph", + content: "Callout child", + }, + ], + }, + ], + }, + executeTest: testExportBlockNoteHTML, + }, + { + testCase: { + name: "container/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: "container/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/container.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/container.json new file mode 100644 index 0000000000..9c1e864bd8 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/container.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/containerEmptyChildren.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerEmptyChildren.json new file mode 100644 index 0000000000..3333d9ac16 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerEmptyChildren.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/containerExternalHTML.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerExternalHTML.json new file mode 100644 index 0000000000..7d0dc770cb --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerExternalHTML.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/containerNested.json b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerNested.json new file mode 100644 index 0000000000..f26c42c127 --- /dev/null +++ b/tests/src/unit/core/formatConversion/parse/__snapshots__/html/containerNested.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..e1d526ac2b 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 container nested inside another, with a non-default prop on each. + testCase: { + name: "containerNested", + content: `
+
+
+
+
+
+

Nested heading

+
+
+
+
+
+
+
+
+

Inner callout child

+
+
+
+
+
+
+
+
`, + }, + executeTest: testParseHTML, + }, + { + // An empty container children holder is filled to its schema minimum + // when parsed into a valid document. + testCase: { + name: "containerEmptyChildren", + content: `
+
+
+
+
`, + }, + executeTest: testParseHTML, + }, + { + // Container HTML without the `data-children-of` marker our own + // serializers add: what a container 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: "containerExternalHTML", + 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..4804f7b7a8 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -1,4 +1,19 @@ { + "alert": { + "config": { + "content": "inline", + "propSchema": {}, + "type": "alert", + }, + "extensions": undefined, + "implementation": { + "keyboard": [Function], + "node": null, + "render": [Function], + "renderFrame": [Function], + "toExternalHTML": [Function], + }, + }, "audio": { "config": { "content": "none", @@ -23,6 +38,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { "fileBlockAccept": [ "audio/*", @@ -63,6 +79,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "isolating": false, }, @@ -73,6 +90,33 @@ "toExternalHTML": [Function], }, }, + "callout": { + "config": { + "children": { + "allow": "blocks", + }, + "container": true, + "content": "none", + "propSchema": { + "flavor": { + "default": "tip", + "values": [ + "tip", + "info", + "warning", + ], + }, + }, + "type": "callout", + }, + "extensions": undefined, + "implementation": { + "keyboard": [Function], + "node": null, + "render": [Function], + "toExternalHTML": [Function], + }, + }, "checkListItem": { "config": { "content": "inline", @@ -103,6 +147,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "isolating": false, }, @@ -130,6 +175,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "code": true, "defining": true, @@ -167,6 +213,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "node": null, "parse": [Function], "render": [Function], @@ -183,6 +230,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "isolating": false, }, @@ -213,6 +261,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { "fileBlockAccept": [ "*/*", @@ -265,13 +314,16 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "dropsIntoChildren": [Function], "isolating": false, }, "node": null, "parse": [Function], "parseContent": [Function], "render": [Function], + "renderFrame": [Function], "runsBefore": [ "toggleListItem", ], @@ -315,6 +367,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { "fileBlockAccept": [ "image/*", @@ -359,6 +412,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "isolating": false, }, @@ -377,6 +431,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "node": null, "parse": [Function], "render": [Function], @@ -409,6 +464,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "isolating": false, }, @@ -439,6 +495,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { "isolating": false, }, @@ -472,6 +529,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "node": null, "render": [Function], "toExternalHTML": [Function], @@ -514,6 +572,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "node": null, "render": [Function], "toExternalHTML": [Function], @@ -534,6 +593,7 @@ [Function], ], "implementation": { + "keyboard": [Function], "node": null, "render": [Function], "toExternalHTML": [Function], @@ -565,13 +625,16 @@ [Function], ], "implementation": { + "keyboard": [Function], "meta": { + "dropsIntoChildren": [Function], "isolating": false, }, "node": null, "parse": [Function], "parseContent": [Function], "render": [Function], + "renderFrame": [Function], "runsBefore": [ "bulletListItem", ], @@ -615,6 +678,7 @@ }, "extensions": undefined, "implementation": { + "keyboard": [Function], "meta": { "fileBlockAccept": [ "video/*", diff --git a/tests/src/unit/core/testSchema.ts b/tests/src/unit/core/testSchema.ts index eca37363fa..4610fb23a1 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,94 @@ const SimpleCustomParagraph = createBlockSpec( }, ); +// A container block: it holds no inline content of its own, and its `contentDOM` +// is where its child blocks go. Covers containers 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", + container: true, + children: { + allow: "blocks", + }, + }, + { + render() { + 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); + + if (this.renderType === "dom") { + // Exercise fragment roots and chrome outside the children region in + // the shared HTML snapshots and full-HTML equality matrix. + const chrome = document.createElement("button"); + chrome.contentEditable = "false"; + chrome.textContent = "UI LABEL"; + callout.append(chrome); + const fragment = document.createDocumentFragment(); + fragment.append(callout); + return { dom: fragment, contentDOM: body }; + } + + return { + dom: callout, + contentDOM: 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 pure containers). +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"; + + const slot = document.createElement("div"); + slot.className = "alert-slot"; + frame.appendChild(slot); + + return { + dom: frame, + slot, + }; + }, + }, +); + // INLINE CONTENT -------------------------------------------------------------- const Mention = createInlineContentSpec( @@ -222,6 +310,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/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 container 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("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 with its content and children in the slot", () => { + const headless = BlockNoteEditor.create({ schema }); + try { + const html = headless.blocksToHTMLLossy([ + { + type: "frameAlert", + content: "Title", + children: [{ type: "paragraph", content: "Body" }], + }, + ]); + const output = document.createElement("div"); + output.innerHTML = html; + expect(output.querySelector(".alert-slot")?.textContent).toBe( + "TitleBody", + ); + expect(output.querySelector(".frame-counter")?.textContent).toBe( + "outside: 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/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx index 71101c7fa7..8eed24c8ff 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 container block, whose node view's node is itself the bnBlock, resolved +// directly instead of by position. +const createBoxBlock = createReactBlockSpec( + { + type: "box", + propSchema: {}, + content: "none", + container: true, + }, + { render: (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-container) block's node shape; container +// tests pass the real PM node instead. +function makeProps(getPos: () => number | undefined, node?: unknown) { return { getPos, + node: node ?? { type: { isInGroup: () => false } }, view: { state: { doc: editor.prosemirrorState.doc } }, } as unknown as Parameters[0]; } @@ -170,4 +186,14 @@ describe("useNodeViewBlock", () => { expect(resolved.id).toBe(target.id); expect(resolved).not.toBe(seed); }); + + it("resolves container 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..c5d091deae 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 + // containers 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); } };