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..d9b56d1216 --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,194 @@ +--- +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 add the `children` option to let it contain other blocks: + +```tsx +import { createReactBlockSpec } from "@blocknote/react"; + +export const createPanel = createReactBlockSpec( + { + type: "panel", + propSchema: {}, + content: "none", + children: { allow: "blocks" }, + }, + { + render: (props) => ( +
+ ), + }, +); +``` + +### Block Config + +The block config defines the content and child blocks your container can hold: + +`content:` Works the same as for [Custom Blocks](/docs/features/custom-schemas/custom-blocks#block-config-customblockconfig). When using the `children` option, choose `"none"`, `"inline"`, or `"plain"`. + +`children.allow:` Set to `"blocks"` to accept the editor's block types. You can also restrict a container 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. With `content: "none"`, 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, and add `children: { allow: "blocks" }`. Use `render` for the block's own text and `renderFrame` to style that content and its child blocks together: + +```tsx +import { createReactBlockSpec } from "@blocknote/react"; + +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: {}, + content: "inline", + children: { allow: "blocks" }, + }, + { + 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.
+
+You can also use `renderFrame` without the `children` option to style a block and its indented children together. 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 body:
+
+```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 starts a paragraph in the body. Moving the callout moves its title and body 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:
+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:
+children: { allow: "blocks" },
+placeable: "namedOnly",
+```
+
+`children.allow:` Accepts `"blocks"` 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 apply to containers with `content: "none"`. Blocks with `content: "inline"` or `"plain"` use `children: { allow: "blocks" }` and can have no child 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..4146907f28 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -56,6 +56,11 @@ type BlockConfig = {
   type: string;
   content: "inline" | "plain" | "none";
   readonly propSchema: PropSchema;
+  children?: {
+    allow: "blocks" | string[];
+    min?: number;
+  };
+  placeable?: "anywhere" | "namedOnly";
 };
 ```
 
@@ -72,6 +77,14 @@ type BlockConfig = {
   alert, so we set `content` to `"inline"`._
 
 
+
+  _Custom blocks can also contain child blocks by declaring the `children`
+  option, with or without editable content of their own. See [Container
+  Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
+`children?:` Defines which child blocks the block can contain. `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
diff --git a/docs/content/docs/features/export/typst.mdx b/docs/content/docs/features/export/typst.mdx
index 4a5e0ddaa3..39e908cba7 100644
--- a/docs/content/docs/features/export/typst.mdx
+++ b/docs/content/docs/features/export/typst.mdx
@@ -120,6 +120,22 @@ 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).
 
+### Container blocks
+
+A [container block](/docs/features/custom-schemas/container-blocks) holds
+child blocks, and its mapping decides where they go: the exporter renders the
+children first and passes them in as the mapping's last argument, rather than
+appending them after the container's own output. A container without a
+mapping is an error rather than a silent omission, since dropping it would
+drop its children too.
+
+```typescript
+myContainer: (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 bcc3d57ceb..317879a072 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/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/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-experimental-mobile-formatting-toolbar/vite.config.ts b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts
+++ b/examples/03-ui-components/14-experimental-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/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/vite.config.ts b/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts
index a96f1f04ff..cbf6ff2ffc 100644
--- a/examples/06-custom-schema/06-toggleable-blocks/vite.config.ts
+++ b/examples/06-custom-schema/06-toggleable-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/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..999bf62786
--- /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 declares the `children` config on `BlockConfig`. `children: { allow: "blocks" }` 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/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html
new file mode 100644
index 0000000000..19321f77b5
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/index.html
@@ -0,0 +1,14 @@
+
+  
+    
+    
+    Container Block
+    
+  
+  
+    
+ + + diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx new file mode 100644 index 0000000000..1260513388 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/main.tsx @@ -0,0 +1,11 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./src/App.jsx"; + +const root = createRoot(document.getElementById("root")!); +root.render( + + + , +); diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json new file mode 100644 index 0000000000..29778f9255 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-container-block", + "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "type": "module", + "private": true, + "version": "0.12.4", + "scripts": { + "start": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^9.0.2", + "@mantine/hooks": "^9.0.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-icons": "^5.5.0" + }, + "devDependencies": { + "@types/react": "^19.2.3", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "vite": "^8.0.0" + } +} diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx new file mode 100644 index 0000000000..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..7af68eff48 --- /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", + children: { allow: "blocks" }, + }, + { + // 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/09-container-block/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json new file mode 100644 index 0000000000..2aa62c56e6 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/tsconfig.json @@ -0,0 +1,32 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "composite": true, + "paths": { + "@shared/*": ["../../../shared/*"] + } + }, + "include": ["."], + "__ADD_FOR_LOCAL_DEV_references": [ + { + "path": "../../../packages/core/" + }, + { + "path": "../../../packages/react/" + } + ] +} diff --git a/examples/06-custom-schema/09-container-block/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/06-custom-schema/09-container-block/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts new file mode 100644 index 0000000000..cbf6ff2ffc --- /dev/null +++ b/examples/06-custom-schema/09-container-block/vite.config.ts @@ -0,0 +1,35 @@ +// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY +import react from "@vitejs/plugin-react"; +import * as fs from "fs"; +import * as path from "path"; +import { defineConfig } from "vite"; +// https://vitejs.dev/config/ +export default defineConfig(((conf: { command: string }) => ({ + plugins: [react()], + optimizeDeps: {}, + build: { + sourcemap: true, + }, + resolve: { + alias: + conf.command === "build" || + !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) + ? {} + : ({ + // The repo-wide alias for the shared test-utils directory (private, + // so it only resolves inside the monorepo). Harmless for examples + // that don't use it. + "@shared": path.resolve(__dirname, "../../../shared/"), + // Comment out the lines below to load a built version of blocknote + // or, keep as is to load live from sources with live reload working + "@blocknote/core": path.resolve( + __dirname, + "../../../packages/core/src/", + ), + "@blocknote/react": path.resolve( + __dirname, + "../../../packages/react/src/", + ), + } as any), + }, +})) as Parameters[0]); diff --git a/examples/06-custom-schema/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/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/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..20071bb974 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/README.md @@ -0,0 +1,19 @@ +# Callout Block + +In this example, we create a custom `Callout` block with a real rich-text title and a body of child blocks (a titled block), like a Notion-style callout. + +The block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body 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 jump into its body. +- Press Backspace at the start of the first body block to merge it back into the title. +- Press "/" inside the body 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..389f1cfc66 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/index.html @@ -0,0 +1,14 @@ + + + + + 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/13-callout-block/package.json b/examples/06-custom-schema/13-callout-block/package.json new file mode 100644 index 0000000000..9a61db5e93 --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/package.json @@ -0,0 +1,31 @@ +{ + "name": "@blocknote/example-custom-schema-callout-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/13-callout-block/src/App.tsx b/examples/06-custom-schema/13-callout-block/src/App.tsx new file mode 100644 index 0000000000..bb199625b9 --- /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 a body of child blocks.", + }, + { + 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 jump into the body, or Backspace at the start of the body to merge 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..d26c7eb81a --- /dev/null +++ b/examples/06-custom-schema/13-callout-block/src/Callout.tsx @@ -0,0 +1,32 @@ +import { createReactBlockSpec } from "@blocknote/react"; + +import "./styles.css"; + +// The Callout block: a titled block. `content: "inline"` plus `children` +// gives the block its own rich-text title with child blocks as its body. +// `render` draws the title row (the title mounts into `contentRef`), while +// `renderFrame` draws the box around the title and the body together. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: {}, + content: "inline", + // The title is ordinary inline content; the children are the body. + children: { allow: "blocks" }, + }, + { + 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..d2d1022389 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -8,12 +8,48 @@ import { it, } from "vite-plus/test"; +import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../../../schema/blocks/createSpec.js"; + +// The editor stays headless, so these blocks are never rendered. `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", + 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", + children: { allow: ["cell"], min: 2 }, + }), + cell: container("cell", { + content: "none", + children: { allow: "blocks" }, + placeable: "namedOnly", + }), + } as const, +}); let editor: BlockNoteEditor; beforeAll(() => { - editor = BlockNoteEditor.create() as any; + editor = BlockNoteEditor.create({ schema }) as any; }); afterAll(() => { @@ -28,21 +64,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 +91,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 b339f66ec1..532da5aaef 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(); @@ -146,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 7101698a39..e3817aa3a6 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,11 +1,45 @@ -import { EditorState } from "prosemirror-state"; +import { Fragment, type Node } from "prosemirror-model"; +import { EditorState, TextSelection } from "prosemirror-state"; import { + type BlockInfo, getBlockInfoAt, getLastDescendantBlockInfo, getPrevBlockInfo, + getParentBlockInfo, } from "../../../getBlockInfoFromPos.js"; +/** Returns compatible text to append, or undefined when the blocks cannot merge. */ +export function getMergeContent( + current: Extract, + next: Extract, +): Fragment | undefined { + const inline = + current.contentKind === "inline" && next.contentKind === "inline"; + const ownedText = + current.hasOwnedChildren && + current.content.node.isTextblock && + next.content.node.isTextblock; + if (!inline && !ownedText) { + return undefined; + } + if (current.contentKind === "plain") { + const type = current.content.node.type; + const children: Node[] = []; + next.content.node.forEach((child) => { + const text = + child.type === type.schema.linebreakReplacement + ? "\n" + : child.textContent; + if (text) { + children.push(type.schema.text(text, type.allowedMarks(child.marks))); + } + }); + return Fragment.from(children); + } + return next.content.node.content; +} + /** * Merges the block starting at `posBetweenBlocks` into the block visually * above it, by deleting the boundary between the two. @@ -15,9 +49,9 @@ import { * i.e. its `BlockInfo`'s `block.beforePos`. The block above is found by walking * back from there. * @returns A tiptap command that returns `false` (leaving the doc untouched) - * when the two blocks can't merge: no block above, either side isn't an - * inline-content block, or the block above is empty (deleting it is handled - * elsewhere). + * when the two blocks can't merge: no compatible text block above, or the + * block above is empty (deleting it is handled elsewhere). An owning block + * can also merge plain text, dropping formatting that its schema disallows. */ export const mergeBlocksCommand = (posBetweenBlocks: number) => @@ -30,42 +64,38 @@ export const mergeBlocksCommand = }) => { const nextBlockInfo = getBlockInfoAt(state.doc, posBetweenBlocks); - const prevBlockInfo = getPrevBlockInfo( + const prevSibling = getPrevBlockInfo( state.doc, nextBlockInfo.block.beforePos, ); - + const parent = prevSibling + ? undefined + : getParentBlockInfo(state.doc, nextBlockInfo.block.beforePos); + // An owned body's first block can merge into its title. Ordinary nested + // blocks still need a preceding sibling; lifting handles their boundary. + const prevBlockInfo = prevSibling + ? getLastDescendantBlockInfo(prevSibling) + : parent?.hasOwnedChildren + ? parent + : undefined; if (!prevBlockInfo) { return false; } - // The block we merge into is the last descendant of the previous block: - // visually, that's the block directly above the boundary. - const bottomNestedBlockInfo = getLastDescendantBlockInfo(prevBlockInfo); - - // Only inline-content blocks can merge, and merging into an empty block - // is handled elsewhere (by deleting the empty block instead). Merging - // into or out of container blocks (columnLists, callouts, ...) is - // intentionally unsupported; the container-boundary Backspace/Delete - // branches in `KeyboardShortcutsExtension` handle those cases by moving - // blocks across the boundary instead of merging their content. if ( - !bottomNestedBlockInfo.hasContent || - bottomNestedBlockInfo.contentKind !== "inline" || - bottomNestedBlockInfo.isContentEmpty || - !nextBlockInfo.hasContent || - nextBlockInfo.contentKind !== "inline" + !prevBlockInfo.hasContent || + prevBlockInfo.isContentEmpty || + !nextBlockInfo.hasContent ) { return false; } + const content = getMergeContent(prevBlockInfo, nextBlockInfo); + if (content === undefined) { + return false; + } - // 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) @@ -86,16 +116,21 @@ export const mergeBlocksCommand = ); } - // Deletes the boundary between the two blocks. Can be thought of as - // removing the closing tags of the first block and the opening tags of the - // second one to stitch them together. if (dispatch) { - dispatch( - state.tr.delete( - bottomNestedBlockInfo.contentEnd, - nextBlockInfo.contentStart, - ), - ); + if (content !== nextBlockInfo.content.node.content) { + state.tr + .replaceWith( + prevBlockInfo.contentEnd, + nextBlockInfo.contentEnd, + content, + ) + .setSelection( + TextSelection.create(state.tr.doc, prevBlockInfo.contentEnd), + ); + } else { + state.tr.delete(prevBlockInfo.contentEnd, nextBlockInfo.contentStart); + } + dispatch(state.tr); } return true; 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..7dc8fa9aa7 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -3,6 +3,11 @@ import { Transaction } from "prosemirror-state"; import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; +import { + CHILD_CONTAINER_GROUP, + hasOwnedChildren, +} from "../../../../schema/blocks/children.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 +23,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 ); } @@ -182,7 +187,14 @@ export function liftItem( return false; } - if ($from.node(range.depth - 1).type === itemType) { + const parent = $from.node(range.depth - 1); + // A titled block's body belongs to the block that owns it, so unnesting + // stops at its edge rather than lifting the block out of it. + if (parent.type === itemType && hasOwnedChildren(parent)) { + return false; + } + + if (parent.type === itemType) { // Inside a parent node return liftToOuterList(tr, itemType, groupType, range); // change 2 } @@ -208,8 +220,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..15caf1dca1 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -27,7 +27,8 @@ import { } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; -import { getPmSchema } from "../../../pmUtil.js"; +import { getBlockSchema, getPmSchema } from "../../../pmUtil.js"; +import { createBlockGroup } 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 +71,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 +86,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 +108,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 (targetConfig.children !== undefined) { + 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 +191,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 +209,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 +238,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 +262,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 +547,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 +558,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 +666,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..a32488ad5b --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts @@ -0,0 +1,96 @@ +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +const renderDiv = () => { + const dom = document.createElement("div"); + return { dom, contentDOM: dom }; +}; + +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + children: { allow: "blocks" }, + }, + { render: renderDiv }, +)(); + +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + children: { + allow: ["gridCell"], + min: 2, + }, + }, + { render: renderDiv }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + children: { allow: "blocks" }, + 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", + children: { + allow: "blocks", + min: 2, + }, + }, + { render: renderDiv }, +)(); + +// A titled block: an ordinary block with inline content (the title) whose +// `children` are a body that belongs to it. The frame draws the box around +// title and body together. +const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + children: { allow: "blocks" }, + }, + { + 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..db5606501c --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,598 @@ +// @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 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(); + }); + + // 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, + 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..ea467db1e1 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,131 @@ +import { Fragment, type Node } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; + +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) { + const children = node.type.spec.blockConfig?.children; + return !!children && (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 childrenConfig = container.type.spec.blockConfig?.children; + const min = childrenConfig ? (childrenConfig.min ?? 1) : 1; + const survivors: Node[] = []; + const emptied: { from: number; to: number }[] = []; + container.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + survivors.push(child); + } 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. + const replacement: Node[] = []; + for (const 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..7fd3b4dc58 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/plainBlocks.test.ts @@ -0,0 +1,197 @@ +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", + children: { allow: "blocks" }, + }, + { + 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 with owned 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.each(["Backspace", "Delete"] as const)( + "%s preserves child text when merging into plain content", + (key) => { + const editor = editorWith("Source", [ + { + id: "body", + type: "paragraph", + content: [ + { type: "text", text: "Bold", styles: { bold: true } }, + "\nNext", + ], + children: [{ id: "nested", type: "paragraph", content: "Nested" }], + }, + ]); + editor.setTextCursorPosition("note", "end"); + const joinPosition = editor.prosemirrorState.selection.from; + editor.setTextCursorPosition( + key === "Backspace" ? "body" : "note", + key === "Backspace" ? "start" : "end", + ); + press(editor, key); + expect(editor.prosemirrorState.selection.from).toBe(joinPosition); + expect(text(editor, "note")).toBe("SourceBold\nNext"); + expect(editor.getBlock("note")!.content).toEqual([ + { type: "text", text: "SourceBold\nNext", styles: {} }, + ]); + expect( + editor.getBlock("note")!.children.map((block) => block.id), + ).toEqual(["nested"]); + expect(editor.getBlock("body")).toBeUndefined(); + editor.prosemirrorState.doc.check(); + }, + ); + + it("keeps owned children 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..af683b567d --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/titledBlocks.test.ts @@ -0,0 +1,352 @@ +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { + hasOwnedChildren, + 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("recognizes declared ownership on ordinary blocks", () => { + const editor = editorWith(withAlert()); + + 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(hasOwnedChildren(alert.node)).toBe(true); + expect(alert.node.attrs.id).toBe("w"); + expect(alert.node.firstChild!.attrs).not.toHaveProperty("id"); + + expect(hasOwnedChildren(getNodeById("pre", tr.doc)!.node)).toBe(false); + + // 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 moves it into the body, whole", () => { + const editor = editorWith(withAlert()); + editor.setTextCursorPosition("post", "start"); + press(editor, "Backspace"); + + // Moved in as its own block: text never merges across the edge. + expect(shape(editor.document)).toBe( + 'paragraph"Before", alert"Title"[paragraph"One", paragraph"Two", paragraph"After"]', + ); + }); + + 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/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..663a5c9689 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, @@ -159,6 +160,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 +173,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, @@ -197,10 +206,30 @@ function serializeBlock< contentDOM?: HTMLElement; }; - 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..5080902c0d 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,140 @@ 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 ownership 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.hasOwnedChildren).toBe(type !== "paragraph"); + 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..e9712d1b4c 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -7,6 +7,11 @@ import { Transaction, } from "prosemirror-state"; +import { + CHILD_CONTAINER_GROUP, + isContainerNode, + hasOwnedChildren, +} from "../schema/blocks/children.js"; import type { BlockConfig } from "../schema/blocks/types.js"; /** @@ -69,6 +74,7 @@ export type BlockInfo = { children: ChildrenInfo; content?: undefined; hasContent: false; + hasOwnedChildren: true; contentStart?: undefined; contentEnd?: undefined; contentKind?: undefined; @@ -105,6 +111,8 @@ export type BlockInfo = { * `hasContent: false`. */ hasContent: true; + /** Whether children belong to this block, even before a body exists. */ + hasOwnedChildren: boolean; } ); @@ -319,9 +327,10 @@ 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, + hasOwnedChildren: true, block, children: { ...block, @@ -375,6 +384,7 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { return { hasContent: true, + hasOwnedChildren: hasOwnedChildren(node), block, content, children, @@ -452,7 +462,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 +593,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 +603,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/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/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..39f49f2ed7 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,31 @@ describe("Exporter missing mappings", () => { ).toThrow('missing a style mapping for style "bold"'); }); }); + +describe("Exporter block types outside its schema", () => { + it("treats a childless block of an unknown type as a regular block", () => { + // 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().isContainerBlock({ + type: "mathBlock", + children: [], + }), + ).toBe(false); + }); + + it("throws when a block of an unknown type has children", () => { + // Ambiguous: without the spec there is no way to tell whether the + // mapping places these children itself (container) or the exporter + // appends them (regular block), and guessing puts them in the wrong + // place silently. + expect(() => + new EmptyMappingsExporter().isContainerBlock({ + type: "columnList", + children: [{ type: "column" }], + }), + ).toThrow( + 'Exporter has no block spec for block type "columnList", and blocks of that type in this document have children', + ); + }); +}); diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index 6ca0efa222..babd4b1cc6 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,20 @@ export abstract class Exporter< public readonly options: ExporterOptions, ) {} + /** Container mappings place their own children; regular mappings do not. */ + public isContainerBlock(block: { + type: string; + children?: unknown[]; + }): boolean { + const spec = this.schema.blockSpecs[block.type]; + if (!spec && block.children?.length) { + throw new Error( + `Exporter has no block spec for block type "${block.type}", and blocks of that type in this document have children. Add its spec to the exporter schema so it can determine who renders the children.`, + ); + } + return spec?.config.children !== undefined; + } + /** * The strings this exporter renders into the produced document - the * `exporter` section of the configured dictionary (the `dictionary` @@ -139,7 +153,7 @@ export abstract class Exporter< public abstract transformStyledText(styledText: StyledText): TS; public async mapBlock( - block: BlockFromConfig, + block: BlockNoDefaults, nestingLevel: number, numberedListIndex: number, children?: Array>, @@ -147,7 +161,9 @@ export abstract class Exporter< const mapping = this.mappings.blockMapping[block.type]; if (!mapping) { throw new Error( - `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`, + this.isContainerBlock(block) + ? `No mapping found for container block type "${block.type}". Container blocks require an explicit block mapping that places their children.` + : `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`, ); } return mapping(block, this, nestingLevel, numberedListIndex, children); diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index fddd2712e9..1300fa2112 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/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 2f3464a2db..5e4723c847 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; -import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { + type PartialBlock, + defaultBlockSpecs, +} from "../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { createBlockSpec } from "../../../schema/index.js"; @@ -548,3 +551,42 @@ describe("KeyboardShortcutsExtension hardBreakShortcut", () => { 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 20e5941fb6..56f2c043bd 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -1,29 +1,79 @@ -import { Extension } from "@tiptap/core"; -import { Fragment, Node } from "prosemirror-model"; -import { TextSelection } from "prosemirror-state"; +import { type ChainedCommands, Extension } from "@tiptap/core"; +import { Fragment } from "prosemirror-model"; +import { 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 { 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"; @@ -49,7 +99,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const selectionAtBlockStart = - state.selection.from === blockInfo.content.beforePos + 1; + state.selection.from === blockInfo.contentStart; const isParagraph = blockInfo.content.node.type.name === "paragraph"; @@ -71,10 +121,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { content } = blockInfo; const selectionAtBlockStart = - state.selection.from === content.beforePos + 1; + state.selection.from === blockInfo.contentStart; if (selectionAtBlockStart) { return liftItem( @@ -94,28 +143,28 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { block, content } = blockInfo; + const { block: blockContainer } = blockInfo; - const prevBlockInfo = getPrevBlockInfo( + const prevSibling = getPrevBlockInfo( state.doc, blockInfo.block.beforePos, ); - // If the previous block has no inline content, it can't be merged. - // It's instead deleted, which is done later in the chan, so we - // return early here. + // A preceding container or owned body takes the move branch below. + // With no sibling, mergeBlocksCommand checks for an owning title. if ( - !prevBlockInfo || - !prevBlockInfo.hasContent || - prevBlockInfo.contentKind !== "inline" + prevSibling && + (!prevSibling.hasContent || + prevSibling.contentKind !== "inline" || + (prevSibling.children && prevSibling.hasOwnedChildren)) ) { 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() @@ -126,93 +175,68 @@ 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. - () => - 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. + // 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) { + 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 && + !(target.children && target.hasOwnedChildren) + ) { + 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; }), // Deletes the current block if it's an empty block with inline content, @@ -225,8 +249,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const blockEmpty = - blockInfo.content.node.childCount === 0 && - blockInfo.contentKind === "inline"; + blockInfo.isContentEmpty && blockInfo.contentKind === "inline"; if (blockEmpty) { const prevBlockInfo = getPrevBlockInfo( @@ -241,14 +264,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!bottomNestedPrevBlockInfo.hasContent) { return false; } - if ( - !bottomNestedPrevBlockInfo || - !bottomNestedPrevBlockInfo.hasContent - ) { - return false; - } - let chainedCommands = chain(); + const chainedCommands = chain(); // Moves the children the current block. if (blockInfo.children) { @@ -258,24 +275,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ ); } - if (bottomNestedPrevBlockInfo.contentKind === "table") { - chainedCommands = chainedCommands.setTextSelection( - tableContentCaretPos( - bottomNestedPrevBlockInfo.content, - "end", - ), - ); - } else if (bottomNestedPrevBlockInfo.contentKind === "none") { - chainedCommands = chainedCommands.setNodeSelection( - bottomNestedPrevBlockInfo.content.beforePos, + chainedCommands.command(({ tr }) => { + tr.setSelection( + blockEdgeSelection(tr.doc, bottomNestedPrevBlockInfo, "end"), ); - } else { - const contentEndPos = - bottomNestedPrevBlockInfo.content.afterPos - 1; - - chainedCommands = - chainedCommands.setTextSelection(contentEndPos); - } + return true; + }); return chainedCommands .deleteRange({ @@ -300,7 +305,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( @@ -309,6 +314,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) { @@ -356,56 +364,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, ); } @@ -420,7 +406,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!blockInfo.hasContent) { return false; } - const { block, content } = blockInfo; + const { block: blockContainer } = blockInfo; const nextBlockInfo = getNextBlockInfo( state.doc, @@ -431,10 +417,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() @@ -445,103 +431,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; - } - - 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) { - return false; + let $boundary = tr.doc.resolve(blockInfo.block.afterPos); + while ( + !$boundary.nodeAfter && + $boundary.depth > 0 && + isContainerNode($boundary.parent.type) + ) { + $boundary = tr.doc.resolve($boundary.after()); } - - const selectionAtBlockEnd = - tr.selection.from === blockInfo.content.afterPos - 1; - if (!selectionAtBlockEnd) { + const nextNode = $boundary.nodeAfter; + if (!nextNode) { return false; } - const $pos = tr.doc.resolve(blockInfo.block.afterPos); - - const nextBlock = $pos.nodeAfter; - if (nextBlock) { + const crossedBoundary = $boundary.pos !== blockInfo.block.afterPos; + if (!crossedBoundary && !isContainerNode(nextNode.type)) { 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 @@ -555,66 +487,35 @@ 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, ); } @@ -630,8 +531,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( @@ -642,21 +542,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({ @@ -681,7 +574,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( @@ -702,7 +595,6 @@ export const KeyboardShortcutsExtension = Extension.create<{ nextBlockInfo.isContentEmpty); if (nextBlockNotTableAndNoContent) { - const childBlocks = nextBlockInfo.block.node.lastChild!.content; return chain() .deleteRange({ from: nextBlockInfo.block.beforePos, @@ -710,9 +602,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ }) .insertContentAt( blockInfo.block.afterPos, - nextBlockInfo.block.node.childCount === 2 - ? childBlocks - : null, + nextBlockInfo.children?.node.content ?? null, ) .run(); } @@ -732,15 +622,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 ( @@ -766,14 +656,6 @@ 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"; @@ -818,6 +700,63 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the block is empty and the last child of a container or an + // owned-children body, 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 || !owner.hasOwnedChildren) { + 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. () => @@ -826,16 +765,21 @@ 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 && + !blockInfo.hasOwnedChildren + ) { + const newBlockInsertionPos = blockContainer.afterPos; const newBlockContentPos = newBlockInsertionPos + 2; if (dispatch) { @@ -874,6 +818,69 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // Enter in a titled block's own content (a callout's title) starts its + // body rather than splitting the block in two: whatever follows the + // cursor becomes the body's first block, and the body the callout + // already had stays where it is. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.hasContent) { + return false; + } + + if (!blockInfo.hasOwnedChildren) { + 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. () => @@ -882,11 +889,10 @@ 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) { chain() @@ -909,42 +915,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/index.ts b/packages/core/src/index.ts index b4f220e1e2..be0fac887b 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..87da0dffed 100644 --- a/packages/core/src/pm-nodes/BlockContainer.ts +++ b/packages/core/src/pm-nodes/BlockContainer.ts @@ -1,10 +1,67 @@ -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 { ignoreNonContentMutations } from "../schema/nodeViewMutations.js"; import { mergeCSSClasses } from "../util/browser.js"; import { suggestionMarks } from "./suggestionMarks.js"; +/** 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); + }, + }; +} + // Object containing all possible block attributes. const BlockAttributes: Record = { blockColor: "data-block-color", @@ -88,4 +145,57 @@ 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); + } + + const nodeView: NodeView = { + dom, + contentDOM: frameView.contentDOM ?? contentDOM, + update(node, decorations, innerDecorations) { + // Changing the wrapper or block type replaces the complete view. + return ( + node.sameMarkup(props.node) && + node.firstChild?.type === props.node.firstChild?.type && + (frameView.update?.(node, decorations, innerDecorations) ?? false) + ); + }, + stopEvent(event) { + // Author chrome handles its own events; the slot remains editable. + const target = event.target; + return ( + (target instanceof globalThis.Node && + frameView.dom.contains(target) && + !nodeView.contentDOM?.contains(target)) || + (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) { + ignoreNonContentMutations(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..0945e577c0 --- /dev/null +++ b/packages/core/src/schema/blocks/children.test.ts @@ -0,0 +1,156 @@ +// @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("throws for an allow array that permits nothing", () => { + expect(() => childrenContentExpression({ allow: [] })).toThrow( + /permits nothing/, + ); + }); +}); + +type ContainerFixture = { + children: ChildrenConfig; + content?: "none" | "inline" | "plain"; + placeable?: "anywhere" | "namedOnly"; +}; + +function specsWith(containers: Record) { + return { + paragraph: { config: { content: "inline" as const } }, + heading: { config: { content: "inline" as const } }, + ...Object.fromEntries( + Object.entries(containers).map( + ([type, { children, content, placeable }]) => [ + type, + { + config: { + content: content ?? ("none" as const), + children, + placeable, + }, + }, + ], + ), + ), + }; +} + +const validate = (containers: Record) => () => + validateChildrenConfigs(specsWith(containers)); + +describe("validateChildrenConfigs", () => { + it("accepts recursive containers, named-only children, and titled blocks", () => { + expect( + validate({ callout: { children: { allow: "blocks" } } }), + ).not.toThrow(); + expect( + validate({ + // gridCell is a terminating alternative to the recursive grid. + grid: { children: { allow: ["gridCell", "grid"], min: 2 } }, + gridCell: { children: { allow: "blocks" }, placeable: "namedOnly" }, + alert: { children: { allow: "blocks" }, content: "inline" }, + source: { children: { allow: "blocks" }, content: "plain" }, + }), + ).not.toThrow(); + }); + + it.each(["inline", "plain"] as const)( + "rejects %s child restrictions the shared blockGroup cannot enforce", + (content) => { + for (const children of [ + { allow: "blocks", min: 2 }, + { allow: ["cell"] }, + ] as const) { + expect( + validate({ + alert: { content, children }, + cell: { children: { allow: "blocks" } }, + }), + ).toThrow(/blocks with inline or plain content support/); + } + }, + ); + + it("does not treat a titled block's content node as an allowed container", () => { + expect( + validate({ + box: { children: { allow: ["alert"] } }, + alert: { content: "inline", children: { allow: "blocks" } }, + }), + ).toThrow(/regular block/); + }); + + it("rejects named-only placement on a shared regular block wrapper", () => { + expect( + validate({ + alert: { + content: "inline", + children: { allow: "blocks" }, + placeable: "namedOnly", + }, + }), + ).toThrow(/requires a container node/); + }); + + // Tables do not support owned child blocks. + it("rejects children combined with table content", () => { + for (const content of ["table"] as const) { + expect(() => + validateChildrenConfigs({ + box: { + config: { content, children: { allow: "blocks" } }, + }, + }), + ).toThrow(/not supported on table blocks/); + } + }); + + it.each(["typo", "blockGroupChild", "toString"])( + "rejects an allow entry that is not a configured block: %s", + (allowed) => { + expect(validate({ box: { children: { allow: [allowed] } } })).toThrow( + /not a configured block type/, + ); + }, + ); + + it("rejects a regular block type in the allow array", () => { + expect(validate({ box: { 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..7b4dd04071 --- /dev/null +++ b/packages/core/src/schema/blocks/children.ts @@ -0,0 +1,117 @@ +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*: one whose own node + * holds its children. A block that has content of its own keeps its ordinary + * shape, and its `children` declare owned children instead. + * @internal + */ +export function isContainerConfig(config: { + content: string; + children?: unknown; +}): boolean { + return config.children !== undefined && config.content === "none"; +} + +// 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"); +} + +/** + * Whether `node` is a block whose children are owned children: a container + * block, or a `blockContainer` whose content node declares `children`. + */ +export function hasOwnedChildren(node: Node): boolean { + return ( + isContainerNode(node.type) || + (node.type.name === "blockContainer" && + node.firstChild?.type.spec.blockConfig?.children !== undefined) + ); +} + +// 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, 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..7505f47068 --- /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", + children: { allow: "blocks" }, + }, + { + render: renderDiv, + parse: (el) => + el.classList.contains("card") + ? { tone: el.getAttribute("data-tone") ?? undefined } + : undefined, + }, +)(); + +// The same, but taking over the parsing of its own body. +const Quote = createBlockSpec( + { + type: "quote" as const, + propSchema: {}, + content: "none", + children: { allow: "blocks" }, + }, + { + 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", + children: { allow: "blocks" }, + } 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..d8380e4f13 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", + children: { allow: "blocks" }, + }, + {}, + ), + }, + }), + ).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", + children: { allow: "blocks" }, + }, + { 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, + children: { allow: "blocks" }, + }, + {}, + )(), + ).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, + children: { allow: "blocks" }, + }, + { + 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", + children: { allow: "blocks" }, + }, + { + 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", + children: { allow: "blocks" }, + }, + { + 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 22fb91c321..02e1902aa3 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,20 @@ import { } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + BLOCK_GROUP_CHILD_GROUP, + CHILD_CONTAINER_GROUP, + childrenContentExpression, + containerNodePriority, + isContainerConfig, +} from "./children.js"; +import { applyContainerAttributes } from "./containerAttributes.js"; +import { + applyDOMAttributes, getBlockFromNodeView, + isDocumentFragment, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; @@ -45,9 +57,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 +174,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,280 +215,241 @@ 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; + ignoreNonContentMutations(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 contentless block builds a container node. A block with content of + // its own keeps its ordinary shape, and its `children` are owned children + // instead. + const isContainer = isContainerConfig(blockConfig); - // Ignores DOM mutations that don't affect the block's content, so - // that browser extensions which rewrite the DOM (e.g. Dark Reader) - // can't trigger an infinite re-render loop that freezes the tab. - ignoreNonContentMutations(typedNodeView); + 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 @@ -366,51 +461,65 @@ 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; + } + return { config: blockConfig, implementation: { ...blockImplementation, 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, }; @@ -520,6 +629,16 @@ export function createBlockSpec< : extensionsOrCreator : undefined; + // Only a contentless block is a container here. A block with content of + // its own keeps its ordinary shape, and its `children` are owned children + // instead. + 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: { @@ -538,6 +657,11 @@ export function createBlockSpec< return undefined; } + if (isContainer) { + applyDOMAttributes(output.dom, this.blockContentDOMAttributes); + return output; + } + return wrapInBlockStructure( output, block.type, @@ -557,6 +681,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..cc8970940e 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,7 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j import { mergeCSSClasses } from "../../util/browser.js"; import { camelToDataKebab } from "../../util/string.js"; import { PropSchema, Props } from "../propTypes.js"; -import { LooseBlockSpec } from "./types.js"; +import { BlockConfig, ChildrenConfig, LooseBlockSpec } from "./types.js"; // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. @@ -157,6 +157,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 +265,12 @@ export function createBlockSpecFromTiptapNode< node: Node; type: string; content: "inline" | "table" | "none" | "plain"; + // Declares the block's container semantics (child counts/repair etc.) + // even though the node itself is hand-written. The node's own content + // expression stays authoritative for the PM schema, while BlockNote-level + // behavior (repair, seeding, validation) reads this config. + children?: ChildrenConfig; + placeable?: BlockConfig["placeable"]; }, P extends PropSchema, >( @@ -244,6 +283,10 @@ export function createBlockSpecFromTiptapNode< type: config.type as T["type"], content: config.content, propSchema, + ...(config.children !== undefined ? { children: config.children } : {}), + ...(config.placeable !== undefined + ? { placeable: config.placeable } + : {}), }, implementation: { node: config.node, 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..cb0419b699 --- /dev/null +++ b/packages/core/src/schema/blocks/renderFrame.test.ts @@ -0,0 +1,450 @@ +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", + children: { allow: "blocks" }, + }, + { + 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", + children: { allow: "blocks" }, + }, + { + 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", + children: { allow: "blocks" }, + }, + { + 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..f6a06eb73e 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 { @@ -67,6 +67,16 @@ export interface BlockConfigMeta< */ isolating?: boolean; + /** + * Whether this block type gets a side menu drag handle (and can be dragged + * by it). Applies to any block type, container or not: e.g. a + * "locked" block can opt out of dragging entirely. A block that opts out is + * skipped when looking for a drag handle, so the handle falls through to the + * nearest draggable ancestor. + * @default true + */ + draggable?: boolean; + /** * Enables syntax highlighting of the contents of the block with the result of this callback */ @@ -81,9 +91,49 @@ 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[]; + +/** + * Marks a block as a *container*: a block whose body is other blocks, exposed + * as `block.children` at runtime. + * + * The config describes one uniform body, semantically a single implicit + * slot. Ordered multi-slot bodies (a `sequence` of slots) can be added later + * as a sibling form. */ +export type ChildrenConfig = { + /** What may appear as a child. See {@link ChildrenAllow}. */ + allow: ChildrenAllow; + /** + * 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 +156,28 @@ 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) + /** + * Declares owned child blocks, exposed on `block.children`. + * With `content: "none"`, the block is a pure container whose `render` + * mounts children through contentDOM (React: contentRef). + * With `content: "inline"` or `"plain"`, `children: { allow: "blocks" }` + * gives the block owned children below its own text. These children remain + * optional; their types and minimum count cannot be restricted. + * `renderFrame` independently styles the block's content and children. + */ + children?: ChildrenConfig; + /** + * 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,7 +294,7 @@ export type LooseBlockSpec< config: BlockConfig; implementation: Omit< BlockImplementation, - "render" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" > & { // purposefully stub the types for render and toExternalHTML since they reference the block render: ( @@ -240,9 +310,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,7 +365,7 @@ export type BlockSpecs = { config: BlockSpec["config"]; implementation: Omit< BlockSpec["implementation"], - "render" | "toExternalHTML" + "render" | "renderFrame" | "toExternalHTML" > & { // purposefully stub the types for render and toExternalHTML since they reference the block render: ( @@ -299,9 +381,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 +660,14 @@ 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 + * A function that converts the block into a DOM element. */ render: ( this: @@ -603,20 +699,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 (blocks with `children`), where + * recreating the node view would remount every child block: e.g. column + * resizing patches widths in place through this hook. Non-container + * blocks always recreate on attr changes (see + * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464). */ - update?: (node: ProsemirrorNode) => boolean; - destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; + /** + * 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 +866,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..5525dc8cc5 --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -0,0 +1,66 @@ +import { isContainerConfig } from "./children.js"; +import type { BlockConfig } from "./types.js"; + +/** Reject declarations ProseMirror would accept with different semantics. */ +export function validateChildrenConfigs( + blockSpecs: Record< + string, + { config: Pick } + >, +) { + for (const [type, { config }] of Object.entries(blockSpecs)) { + 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; + } + + const { allow, min } = config.children; + if (config.content === "table") { + fail(type, "`children` is not supported on table blocks."); + } + + // Text blocks share an optional child group, so only pure containers + // can restrict their children's types or minimum count. + if ( + config.content !== "none" && + (allow !== "blocks" || min !== undefined) + ) { + fail( + type, + 'blocks with inline or plain content support `children: { allow: "blocks" }` only. Child-type and minimum-count restrictions require a pure container.', + ); + } + + // 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/schema.ts b/packages/core/src/schema/schema.ts index a9c9f814d3..7b0ffde4bf 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/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 2bf0e4fa57..a61d60c4ee 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -1,4 +1,4 @@ -import { getNodeById } from "@blocknote/core"; +import { getNodeById, isContainerNode } from "@blocknote/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -29,6 +29,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 507f2cd46f..c3a39005a5 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -111,6 +111,13 @@ width: 100%; } +/* Container blocks own their outer DOM: the block's root element is the one + its `render` returned, so the wrapper React needs around it must not be a + box of its own. */ +.bn-react-node-view-renderer.bn-container-node-view { + display: contents; +} + /* Indent line styling */ .bn-block-group .bn-block:not(:has(.bn-toggle-wrapper)) diff --git a/packages/react/src/schema/@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..4c29c09a15 --- /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", + children: { allow: "blocks" }, + }, + { + 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..09208b7915 --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.frame.browser.test.tsx @@ -0,0 +1,173 @@ +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, + children: { allow: "blocks" }, + }, + { + 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..dc10df47af 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -1,16 +1,21 @@ import { + applyContainerAttributes, + isContainerConfig, BlockConfig, + BlockFromConfig, BlockConfigOrCreator, BlockImplementation, BlockNoDefaults, BlockNoteEditor, BlockSpec, camelToDataKebab, + ChildrenConfig, CustomBlockImplementation, Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, mergeCSSClasses, + nodeToBlock, Props, PropSchema, } from "@blocknote/core"; @@ -20,12 +25,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 +43,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 without `children` (and the table block, whose + // content is managed separately) has nothing to place. +} & (Config extends { children: ChildrenConfig } + ? { contentRef: (node: HTMLElement | null) => void } + : Config["content"] extends "inline" | "plain" + ? { contentRef: (node: HTMLElement | null) => void } + : object); + +// extend BlockConfig but use a React render function +export type 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 +80,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 +165,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 +186,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 +259,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 +408,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 d4e59a60b4..1001b8cbe8 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,7 +1,7 @@ import react from "@vitejs/plugin-react"; import * as path from "path"; import { webpackStats } from "rollup-plugin-webpack-stats"; -import { defineConfig, type UserConfig } from "vite-plus"; +import { configDefaults, defineConfig, type UserConfig } from "vite-plus"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; @@ -26,6 +26,9 @@ export default defineConfig( test: { environment: "jsdom", setupFiles: ["./vitestSetup.ts"], + // `.browser.test` files need a real browser; the tests package's + // browser suite runs them. + exclude: [...configDefaults.exclude, "**/*.browser.test.*"], }, plugins: [react(), webpackStats()], // used so that vitest resolves the core package from the sources instead of the built version diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 11367c90f2..c1ca3a244c 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,163 @@ describe("exporter", () => { ); }); +describe("custom container blocks", () => { + const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + children: { allow: "blocks" }, + }, + { + 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 + // mapping renders the title into its own paragraph and places the children + // after it; because the block counts as a container, transformBlocks must + // not append them a second time. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + children: { allow: "blocks" }, + }, + { + 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: ( + _block: any, + _exporter: any, + _nesting: any, + _index: any, + children: any, + ) => + new Paragraph({ + children: [new TextRun(`BOX(${children?.length ?? 0})`)], + }), + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const transformed = await exporter.transformBlocks(boxDocument as any); + expect(transformed).toHaveLength(1); + const xml = JSON.stringify(transformed[0]); + expect(xml).toContain("BOX(2)"); + }); + + it("throws a clear error for an unmapped container block", async () => { + const exporter = new DOCXExporter( + boxSchema, + docxDefaultSchemaMappings as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow( + /container block type "box"/, + ); + }); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new DOCXExporter( + alertSchema, + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + alert: ( + 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..a78baec9cc 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.isContainerBlock(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.isContainerBlock(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..716dd41d8a 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,19 @@ 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: (_block, _exporter, _nestingLevel, _numberedListIndex, children) => ( + <>{children} + ), + columnList: ( + _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 91ef40cf36..6fe9ab7b06 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, ); @@ -655,7 +661,7 @@ describe("react email exporter", () => { it("should handle document with custom body styles", async () => { const exporter = new ReactEmailExporter( - BlockNoteSchema.create(), + fullSchema, reactEmailDefaultSchemaMappings, ); @@ -668,3 +674,93 @@ describe("react email exporter", () => { ); }); }); + +describe("titled blocks", () => { + // A titled block: inline content (the title) plus children (the body). The + // mapping renders the title and places the children inside its own box; + // because the block counts as a container, transformBlocks must not render + // them after it as an indented sibling list. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + children: { allow: "blocks" }, + }, + { + 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("throws a clear error for an unmapped container block", async () => { + const exporter = new ReactEmailExporter( + alertSchema, + reactEmailDefaultSchemaMappings as any, + ); + + await expect( + exporter.transformBlocks(alertDocument as any), + ).rejects.toThrow(/container block type "alert"/); + }); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new ReactEmailExporter(alertSchema, { + ...reactEmailDefaultSchemaMappings, + blockMapping: { + ...reactEmailDefaultSchemaMappings.blockMapping, + alert: ( + 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 c0d19108ae..3a2c942730 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx @@ -87,11 +87,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( @@ -152,11 +148,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, ); @@ -246,26 +242,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.isContainerBlock(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( @@ -274,7 +261,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..8be04b6d1f 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,28 +1,68 @@ +import { createBlockSpec } from "@blocknote/core"; + +import { ColumnResizeExtension } from "../../extensions/ColumnResize/ColumnResizeExtension.js"; import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js"; -import { Column } from "../../pm-nodes/Column.js"; -import { ColumnList } from "../../pm-nodes/ColumnList.js"; -import { createBlockSpecFromTiptapNode } from "@blocknote/core"; +const COLUMN_WIDTH_DEFAULT = 1; -export const ColumnBlock = createBlockSpecFromTiptapNode( +export const ColumnBlock = createBlockSpec( { - node: Column, - type: "column", + type: "column" as const, + propSchema: { + width: { + default: COLUMN_WIDTH_DEFAULT, + }, + }, content: "none", + children: { allow: "blocks" }, + 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", + 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/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/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/odtExporter.test.ts b/packages/xl-odt-exporter/src/odt/odtExporter.test.ts index 4883fd0387..a6681e6917 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,113 @@ describe("exporter", () => { ); }); }); + +describe("titled blocks", () => { + // A titled block: inline content (the title) plus children (the body). The + // mapping renders the title and places the children inside its own + // section; because the block counts as a container, transformBlocks must + // not append them after it as tab-indented siblings. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + children: { allow: "blocks" }, + }, + { + 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("throws a clear error for an unmapped container block", async () => { + const exporter = new ODTExporter( + alertSchema, + odtDefaultSchemaMappings as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + await expect( + exporter.transformBlocks(alertDocument as any), + ).rejects.toThrow(/container block type "alert"/); + }); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new ODTExporter( + alertSchema, + { + ...odtDefaultSchemaMappings, + blockMapping: { + ...odtDefaultSchemaMappings.blockMapping, + alert: ( + 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..f254a4fc63 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.isContainerBlock(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/pdfExporter.test.tsx b/packages/xl-pdf-exporter/src/react-pdf/pdfExporter.test.tsx index 8be64289e2..22364d427d 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,98 @@ describe("exporter", () => { ); }); }); + +describe("titled blocks", () => { + // A titled block: inline content (the title) plus children (the body). The + // mapping renders the title and places the children inside its own view; + // because the block counts as a container, transformBlocks must not wrap + // them after it in padded sibling views. + const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + children: { allow: "blocks" }, + }, + { + 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("throws a clear error for an unmapped container block", async () => { + const exporter = new PDFExporter( + alertSchema, + pdfDefaultSchemaMappings as any, + ); + + await expect( + exporter.transformBlocks(alertDocument as any), + ).rejects.toThrow(/container block type "alert"/); + }); + + it("renders a titled block's title and places its children inside", async () => { + const exporter = new PDFExporter(alertSchema, { + ...pdfDefaultSchemaMappings, + blockMapping: { + ...pdfDefaultSchemaMappings.blockMapping, + alert: ( + 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..5ff077fa6d 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.isContainerBlock(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..12dc1349da 100644 --- a/packages/xl-typst-exporter/src/defaultSchema/blocks.ts +++ b/packages/xl-typst-exporter/src/defaultSchema/blocks.ts @@ -159,11 +159,36 @@ 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: (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: ( + _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..5ed9e05e0b 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", + children: { allow: "blocks" }, + }, + { + 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 +// schema build marks it as a titled block rather than a container node, but +// for export the contract is the same - the mapping renders the title and +// places the children, so they must arrive as the mapping's `children` arg. +const Alert = createBlockSpec( + { + type: "alert" as const, + propSchema: {}, + content: "inline", + children: { allow: "blocks" }, + }, + { + 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: ( + 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,94 @@ 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: ( + _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("throws a clear error for an unmapped container block", async () => { + // The missing `box` mapping is the point of the test, and it's exactly + // what `BlockMapping` refuses to type - hence the cast (as in the DOCX + // exporter's equivalent test). + const exporter = new TypstExporter( + boxSchema, + typstDefaultSchemaMappings as any, + ); + + await expect(exporter.toTypst(boxDocument)).rejects.toThrow( + /container block type "box"/, + ); + }); + + 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..f45a27105c 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.isContainerBlock(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 2d32e8c524..2e793bfe93 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1487,6 +1487,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, like a Notion-style callout wrapping a paragraph followed by a code block.\n\nThe block declares the `children` config on `BlockConfig`. `children: { allow: "blocks" }` makes it a container: its child blocks mount into the frame\'s `slot` (attached with `ref={contentRef}`), and live on `block.children` at runtime. A pure container like this draws its box in `renderFrame` alone, which re-renders live when props change — click the icon to cycle the panel\'s flavor and watch the box follow without rebuilding the body.\n\nWe also wire up a Slash Menu item to insert the panel, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the panel\'s body and add a code block, heading, or list.\n- Click the panel\'s icon to cycle its flavor. The box re-renders in place; the children are untouched.\n- Watch the JSON panel on the right update as you edit; the panel\'s children appear in `block.children`.\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", @@ -1566,6 +1593,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 a body of child blocks (a titled block), like a Notion-style callout.\n\nThe block combines `content: "inline"` with the `children` config on `BlockConfig`. The title is ordinary inline content — formatting, links, and multiplayer cursors all work — while `children: { allow: "blocks" }` hosts the body blocks, which live on `block.children` at runtime. `render` draws the title row and `renderFrame` draws the box around the title and body together.\n\nWe also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the titled block and its nested children.\n\n**Try it out:**\n\n- Press Enter at the end of the callout\'s title to jump into its body.\n- Press Backspace at the start of the first body block to merge it back into the title.\n- Press "/" inside the body and add a code block, heading, or list.\n- Watch the JSON panel on the right update as you edit; the title is `content` and the body is `block.children`.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', + }, { projectSlug: "draggable-inline-content", fullSlug: "custom-schema/draggable-inline-content", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4aeb2f0e..03bd5b9292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3511,6 +3511,52 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/09-container-block: + dependencies: + '@blocknote/ariakit': + specifier: latest + version: link:../../../packages/ariakit + '@blocknote/core': + specifier: latest + version: link:../../../packages/core + '@blocknote/mantine': + specifier: latest + version: link:../../../packages/mantine + '@blocknote/react': + specifier: latest + version: link:../../../packages/react + '@blocknote/shadcn': + specifier: latest + version: link:../../../packages/shadcn + '@mantine/core': + specifier: ^9.0.2 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': + specifier: ^9.0.2 + version: 9.1.1(react@19.2.5) + react: + specifier: ^19.2.3 + version: 19.2.5 + react-dom: + specifier: ^19.2.3 + version: 19.2.5(react@19.2.5) + react-icons: + specifier: ^5.5.0 + version: 5.6.0(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.2.3 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/09-math-block: dependencies: '@blocknote/ariakit': @@ -3661,6 +3707,52 @@ importers: specifier: ^8.0.0 version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/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.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)) + vite: + specifier: ^8.0.0 + version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) + examples/06-custom-schema/draggable-inline-content: dependencies: '@blocknote/ariakit': diff --git a/tests/src/end-to-end/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/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/container/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/basic.html new file mode 100644 index 0000000000..e26ee72a29 --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/container/basic.html @@ -0,0 +1,14 @@ +
+
+
+
+
+
+

Callout child

+
+
+
+
+ +
+
\ No newline at end of file diff --git a/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/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/titledBlock/basic.html b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/titledBlock/basic.html new file mode 100644 index 0000000000..4027ff9eee --- /dev/null +++ b/tests/src/unit/core/formatConversion/export/__snapshots__/blocknoteHTML/titledBlock/basic.html @@ -0,0 +1,36 @@ +
+
+
+
+
+
+
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 536a7ac784..782b0ebab3 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..99261ea107 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -1,4 +1,21 @@ { + "alert": { + "config": { + "children": { + "allow": "blocks", + }, + "content": "inline", + "propSchema": {}, + "type": "alert", + }, + "extensions": undefined, + "implementation": { + "node": null, + "render": [Function], + "renderFrame": [Function], + "toExternalHTML": [Function], + }, + }, "audio": { "config": { "content": "none", @@ -73,6 +90,31 @@ "toExternalHTML": [Function], }, }, + "callout": { + "config": { + "children": { + "allow": "blocks", + }, + "content": "none", + "propSchema": { + "flavor": { + "default": "tip", + "values": [ + "tip", + "info", + "warning", + ], + }, + }, + "type": "callout", + }, + "extensions": undefined, + "implementation": { + "node": null, + "render": [Function], + "toExternalHTML": [Function], + }, + }, "checkListItem": { "config": { "content": "inline", diff --git a/tests/src/unit/core/testSchema.ts b/tests/src/unit/core/testSchema.ts index eca37363fa..c1ddf7b309 100644 --- a/tests/src/unit/core/testSchema.ts +++ b/tests/src/unit/core/testSchema.ts @@ -27,7 +27,7 @@ const SimpleImage = createBlockSpec( ), { render(block, editor) { - return createImageBlockSpec().implementation.render.call( + return createImageBlockSpec().implementation.render!.call( this, block as any, editor as any, @@ -99,6 +99,96 @@ const SimpleCustomParagraph = createBlockSpec( }, ); +// A 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", + 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", + children: { + allow: "blocks", + }, + }, + { + 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 +312,8 @@ export const testSchema = BlockNoteSchema.create().extend({ customParagraph: CustomParagraph(), simpleCustomParagraph: SimpleCustomParagraph(), simpleImage: SimpleImage(), + callout: Callout(), + alert: Alert(), }, inlineContentSpecs: { mention: Mention, diff --git a/tests/src/unit/react/reactFrame.test.tsx b/tests/src/unit/react/reactFrame.test.tsx new file mode 100644 index 0000000000..78c93edee4 --- /dev/null +++ b/tests/src/unit/react/reactFrame.test.tsx @@ -0,0 +1,382 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { + BlockNoteViewRaw, + createReactBlockSpec, + type ReactCustomBlockFrameProps, +} from "@blocknote/react"; +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +// Pure containers continue to draw their outer box through render. +const createFrameBox = createReactBlockSpec( + { + type: "frameBox", + propSchema: { flavor: { default: "tip" } }, + content: "none", + children: { allow: "blocks" }, + }, + { + render: function Container(props) { + const [alternate, setAlternate] = useState(false); + const Tag = alternate ? "section" : "div"; + return ( + + +
+ + ); + }, + }, +); + +const FrameContext = createContext("outside"); +let activeFrames = 0; +const alertConfig = { + type: "frameAlert", + propSchema: { flavor: { default: "tip" }, framed: { default: true } }, + content: "inline", + children: { allow: "blocks" }, +} 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 turn ordinary nesting into an owned body. +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..c20c6aaeb9 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", + children: { allow: "blocks" }, + }, + { 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); } };