diff --git a/docs/components/pages/landing/hero/DemoEditor.tsx b/docs/components/pages/landing/hero/DemoEditor.tsx
index acb58ee337..4b4244d435 100644
--- a/docs/components/pages/landing/hero/DemoEditor.tsx
+++ b/docs/components/pages/landing/hero/DemoEditor.tsx
@@ -1,7 +1,14 @@
-import { uploadToTmpFilesDotOrg_DEV_ONLY } from "@blocknote/core";
+import {
+ BlockNoteSchema,
+ uploadToTmpFilesDotOrg_DEV_ONLY,
+} from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
+import {
+ multiColumnDropCursor,
+ withMultiColumn,
+} from "@blocknote/xl-multi-column";
import "@blocknote/mantine/style.css";
import { useCallback, useMemo, useState } from "react";
import YPartyKitProvider from "y-partykit/provider";
@@ -67,6 +74,8 @@ export default function DemoEditor(props: { theme?: "light" | "dark" }) {
const editor = useCreateBlockNote(
{
+ schema: withMultiColumn(BlockNoteSchema.create()),
+ dropCursor: multiColumnDropCursor,
collaboration: {
provider,
fragment: doc.getXmlFragment("blocknote"),
diff --git a/docs/package.json b/docs/package.json
index 264a356710..edee91853f 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -14,6 +14,7 @@
"@blocknote/mantine": "^0.18.1",
"@blocknote/react": "^0.18.1",
"@blocknote/shadcn": "^0.18.1",
+ "@blocknote/xl-multi-column": "^0.18.1",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.4",
"@mantine/core": "^7.10.1",
diff --git a/docs/pages/docs/editor-basics/document-structure.mdx b/docs/pages/docs/editor-basics/document-structure.mdx
index dc75d798fa..d355db9087 100644
--- a/docs/pages/docs/editor-basics/document-structure.mdx
+++ b/docs/pages/docs/editor-basics/document-structure.mdx
@@ -43,6 +43,34 @@ type Block = {
`children:` Any blocks nested inside the block. The nested blocks are also represented using `Block` objects.
+### Column Blocks
+
+The `@blocknote/xl-multi-column` package allows you to organize blocks side-by-side in columns. It introduces 2 additional block types, the column and column list:
+
+```typescript
+type ColumnBlock = {
+ id: string;
+ type: "column";
+ props: { width: number };
+ content: undefined;
+ children: Block[];
+};
+
+type ColumnListBlock = {
+ id: string;
+ type: "columnList";
+ props: {};
+ content: undefined;
+ children: ColumnBlock[];
+};
+```
+
+While both of these act as regular blocks, there are a few additional restrictions to have in mind when working with them:
+
+- Children of columns must be regular blocks
+- Children of column lists must be columns
+- There must be at least 2 columns in a column list
+
## Inline Content
The `content` field of a block contains the rich-text content of a block. This is defined as an array of `InlineContent` objects. Inline content can either be styled text or a link (or a custom inline content type if you customize the editor schema).
diff --git a/examples/01-basic/03-all-blocks/.bnexample.json b/examples/01-basic/03-all-blocks/.bnexample.json
deleted file mode 100644
index 6b15063ef2..0000000000
--- a/examples/01-basic/03-all-blocks/.bnexample.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "playground": true,
- "docs": true,
- "author": "yousefed",
- "tags": ["Basic", "Blocks", "Inline Content"]
-}
diff --git a/examples/01-basic/03-multi-column/.bnexample.json b/examples/01-basic/03-multi-column/.bnexample.json
new file mode 100644
index 0000000000..d143c28aab
--- /dev/null
+++ b/examples/01-basic/03-multi-column/.bnexample.json
@@ -0,0 +1,9 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "yousefed",
+ "tags": ["Basic", "Blocks"],
+ "dependencies": {
+ "@blocknote/xl-multi-column": "latest"
+ }
+}
diff --git a/examples/01-basic/03-multi-column/App.tsx b/examples/01-basic/03-multi-column/App.tsx
new file mode 100644
index 0000000000..600ed211b6
--- /dev/null
+++ b/examples/01-basic/03-multi-column/App.tsx
@@ -0,0 +1,118 @@
+import {
+ BlockNoteSchema,
+ combineByGroup,
+ filterSuggestionItems,
+ locales,
+} from "@blocknote/core";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import {
+ SuggestionMenuController,
+ getDefaultReactSlashMenuItems,
+ useCreateBlockNote,
+} from "@blocknote/react";
+import {
+ getMultiColumnSlashMenuItems,
+ multiColumnDropCursor,
+ locales as multiColumnLocales,
+ withMultiColumn,
+} from "@blocknote/xl-multi-column";
+import { useMemo } from "react";
+export default function App() {
+ // Creates a new editor instance.
+ const editor = useCreateBlockNote({
+ // Adds column and column list blocks to the schema.
+ schema: withMultiColumn(BlockNoteSchema.create()),
+ // The default drop cursor only shows up above and below blocks - we replace
+ // it with the multi-column one that also shows up on the sides of blocks.
+ dropCursor: multiColumnDropCursor,
+ // Merges the default dictionary with the multi-column dictionary.
+ dictionary: {
+ ...locales.en,
+ multi_column: multiColumnLocales.en,
+ },
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Welcome to this demo!",
+ },
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ props: {
+ width: 0.8,
+ },
+ children: [
+ {
+ type: "paragraph",
+ content: "This paragraph is in a column!",
+ },
+ ],
+ },
+ {
+ type: "column",
+ props: {
+ width: 1.4,
+ },
+ children: [
+ {
+ type: "heading",
+ content: "So is this heading!",
+ },
+ ],
+ },
+ {
+ type: "column",
+ props: {
+ width: 0.8,
+ },
+ children: [
+ {
+ type: "paragraph",
+ content: "You can have multiple blocks in a column too",
+ },
+ {
+ type: "bulletListItem",
+ content: "Block 1",
+ },
+ {
+ type: "bulletListItem",
+ content: "Block 2",
+ },
+ {
+ type: "bulletListItem",
+ content: "Block 3",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ },
+ ],
+ });
+
+ // Merges the default slash menu items with the multi-column ones.
+ const slashMenuItems = useMemo(() => {
+ return combineByGroup(
+ getDefaultReactSlashMenuItems(editor),
+ getMultiColumnSlashMenuItems(editor)
+ );
+ }, [editor]);
+
+ // Renders the editor instance using a React component.
+ return (
+
+ {/* Replaces the default slash menu with one that has both the default
+ items and the multi-column ones. */}
+ filterSuggestionItems(slashMenuItems, query)}
+ />
+
+ );
+}
diff --git a/examples/01-basic/03-multi-column/README.md b/examples/01-basic/03-multi-column/README.md
new file mode 100644
index 0000000000..488b64fa7a
--- /dev/null
+++ b/examples/01-basic/03-multi-column/README.md
@@ -0,0 +1,8 @@
+# Multi-Column Blocks
+
+This example showcases multi-column blocks, allowing you to stack blocks next to each other. These come as part of the `@blocknote/xl-multi-column` package.
+
+**Relevant Docs:**
+
+- [Editor Setup](/docs/editor-basics/setup)
+- [Document Structure](/docs/editor-basics/document-structure)
diff --git a/examples/01-basic/03-multi-column/index.html b/examples/01-basic/03-multi-column/index.html
new file mode 100644
index 0000000000..09721e7728
--- /dev/null
+++ b/examples/01-basic/03-multi-column/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Multi-Column Blocks
+
+
+
+
+
+
diff --git a/examples/01-basic/03-all-blocks/main.tsx b/examples/01-basic/03-multi-column/main.tsx
similarity index 100%
rename from examples/01-basic/03-all-blocks/main.tsx
rename to examples/01-basic/03-multi-column/main.tsx
diff --git a/examples/01-basic/03-multi-column/package.json b/examples/01-basic/03-multi-column/package.json
new file mode 100644
index 0000000000..33846ab143
--- /dev/null
+++ b/examples/01-basic/03-multi-column/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@blocknote/example-multi-column",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint . --max-warnings 0"
+ },
+ "dependencies": {
+ "@blocknote/core": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/ariakit": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/shadcn": "latest",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "@blocknote/xl-multi-column": "latest"
+ },
+ "devDependencies": {
+ "@types/react": "^18.0.25",
+ "@types/react-dom": "^18.0.9",
+ "@vitejs/plugin-react": "^4.3.1",
+ "eslint": "^8.10.0",
+ "vite": "^5.3.4"
+ },
+ "eslintConfig": {
+ "extends": [
+ "../../../.eslintrc.js"
+ ]
+ },
+ "eslintIgnore": [
+ "dist"
+ ]
+}
\ No newline at end of file
diff --git a/examples/01-basic/03-all-blocks/tsconfig.json b/examples/01-basic/03-multi-column/tsconfig.json
similarity index 100%
rename from examples/01-basic/03-all-blocks/tsconfig.json
rename to examples/01-basic/03-multi-column/tsconfig.json
diff --git a/examples/01-basic/03-all-blocks/vite.config.ts b/examples/01-basic/03-multi-column/vite.config.ts
similarity index 100%
rename from examples/01-basic/03-all-blocks/vite.config.ts
rename to examples/01-basic/03-multi-column/vite.config.ts
diff --git a/examples/01-basic/04-all-blocks/.bnexample.json b/examples/01-basic/04-all-blocks/.bnexample.json
new file mode 100644
index 0000000000..d38bcda2ee
--- /dev/null
+++ b/examples/01-basic/04-all-blocks/.bnexample.json
@@ -0,0 +1,9 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "yousefed",
+ "tags": ["Basic", "Blocks", "Inline Content"],
+ "dependencies": {
+ "@blocknote/xl-multi-column": "latest"
+ }
+}
diff --git a/examples/01-basic/03-all-blocks/App.tsx b/examples/01-basic/04-all-blocks/App.tsx
similarity index 67%
rename from examples/01-basic/03-all-blocks/App.tsx
rename to examples/01-basic/04-all-blocks/App.tsx
index 7176c8b82d..73681ebcf0 100644
--- a/examples/01-basic/03-all-blocks/App.tsx
+++ b/examples/01-basic/04-all-blocks/App.tsx
@@ -1,11 +1,33 @@
+import {
+ BlockNoteSchema,
+ combineByGroup,
+ filterSuggestionItems,
+ locales,
+} from "@blocknote/core";
import "@blocknote/core/fonts/inter.css";
-import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";
-
+import {
+ SuggestionMenuController,
+ getDefaultReactSlashMenuItems,
+ useCreateBlockNote,
+} from "@blocknote/react";
+import {
+ getMultiColumnSlashMenuItems,
+ multiColumnDropCursor,
+ locales as multiColumnLocales,
+ withMultiColumn,
+} from "@blocknote/xl-multi-column";
+import { useMemo } from "react";
export default function App() {
// Creates a new editor instance.
const editor = useCreateBlockNote({
+ schema: withMultiColumn(BlockNoteSchema.create()),
+ dropCursor: multiColumnDropCursor,
+ dictionary: {
+ ...locales.en,
+ multi_column: multiColumnLocales.en,
+ },
initialContent: [
{
type: "paragraph",
@@ -28,6 +50,35 @@ export default function App() {
type: "paragraph",
content: "Paragraph",
},
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ props: {
+ width: 0.8,
+ },
+ children: [
+ {
+ type: "paragraph",
+ content: "Hello to the left!",
+ },
+ ],
+ },
+ {
+ type: "column",
+ props: {
+ width: 1.2,
+ },
+ children: [
+ {
+ type: "paragraph",
+ content: "Hello to the right!",
+ },
+ ],
+ },
+ ],
+ },
{
type: "heading",
content: "Heading",
@@ -137,6 +188,20 @@ export default function App() {
],
});
+ const slashMenuItems = useMemo(() => {
+ return combineByGroup(
+ getDefaultReactSlashMenuItems(editor),
+ getMultiColumnSlashMenuItems(editor)
+ );
+ }, [editor]);
+
// Renders the editor instance using a React component.
- return ;
+ return (
+
+ filterSuggestionItems(slashMenuItems, query)}
+ />
+
+ );
}
diff --git a/examples/01-basic/03-all-blocks/README.md b/examples/01-basic/04-all-blocks/README.md
similarity index 85%
rename from examples/01-basic/03-all-blocks/README.md
rename to examples/01-basic/04-all-blocks/README.md
index d8b2480991..80d7132338 100644
--- a/examples/01-basic/03-all-blocks/README.md
+++ b/examples/01-basic/04-all-blocks/README.md
@@ -4,5 +4,6 @@ This example showcases each block and inline content type in BlockNote's default
**Relevant Docs:**
+- [Editor Setup](/docs/editor-basics/setup)
- [Document Structure](/docs/editor-basics/document-structure)
- [Default Schema](/docs/editor-basics/default-schema)
diff --git a/examples/01-basic/03-all-blocks/index.html b/examples/01-basic/04-all-blocks/index.html
similarity index 100%
rename from examples/01-basic/03-all-blocks/index.html
rename to examples/01-basic/04-all-blocks/index.html
diff --git a/examples/01-basic/04-removing-default-blocks/main.tsx b/examples/01-basic/04-all-blocks/main.tsx
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/main.tsx
rename to examples/01-basic/04-all-blocks/main.tsx
diff --git a/examples/01-basic/03-all-blocks/package.json b/examples/01-basic/04-all-blocks/package.json
similarity index 92%
rename from examples/01-basic/03-all-blocks/package.json
rename to examples/01-basic/04-all-blocks/package.json
index d66700795e..352d433f1f 100644
--- a/examples/01-basic/03-all-blocks/package.json
+++ b/examples/01-basic/04-all-blocks/package.json
@@ -17,7 +17,8 @@
"@blocknote/mantine": "latest",
"@blocknote/shadcn": "latest",
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "@blocknote/xl-multi-column": "latest"
},
"devDependencies": {
"@types/react": "^18.0.25",
diff --git a/examples/01-basic/04-removing-default-blocks/tsconfig.json b/examples/01-basic/04-all-blocks/tsconfig.json
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/tsconfig.json
rename to examples/01-basic/04-all-blocks/tsconfig.json
diff --git a/examples/01-basic/04-removing-default-blocks/vite.config.ts b/examples/01-basic/04-all-blocks/vite.config.ts
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/vite.config.ts
rename to examples/01-basic/04-all-blocks/vite.config.ts
diff --git a/examples/01-basic/04-removing-default-blocks/.bnexample.json b/examples/01-basic/05-removing-default-blocks/.bnexample.json
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/.bnexample.json
rename to examples/01-basic/05-removing-default-blocks/.bnexample.json
diff --git a/examples/01-basic/04-removing-default-blocks/App.tsx b/examples/01-basic/05-removing-default-blocks/App.tsx
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/App.tsx
rename to examples/01-basic/05-removing-default-blocks/App.tsx
diff --git a/examples/01-basic/04-removing-default-blocks/README.md b/examples/01-basic/05-removing-default-blocks/README.md
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/README.md
rename to examples/01-basic/05-removing-default-blocks/README.md
diff --git a/examples/01-basic/04-removing-default-blocks/index.html b/examples/01-basic/05-removing-default-blocks/index.html
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/index.html
rename to examples/01-basic/05-removing-default-blocks/index.html
diff --git a/examples/01-basic/05-block-manipulation/main.tsx b/examples/01-basic/05-removing-default-blocks/main.tsx
similarity index 100%
rename from examples/01-basic/05-block-manipulation/main.tsx
rename to examples/01-basic/05-removing-default-blocks/main.tsx
diff --git a/examples/01-basic/04-removing-default-blocks/package.json b/examples/01-basic/05-removing-default-blocks/package.json
similarity index 100%
rename from examples/01-basic/04-removing-default-blocks/package.json
rename to examples/01-basic/05-removing-default-blocks/package.json
diff --git a/examples/01-basic/05-block-manipulation/tsconfig.json b/examples/01-basic/05-removing-default-blocks/tsconfig.json
similarity index 100%
rename from examples/01-basic/05-block-manipulation/tsconfig.json
rename to examples/01-basic/05-removing-default-blocks/tsconfig.json
diff --git a/examples/01-basic/05-block-manipulation/vite.config.ts b/examples/01-basic/05-removing-default-blocks/vite.config.ts
similarity index 100%
rename from examples/01-basic/05-block-manipulation/vite.config.ts
rename to examples/01-basic/05-removing-default-blocks/vite.config.ts
diff --git a/examples/01-basic/05-block-manipulation/.bnexample.json b/examples/01-basic/06-block-manipulation/.bnexample.json
similarity index 100%
rename from examples/01-basic/05-block-manipulation/.bnexample.json
rename to examples/01-basic/06-block-manipulation/.bnexample.json
diff --git a/examples/01-basic/05-block-manipulation/App.tsx b/examples/01-basic/06-block-manipulation/App.tsx
similarity index 100%
rename from examples/01-basic/05-block-manipulation/App.tsx
rename to examples/01-basic/06-block-manipulation/App.tsx
diff --git a/examples/01-basic/05-block-manipulation/README.md b/examples/01-basic/06-block-manipulation/README.md
similarity index 100%
rename from examples/01-basic/05-block-manipulation/README.md
rename to examples/01-basic/06-block-manipulation/README.md
diff --git a/examples/01-basic/05-block-manipulation/index.html b/examples/01-basic/06-block-manipulation/index.html
similarity index 100%
rename from examples/01-basic/05-block-manipulation/index.html
rename to examples/01-basic/06-block-manipulation/index.html
diff --git a/examples/01-basic/06-selection-blocks/main.tsx b/examples/01-basic/06-block-manipulation/main.tsx
similarity index 100%
rename from examples/01-basic/06-selection-blocks/main.tsx
rename to examples/01-basic/06-block-manipulation/main.tsx
diff --git a/examples/01-basic/05-block-manipulation/package.json b/examples/01-basic/06-block-manipulation/package.json
similarity index 100%
rename from examples/01-basic/05-block-manipulation/package.json
rename to examples/01-basic/06-block-manipulation/package.json
diff --git a/examples/01-basic/05-block-manipulation/styles.css b/examples/01-basic/06-block-manipulation/styles.css
similarity index 100%
rename from examples/01-basic/05-block-manipulation/styles.css
rename to examples/01-basic/06-block-manipulation/styles.css
diff --git a/examples/01-basic/06-selection-blocks/tsconfig.json b/examples/01-basic/06-block-manipulation/tsconfig.json
similarity index 100%
rename from examples/01-basic/06-selection-blocks/tsconfig.json
rename to examples/01-basic/06-block-manipulation/tsconfig.json
diff --git a/examples/01-basic/06-selection-blocks/vite.config.ts b/examples/01-basic/06-block-manipulation/vite.config.ts
similarity index 100%
rename from examples/01-basic/06-selection-blocks/vite.config.ts
rename to examples/01-basic/06-block-manipulation/vite.config.ts
diff --git a/examples/01-basic/06-selection-blocks/.bnexample.json b/examples/01-basic/07-selection-blocks/.bnexample.json
similarity index 100%
rename from examples/01-basic/06-selection-blocks/.bnexample.json
rename to examples/01-basic/07-selection-blocks/.bnexample.json
diff --git a/examples/01-basic/06-selection-blocks/App.tsx b/examples/01-basic/07-selection-blocks/App.tsx
similarity index 100%
rename from examples/01-basic/06-selection-blocks/App.tsx
rename to examples/01-basic/07-selection-blocks/App.tsx
diff --git a/examples/01-basic/06-selection-blocks/README.md b/examples/01-basic/07-selection-blocks/README.md
similarity index 100%
rename from examples/01-basic/06-selection-blocks/README.md
rename to examples/01-basic/07-selection-blocks/README.md
diff --git a/examples/01-basic/06-selection-blocks/index.html b/examples/01-basic/07-selection-blocks/index.html
similarity index 100%
rename from examples/01-basic/06-selection-blocks/index.html
rename to examples/01-basic/07-selection-blocks/index.html
diff --git a/examples/01-basic/07-ariakit/main.tsx b/examples/01-basic/07-selection-blocks/main.tsx
similarity index 100%
rename from examples/01-basic/07-ariakit/main.tsx
rename to examples/01-basic/07-selection-blocks/main.tsx
diff --git a/examples/01-basic/06-selection-blocks/package.json b/examples/01-basic/07-selection-blocks/package.json
similarity index 100%
rename from examples/01-basic/06-selection-blocks/package.json
rename to examples/01-basic/07-selection-blocks/package.json
diff --git a/examples/01-basic/06-selection-blocks/styles.css b/examples/01-basic/07-selection-blocks/styles.css
similarity index 100%
rename from examples/01-basic/06-selection-blocks/styles.css
rename to examples/01-basic/07-selection-blocks/styles.css
diff --git a/examples/01-basic/07-ariakit/tsconfig.json b/examples/01-basic/07-selection-blocks/tsconfig.json
similarity index 100%
rename from examples/01-basic/07-ariakit/tsconfig.json
rename to examples/01-basic/07-selection-blocks/tsconfig.json
diff --git a/examples/01-basic/07-ariakit/vite.config.ts b/examples/01-basic/07-selection-blocks/vite.config.ts
similarity index 100%
rename from examples/01-basic/07-ariakit/vite.config.ts
rename to examples/01-basic/07-selection-blocks/vite.config.ts
diff --git a/examples/01-basic/07-ariakit/.bnexample.json b/examples/01-basic/08-ariakit/.bnexample.json
similarity index 100%
rename from examples/01-basic/07-ariakit/.bnexample.json
rename to examples/01-basic/08-ariakit/.bnexample.json
diff --git a/examples/01-basic/07-ariakit/App.tsx b/examples/01-basic/08-ariakit/App.tsx
similarity index 100%
rename from examples/01-basic/07-ariakit/App.tsx
rename to examples/01-basic/08-ariakit/App.tsx
diff --git a/examples/01-basic/07-ariakit/README.md b/examples/01-basic/08-ariakit/README.md
similarity index 100%
rename from examples/01-basic/07-ariakit/README.md
rename to examples/01-basic/08-ariakit/README.md
diff --git a/examples/01-basic/07-ariakit/index.html b/examples/01-basic/08-ariakit/index.html
similarity index 100%
rename from examples/01-basic/07-ariakit/index.html
rename to examples/01-basic/08-ariakit/index.html
diff --git a/examples/01-basic/08-shadcn/main.tsx b/examples/01-basic/08-ariakit/main.tsx
similarity index 100%
rename from examples/01-basic/08-shadcn/main.tsx
rename to examples/01-basic/08-ariakit/main.tsx
diff --git a/examples/01-basic/07-ariakit/package.json b/examples/01-basic/08-ariakit/package.json
similarity index 100%
rename from examples/01-basic/07-ariakit/package.json
rename to examples/01-basic/08-ariakit/package.json
diff --git a/examples/01-basic/08-shadcn/tsconfig.json b/examples/01-basic/08-ariakit/tsconfig.json
similarity index 100%
rename from examples/01-basic/08-shadcn/tsconfig.json
rename to examples/01-basic/08-ariakit/tsconfig.json
diff --git a/examples/01-basic/08-shadcn/vite.config.ts b/examples/01-basic/08-ariakit/vite.config.ts
similarity index 100%
rename from examples/01-basic/08-shadcn/vite.config.ts
rename to examples/01-basic/08-ariakit/vite.config.ts
diff --git a/examples/01-basic/08-shadcn/.bnexample.json b/examples/01-basic/09-shadcn/.bnexample.json
similarity index 100%
rename from examples/01-basic/08-shadcn/.bnexample.json
rename to examples/01-basic/09-shadcn/.bnexample.json
diff --git a/examples/01-basic/08-shadcn/App.tsx b/examples/01-basic/09-shadcn/App.tsx
similarity index 100%
rename from examples/01-basic/08-shadcn/App.tsx
rename to examples/01-basic/09-shadcn/App.tsx
diff --git a/examples/01-basic/08-shadcn/README.md b/examples/01-basic/09-shadcn/README.md
similarity index 100%
rename from examples/01-basic/08-shadcn/README.md
rename to examples/01-basic/09-shadcn/README.md
diff --git a/examples/01-basic/08-shadcn/index.html b/examples/01-basic/09-shadcn/index.html
similarity index 100%
rename from examples/01-basic/08-shadcn/index.html
rename to examples/01-basic/09-shadcn/index.html
diff --git a/examples/01-basic/09-localization/main.tsx b/examples/01-basic/09-shadcn/main.tsx
similarity index 100%
rename from examples/01-basic/09-localization/main.tsx
rename to examples/01-basic/09-shadcn/main.tsx
diff --git a/examples/01-basic/08-shadcn/package.json b/examples/01-basic/09-shadcn/package.json
similarity index 100%
rename from examples/01-basic/08-shadcn/package.json
rename to examples/01-basic/09-shadcn/package.json
diff --git a/examples/01-basic/09-localization/tsconfig.json b/examples/01-basic/09-shadcn/tsconfig.json
similarity index 100%
rename from examples/01-basic/09-localization/tsconfig.json
rename to examples/01-basic/09-shadcn/tsconfig.json
diff --git a/examples/01-basic/09-localization/vite.config.ts b/examples/01-basic/09-shadcn/vite.config.ts
similarity index 100%
rename from examples/01-basic/09-localization/vite.config.ts
rename to examples/01-basic/09-shadcn/vite.config.ts
diff --git a/examples/01-basic/09-localization/.bnexample.json b/examples/01-basic/10-localization/.bnexample.json
similarity index 100%
rename from examples/01-basic/09-localization/.bnexample.json
rename to examples/01-basic/10-localization/.bnexample.json
diff --git a/examples/01-basic/09-localization/App.tsx b/examples/01-basic/10-localization/App.tsx
similarity index 100%
rename from examples/01-basic/09-localization/App.tsx
rename to examples/01-basic/10-localization/App.tsx
diff --git a/examples/01-basic/09-localization/README.md b/examples/01-basic/10-localization/README.md
similarity index 100%
rename from examples/01-basic/09-localization/README.md
rename to examples/01-basic/10-localization/README.md
diff --git a/examples/01-basic/09-localization/index.html b/examples/01-basic/10-localization/index.html
similarity index 100%
rename from examples/01-basic/09-localization/index.html
rename to examples/01-basic/10-localization/index.html
diff --git a/examples/01-basic/10-localization/main.tsx b/examples/01-basic/10-localization/main.tsx
new file mode 100644
index 0000000000..f88b490fbd
--- /dev/null
+++ b/examples/01-basic/10-localization/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 "./App";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+
+);
diff --git a/examples/01-basic/09-localization/package.json b/examples/01-basic/10-localization/package.json
similarity index 100%
rename from examples/01-basic/09-localization/package.json
rename to examples/01-basic/10-localization/package.json
diff --git a/examples/01-basic/10-localization/tsconfig.json b/examples/01-basic/10-localization/tsconfig.json
new file mode 100644
index 0000000000..1bd8ab3c57
--- /dev/null
+++ b/examples/01-basic/10-localization/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true
+ },
+ "include": [
+ "."
+ ],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/examples/01-basic/10-localization/vite.config.ts b/examples/01-basic/10-localization/vite.config.ts
new file mode 100644
index 0000000000..f62ab20bc2
--- /dev/null
+++ b/examples/01-basic/10-localization/vite.config.ts
@@ -0,0 +1,32 @@
+// 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";
+// import eslintPlugin from "vite-plugin-eslint";
+// https://vitejs.dev/config/
+export default defineConfig((conf) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ ? {}
+ : ({
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../packages/core/src/"
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/"
+ ),
+ } as any),
+ },
+}));
diff --git a/examples/03-ui-components/13-custom-ui/MUISideMenu.tsx b/examples/03-ui-components/13-custom-ui/MUISideMenu.tsx
index 6069094c4b..60b53e48ba 100644
--- a/examples/03-ui-components/13-custom-ui/MUISideMenu.tsx
+++ b/examples/03-ui-components/13-custom-ui/MUISideMenu.tsx
@@ -92,7 +92,7 @@ function MUIDragHandleButton(props: SideMenuProps) {
component={"button"}
draggable={"true"}
onClick={onClick}
- onDragStart={props.blockDragStart}
+ onDragStart={(e) => props.blockDragStart(e, props.block)}
onDragEnd={props.blockDragEnd}>
=18"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.4.tgz",
+ "integrity": "sha512-MXDnZn0Awl2S86PSNIim5PWXgIAx8CIkzu35mBdSApUip6RFOGXBCf3YFyeEu8n1IHk4bWD46DeYFu9mQlFIRg==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/pretty-format": "2.1.4",
+ "loupe": "^3.1.2",
+ "tinyrainbow": "^1.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
}
},
"node_modules/@webassemblyjs/ast": {
@@ -11564,9 +11621,9 @@
}
},
"node_modules/chai": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz",
- "integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==",
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz",
+ "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==",
"dev": true,
"dependencies": {
"assertion-error": "^2.0.1",
@@ -13018,11 +13075,11 @@
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="
},
"node_modules/debug": {
- "version": "4.3.5",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
- "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"dependencies": {
- "ms": "2.1.2"
+ "ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
@@ -14442,6 +14499,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/expect-type": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz",
+ "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==",
+ "dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/exponential-backoff": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz",
@@ -14973,15 +15039,6 @@
"node": "6.* || 8.* || >= 10.*"
}
},
- "node_modules/get-func-name": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
- "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
- "dev": true,
- "engines": {
- "node": "*"
- }
- },
"node_modules/get-intrinsic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
@@ -18357,13 +18414,10 @@
}
},
"node_modules/loupe": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz",
- "integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==",
- "dev": true,
- "dependencies": {
- "get-func-name": "^2.0.1"
- }
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz",
+ "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==",
+ "dev": true
},
"node_modules/lru-cache": {
"version": "5.1.1",
@@ -18383,12 +18437,12 @@
}
},
"node_modules/magic-string": {
- "version": "0.30.10",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz",
- "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==",
+ "version": "0.30.12",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
+ "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==",
"dev": true,
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.4.15"
+ "@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/make-dir": {
@@ -20457,9 +20511,9 @@
}
},
"node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/multimatch": {
"version": "5.0.0",
@@ -23392,9 +23446,9 @@
}
},
"node_modules/prosemirror-model": {
- "version": "1.22.3",
- "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.22.3.tgz",
- "integrity": "sha512-V4XCysitErI+i0rKFILGt/xClnFJaohe/wrrlT2NSZ+zk8ggQfDH4x2wNK7Gm0Hp4CIoWizvXFP7L9KMaCuI0Q==",
+ "version": "1.23.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.23.0.tgz",
+ "integrity": "sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==",
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -26176,15 +26230,60 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="
},
"node_modules/tinybench": {
- "version": "2.8.0",
- "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz",
- "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==",
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true
+ },
+ "node_modules/tinyexec": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz",
+ "integrity": "sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==",
"dev": true
},
+ "node_modules/tinyglobby": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz",
+ "integrity": "sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==",
+ "dev": true,
+ "dependencies": {
+ "fdir": "^6.4.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz",
+ "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==",
+ "dev": true,
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
+ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/tinypool": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.0.tgz",
- "integrity": "sha512-KIKExllK7jp3uvrNtvRBYBWBOAXSX8ZvoaD8T+7KB/QHIuoJW3Pmr60zucywjAlMb5TeXUkcs/MWeWLu0qvuAQ==",
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.1.tgz",
+ "integrity": "sha512-URZYihUbRPcGv95En+sz6MfghfIc2OJ1sv/RmhWZLouPY0/8Vo80viwPvg3dlaS9fuq7fQMEfgRRK7BBZThBEA==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
@@ -26200,9 +26299,9 @@
}
},
"node_modules/tinyspy": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz",
- "integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==",
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+ "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
@@ -27483,15 +27582,14 @@
}
},
"node_modules/vite-node": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.3.tgz",
- "integrity": "sha512-14jzwMx7XTcMB+9BhGQyoEAmSl0eOr3nrnn+Z12WNERtOvLN+d2scbRUvyni05rT3997Bg+rZb47NyP4IQPKXg==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.4.tgz",
+ "integrity": "sha512-kqa9v+oi4HwkG6g8ufRnb5AeplcRw8jUF6/7/Qz1qRQOXHImG8YnLbB+LLszENwFnoBl9xIf9nVdCFzNd7GQEg==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
- "debug": "^4.3.5",
+ "debug": "^4.3.7",
"pathe": "^1.1.2",
- "tinyrainbow": "^1.2.0",
"vite": "^5.0.0"
},
"bin": {
@@ -27547,30 +27645,31 @@
}
},
"node_modules/vitest": {
- "version": "2.0.3",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.3.tgz",
- "integrity": "sha512-o3HRvU93q6qZK4rI2JrhKyZMMuxg/JRt30E6qeQs6ueaiz5hr1cPj+Sk2kATgQzMMqsa2DiNI0TIK++1ULx8Jw==",
- "dev": true,
- "dependencies": {
- "@ampproject/remapping": "^2.3.0",
- "@vitest/expect": "2.0.3",
- "@vitest/pretty-format": "^2.0.3",
- "@vitest/runner": "2.0.3",
- "@vitest/snapshot": "2.0.3",
- "@vitest/spy": "2.0.3",
- "@vitest/utils": "2.0.3",
- "chai": "^5.1.1",
- "debug": "^4.3.5",
- "execa": "^8.0.1",
- "magic-string": "^0.30.10",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.4.tgz",
+ "integrity": "sha512-eDjxbVAJw1UJJCHr5xr/xM86Zx+YxIEXGAR+bmnEID7z9qWfoxpHw0zdobz+TQAFOLT+nEXz3+gx6nUJ7RgmlQ==",
+ "dev": true,
+ "dependencies": {
+ "@vitest/expect": "2.1.4",
+ "@vitest/mocker": "2.1.4",
+ "@vitest/pretty-format": "^2.1.4",
+ "@vitest/runner": "2.1.4",
+ "@vitest/snapshot": "2.1.4",
+ "@vitest/spy": "2.1.4",
+ "@vitest/utils": "2.1.4",
+ "chai": "^5.1.2",
+ "debug": "^4.3.7",
+ "expect-type": "^1.1.0",
+ "magic-string": "^0.30.12",
"pathe": "^1.1.2",
"std-env": "^3.7.0",
- "tinybench": "^2.8.0",
- "tinypool": "^1.0.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^0.3.1",
+ "tinypool": "^1.0.1",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
- "vite-node": "2.0.3",
- "why-is-node-running": "^2.2.2"
+ "vite-node": "2.1.4",
+ "why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
@@ -27584,8 +27683,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
- "@vitest/browser": "2.0.3",
- "@vitest/ui": "2.0.3",
+ "@vitest/browser": "2.1.4",
+ "@vitest/ui": "2.1.4",
"happy-dom": "*",
"jsdom": "*"
},
@@ -27610,128 +27709,6 @@
}
}
},
- "node_modules/vitest/node_modules/execa": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
- "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
- "dev": true,
- "dependencies": {
- "cross-spawn": "^7.0.3",
- "get-stream": "^8.0.1",
- "human-signals": "^5.0.0",
- "is-stream": "^3.0.0",
- "merge-stream": "^2.0.0",
- "npm-run-path": "^5.1.0",
- "onetime": "^6.0.0",
- "signal-exit": "^4.1.0",
- "strip-final-newline": "^3.0.0"
- },
- "engines": {
- "node": ">=16.17"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/execa?sponsor=1"
- }
- },
- "node_modules/vitest/node_modules/get-stream": {
- "version": "8.0.1",
- "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
- "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
- "dev": true,
- "engines": {
- "node": ">=16"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/vitest/node_modules/human-signals": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
- "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
- "dev": true,
- "engines": {
- "node": ">=16.17.0"
- }
- },
- "node_modules/vitest/node_modules/is-stream": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
- "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
- "dev": true,
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/vitest/node_modules/mimic-fn": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
- "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/vitest/node_modules/npm-run-path": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
- "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
- "dev": true,
- "dependencies": {
- "path-key": "^4.0.0"
- },
- "engines": {
- "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/vitest/node_modules/onetime": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
- "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
- "dev": true,
- "dependencies": {
- "mimic-fn": "^4.0.0"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/vitest/node_modules/path-key": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
- "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/vitest/node_modules/strip-final-newline": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
- "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/vscode-oniguruma": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
@@ -28034,9 +28011,9 @@
}
},
"node_modules/why-is-node-running": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz",
- "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==",
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"dependencies": {
"siginfo": "^2.0.0",
@@ -28673,7 +28650,6 @@
"@tiptap/extension-code": "^2.7.1",
"@tiptap/extension-collaboration": "^2.7.1",
"@tiptap/extension-collaboration-cursor": "^2.7.1",
- "@tiptap/extension-dropcursor": "^2.7.1",
"@tiptap/extension-gapcursor": "^2.7.1",
"@tiptap/extension-hard-break": "^2.7.1",
"@tiptap/extension-history": "^2.7.1",
@@ -28690,8 +28666,9 @@
"@tiptap/pm": "^2.7.1",
"emoji-mart": "^5.6.0",
"hast-util-from-dom": "^4.2.0",
+ "prosemirror-dropcursor": "^1.8.1",
"prosemirror-highlight": "^0.9.0",
- "prosemirror-model": "^1.21.0",
+ "prosemirror-model": "^1.23.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.1",
"prosemirror-transform": "^1.9.0",
@@ -28884,6 +28861,35 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "packages/multi-column": {
+ "name": "@blocknote/xl-multi-column",
+ "version": "0.18.1",
+ "extraneous": true,
+ "license": "AGPL-3.0 OR PROPRIETARY",
+ "dependencies": {
+ "@blocknote/core": "*",
+ "@blocknote/react": "*",
+ "@tiptap/core": "^2.7.1",
+ "prosemirror-model": "^1.23.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.3.7",
+ "prosemirror-transform": "^1.9.0",
+ "prosemirror-view": "^1.33.7",
+ "react-icons": "^5.2.1"
+ },
+ "devDependencies": {
+ "@vitest/ui": "^2.1.4",
+ "eslint": "^8.10.0",
+ "jsdom": "^21.1.0",
+ "prettier": "^2.7.1",
+ "rimraf": "^5.0.5",
+ "rollup-plugin-webpack-stats": "^0.2.2",
+ "typescript": "^5.3.3",
+ "vite": "^5.3.4",
+ "vite-plugin-eslint": "^1.8.1",
+ "vitest": "^2.0.3"
+ }
+ },
"packages/react": {
"name": "@blocknote/react",
"version": "0.18.1",
@@ -29030,6 +29036,49 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "packages/xl-multi-column": {
+ "name": "@blocknote/xl-multi-column",
+ "version": "0.18.1",
+ "license": "AGPL-3.0 OR PROPRIETARY",
+ "dependencies": {
+ "@blocknote/core": "*",
+ "@blocknote/react": "*",
+ "@tiptap/core": "^2.7.1",
+ "prosemirror-model": "^1.23.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.3.7",
+ "prosemirror-transform": "^1.9.0",
+ "prosemirror-view": "^1.33.7",
+ "react-icons": "^5.2.1"
+ },
+ "devDependencies": {
+ "@vitest/ui": "^2.1.4",
+ "eslint": "^8.10.0",
+ "jsdom": "^21.1.0",
+ "prettier": "^2.7.1",
+ "rimraf": "^5.0.5",
+ "rollup-plugin-webpack-stats": "^0.2.2",
+ "typescript": "^5.3.3",
+ "vite": "^5.3.4",
+ "vite-plugin-eslint": "^1.8.1",
+ "vitest": "^2.0.3"
+ }
+ },
+ "packages/xl-multi-column/node_modules/rimraf": {
+ "version": "5.0.10",
+ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
+ "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==",
+ "dev": true,
+ "dependencies": {
+ "glob": "^10.3.7"
+ },
+ "bin": {
+ "rimraf": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"playground": {
"name": "@blocknote/example-editor",
"version": "0.18.1",
@@ -29042,6 +29091,7 @@
"@blocknote/react": "^0.18.1",
"@blocknote/server-util": "^0.18.1",
"@blocknote/shadcn": "^0.18.1",
+ "@blocknote/xl-multi-column": "^0.18.1",
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@liveblocks/client": "^1.10.0",
diff --git a/packages/ariakit/package.json b/packages/ariakit/package.json
index 19a7e7fb67..816ece0626 100644
--- a/packages/ariakit/package.json
+++ b/packages/ariakit/package.json
@@ -45,7 +45,6 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "build-bundled": "tsc && vite build --config vite.config.bundled.ts && git checkout tmp-releases && rm -rf ../../release && mv ../../release-tmp ../../release",
"preview": "vite preview",
"lint": "eslint src --max-warnings 0",
"clean": "rimraf dist && rimraf types"
diff --git a/packages/core/package.json b/packages/core/package.json
index 0dbd7fed5a..3796a5fa13 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -63,7 +63,6 @@
"@tiptap/extension-code": "^2.7.1",
"@tiptap/extension-collaboration": "^2.7.1",
"@tiptap/extension-collaboration-cursor": "^2.7.1",
- "@tiptap/extension-dropcursor": "^2.7.1",
"@tiptap/extension-gapcursor": "^2.7.1",
"@tiptap/extension-hard-break": "^2.7.1",
"@tiptap/extension-history": "^2.7.1",
@@ -80,12 +79,13 @@
"@tiptap/pm": "^2.7.1",
"emoji-mart": "^5.6.0",
"hast-util-from-dom": "^4.2.0",
+ "prosemirror-model": "^1.23.0",
"prosemirror-highlight": "^0.9.0",
- "prosemirror-model": "^1.21.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.1",
"prosemirror-transform": "^1.9.0",
"prosemirror-view": "^1.33.7",
+ "prosemirror-dropcursor": "^1.8.1",
"rehype-format": "^5.0.0",
"rehype-parse": "^8.0.4",
"rehype-remark": "^9.1.2",
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
index 66474cef0e..21eb2dc305 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
@@ -37,6 +37,8 @@ export function insertBlocks<
editor._tiptapEditor.state.doc
);
+ // TODO: we might want to use the ReplaceStep directly here instead of insert,
+ // because the fitting algorithm should not be necessary and might even cause unexpected behavior
if (placement === "before") {
editor.dispatch(
editor._tiptapEditor.state.tr.insert(posBeforeNode, nodesToInsert)
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 4e33a3b1ea..9c9613d0b0 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts
@@ -13,8 +13,8 @@ function mergeBlocks(posBetweenBlocks: number) {
}
function getPosBeforeSelectedBlock() {
- return getBlockInfoFromSelection(getEditor()._tiptapEditor.state)
- .blockContainer.beforePos;
+ return getBlockInfoFromSelection(getEditor()._tiptapEditor.state).bnBlock
+ .beforePos;
}
describe("Test mergeBlocks", () => {
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
index 7af11571d6..9345287faa 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
@@ -1,68 +1,114 @@
-import { Node, ResolvedPos } from "prosemirror-model";
+import { Node } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
-import { getBlockInfoFromResolvedPos } from "../../../getBlockInfoFromPos.js";
+import {
+ BlockInfo,
+ getBlockInfoFromResolvedPos,
+} from "../../../getBlockInfoFromPos.js";
-export const getPrevBlockPos = (doc: Node, $nextBlockPos: ResolvedPos) => {
- const prevNode = $nextBlockPos.nodeBefore;
+/**
+ * Returns the block info from the parent block
+ * or undefined if we're at the root
+ */
+export const getParentBlockInfo = (doc: Node, beforePos: number) => {
+ const $pos = doc.resolve(beforePos);
- if (!prevNode) {
- throw new Error(
- `Attempted to get previous blockContainer node for merge at position ${$nextBlockPos.pos} but a previous node does not exist`
- );
+ if ($pos.depth <= 1) {
+ return undefined;
}
- // Finds the nearest previous block, regardless of nesting level.
- let prevBlockBeforePos = $nextBlockPos.posAtIndex($nextBlockPos.index() - 1);
- let prevBlockInfo = getBlockInfoFromResolvedPos(
- doc.resolve(prevBlockBeforePos)
+ // get start pos of parent
+ const parentBeforePos = $pos.posAtIndex(
+ $pos.index($pos.depth - 1),
+ $pos.depth - 1
+ );
+
+ const parentBlockInfo = getBlockInfoFromResolvedPos(
+ doc.resolve(parentBeforePos)
);
+ return parentBlockInfo;
+};
- while (prevBlockInfo.blockGroup) {
- const group = prevBlockInfo.blockGroup.node;
+/**
+ * Returns the block info from the sibling block before (above) the given block,
+ * or undefined if the given block is the first sibling.
+ */
+export const getPrevBlockInfo = (doc: Node, beforePos: number) => {
+ const $pos = doc.resolve(beforePos);
- prevBlockBeforePos = doc
- .resolve(prevBlockInfo.blockGroup.beforePos + 1)
- .posAtIndex(group.childCount - 1);
- prevBlockInfo = getBlockInfoFromResolvedPos(
- doc.resolve(prevBlockBeforePos)
- );
+ const indexInParent = $pos.index();
+
+ if (indexInParent === 0) {
+ return undefined;
}
- return doc.resolve(prevBlockBeforePos);
+ const prevBlockBeforePos = $pos.posAtIndex(indexInParent - 1);
+
+ const prevBlockInfo = getBlockInfoFromResolvedPos(
+ doc.resolve(prevBlockBeforePos)
+ );
+ return prevBlockInfo;
};
-const canMerge = ($prevBlockPos: ResolvedPos, $nextBlockPos: ResolvedPos) => {
- const prevBlockInfo = getBlockInfoFromResolvedPos($prevBlockPos);
- const nextBlockInfo = getBlockInfoFromResolvedPos($nextBlockPos);
+/**
+ * If a block has children like this:
+ * A
+ * - B
+ * - C
+ * -- D
+ *
+ * Then the bottom nested block returned is D.
+ */
+export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => {
+ while (blockInfo.childContainer) {
+ const group = blockInfo.childContainer.node;
+
+ const newPos = doc
+ .resolve(blockInfo.childContainer.beforePos + 1)
+ .posAtIndex(group.childCount - 1);
+ blockInfo = getBlockInfoFromResolvedPos(doc.resolve(newPos));
+ }
+ return blockInfo;
+};
+
+const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => {
return (
+ prevBlockInfo.isBlockContainer &&
prevBlockInfo.blockContent.node.type.spec.content === "inline*" &&
- nextBlockInfo.blockContent.node.type.spec.content === "inline*" &&
- prevBlockInfo.blockContent.node.childCount > 0
+ prevBlockInfo.blockContent.node.childCount > 0 &&
+ nextBlockInfo.isBlockContainer &&
+ nextBlockInfo.blockContent.node.type.spec.content === "inline*"
);
};
const mergeBlocks = (
state: EditorState,
dispatch: ((args?: any) => any) | undefined,
- $prevBlockPos: ResolvedPos,
- $nextBlockPos: ResolvedPos
+ prevBlockInfo: BlockInfo,
+ nextBlockInfo: BlockInfo
) => {
- const nextBlockInfo = getBlockInfoFromResolvedPos($nextBlockPos);
-
// Un-nests all children of the next block.
- if (nextBlockInfo.blockGroup) {
+ if (!nextBlockInfo.isBlockContainer) {
+ throw new Error(
+ `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but next block is not a block container`
+ );
+ }
+
+ // Removes a level of nesting all children of the next block by 1 level, if it contains both content and block
+ // group nodes.
+ if (nextBlockInfo.childContainer) {
const childBlocksStart = state.doc.resolve(
- nextBlockInfo.blockGroup.beforePos + 1
+ nextBlockInfo.childContainer.beforePos + 1
);
const childBlocksEnd = state.doc.resolve(
- nextBlockInfo.blockGroup.afterPos - 1
+ nextBlockInfo.childContainer.afterPos - 1
);
const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd);
if (dispatch) {
- state.tr.lift(childBlocksRange!, $nextBlockPos.depth);
+ const pos = state.doc.resolve(nextBlockInfo.bnBlock.beforePos);
+ state.tr.lift(childBlocksRange!, pos.depth);
}
}
@@ -70,8 +116,13 @@ const mergeBlocks = (
// removing the closing tags of the first block and the opening tags of the
// second one to stitch them together.
if (dispatch) {
- const prevBlockInfo = getBlockInfoFromResolvedPos($prevBlockPos);
+ if (!prevBlockInfo.isBlockContainer) {
+ throw new Error(
+ `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but previous block is not a block container`
+ );
+ }
+ // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
dispatch(
state.tr.delete(
prevBlockInfo.blockContent.afterPos - 1,
@@ -92,12 +143,26 @@ export const mergeBlocksCommand =
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
- const $nextBlockPos = state.doc.resolve(posBetweenBlocks);
- const $prevBlockPos = getPrevBlockPos(state.doc, $nextBlockPos);
+ const $pos = state.doc.resolve(posBetweenBlocks);
+ const nextBlockInfo = getBlockInfoFromResolvedPos($pos);
+
+ const prevBlockInfo = getPrevBlockInfo(
+ state.doc,
+ nextBlockInfo.bnBlock.beforePos
+ );
+
+ if (!prevBlockInfo) {
+ return false;
+ }
+
+ const bottomNestedBlockInfo = getBottomNestedBlockInfo(
+ state.doc,
+ prevBlockInfo
+ );
- if (!canMerge($prevBlockPos, $nextBlockPos)) {
+ if (!canMerge(bottomNestedBlockInfo, nextBlockInfo)) {
return false;
}
- return mergeBlocks(state, dispatch, $prevBlockPos, $nextBlockPos);
+ return mergeBlocks(state, dispatch, bottomNestedBlockInfo, nextBlockInfo);
};
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlock/moveBlock.ts b/packages/core/src/api/blockManipulation/commands/moveBlock/moveBlock.ts
index 89ed457b4e..20974ddc2a 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlock/moveBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlock/moveBlock.ts
@@ -31,13 +31,11 @@ type BlockSelectionData = (
function getBlockSelectionData(
editor: BlockNoteEditor
): BlockSelectionData {
- const { blockContainer } = getBlockInfoFromSelection(
- editor._tiptapEditor.state
- );
+ const { bnBlock } = getBlockInfoFromSelection(editor._tiptapEditor.state);
const selectionData = {
- blockId: blockContainer.node.attrs.id,
- blockPos: blockContainer.beforePos,
+ blockId: bnBlock.node.attrs.id,
+ blockPos: bnBlock.beforePos,
};
if (editor._tiptapEditor.state.selection instanceof CellSelection) {
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
new file mode 100644
index 0000000000..0f0463660d
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
@@ -0,0 +1,100 @@
+import { Fragment, NodeType, Slice } from "prosemirror-model";
+import { EditorState } from "prosemirror-state";
+import { ReplaceAroundStep } from "prosemirror-transform";
+
+import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js";
+
+// TODO: Unit tests
+/**
+ * This is a modified version of https://github.com/ProseMirror/prosemirror-schema-list/blob/569c2770cbb8092d8f11ea53ecf78cb7a4e8f15a/src/schema-list.ts#L232
+ *
+ * The original function derives too many information from the parentnode and itemtype
+ */
+function sinkListItem(itemType: NodeType, groupType: NodeType) {
+ return function ({ state, dispatch }: { state: EditorState; dispatch: any }) {
+ const { $from, $to } = state.selection;
+ const range = $from.blockRange(
+ $to,
+ (node) =>
+ node.childCount > 0 &&
+ (node.type.name === "blockGroup" || node.type.name === "column") // change necessary to not look at first item child type
+ );
+ if (!range) {
+ return false;
+ }
+ const startIndex = range.startIndex;
+ if (startIndex === 0) {
+ return false;
+ }
+ const parent = range.parent;
+ const nodeBefore = parent.child(startIndex - 1);
+ if (nodeBefore.type !== itemType) {
+ return false;
+ }
+ if (dispatch) {
+ const nestedBefore =
+ nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change necessary to check groupType instead of parent.type
+ const inner = Fragment.from(nestedBefore ? itemType.create() : null);
+ const slice = new Slice(
+ Fragment.from(
+ itemType.create(null, Fragment.from(groupType.create(null, inner))) // change necessary to create "groupType" instead of parent.type
+ ),
+ nestedBefore ? 3 : 1,
+ 0
+ );
+
+ const before = range.start;
+ const after = range.end;
+ dispatch(
+ state.tr
+ .step(
+ new ReplaceAroundStep(
+ before - (nestedBefore ? 3 : 1),
+ after,
+ before,
+ after,
+ slice,
+ 1,
+ true
+ )
+ )
+ .scrollIntoView()
+ );
+ }
+ return true;
+ };
+}
+
+export function nestBlock(editor: BlockNoteEditor) {
+ return editor._tiptapEditor.commands.command(
+ sinkListItem(
+ editor._tiptapEditor.schema.nodes["blockContainer"],
+ editor._tiptapEditor.schema.nodes["blockGroup"]
+ )
+ );
+}
+
+export function unnestBlock(editor: BlockNoteEditor) {
+ editor._tiptapEditor.commands.liftListItem("blockContainer");
+}
+
+export function canNestBlock(editor: BlockNoteEditor) {
+ const { bnBlock: blockContainer } = getBlockInfoFromSelection(
+ editor._tiptapEditor.state
+ );
+
+ return (
+ editor._tiptapEditor.state.doc.resolve(blockContainer.beforePos)
+ .nodeBefore !== null
+ );
+}
+export function canUnnestBlock(editor: BlockNoteEditor) {
+ const { bnBlock: blockContainer } = getBlockInfoFromSelection(
+ editor._tiptapEditor.state
+ );
+
+ return (
+ editor._tiptapEditor.state.doc.resolve(blockContainer.beforePos).depth > 1
+ );
+}
diff --git a/packages/core/src/api/blockManipulation/commands/removeBlocks/removeBlocks.ts b/packages/core/src/api/blockManipulation/commands/removeBlocks/removeBlocks.ts
index 550e20ecc4..f5cd55d2cc 100644
--- a/packages/core/src/api/blockManipulation/commands/removeBlocks/removeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/removeBlocks/removeBlocks.ts
@@ -45,7 +45,7 @@ export function removeBlocksWithCallback<
// Keeps traversing nodes if block with target ID has not been found.
if (
- node.type.name !== "blockContainer" ||
+ !node.type.isInGroup("bnBlock") ||
!idsOfBlocksToRemove.has(node.attrs.id)
) {
return true;
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 5d074ee1c8..c9c53480e5 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
@@ -28,11 +28,15 @@ function setSelectionWithOffset(
offset: number
) {
const posInfo = getNodeById(targetBlockId, doc);
- const { blockContent } = getBlockInfo(posInfo);
+ const info = getBlockInfo(posInfo);
+
+ if (!info.isBlockContainer) {
+ throw new Error("Target block is not a block container");
+ }
getEditor()._tiptapEditor.view.dispatch(
getEditor()._tiptapEditor.state.tr.setSelection(
- TextSelection.create(doc, blockContent.beforePos + offset + 1)
+ TextSelection.create(doc, info.blockContent.beforePos + offset + 1)
)
);
}
@@ -123,12 +127,12 @@ describe("Test splitBlocks", () => {
splitBlock(getEditor()._tiptapEditor.state.selection.anchor);
- const { blockContainer } = getBlockInfoFromSelection(
+ const { bnBlock } = getBlockInfoFromSelection(
getEditor()._tiptapEditor.state
);
const anchorIsAtStartOfNewBlock =
- blockContainer.node.attrs.id === "0" &&
+ bnBlock.node.attrs.id === "0" &&
getEditor()._tiptapEditor.state.selection.$anchor.parentOffset === 0;
expect(anchorIsAtStartOfNewBlock).toBeTruthy();
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
index 07df1a9a52..a12c03d90f 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
@@ -22,20 +22,24 @@ export const splitBlockCommand = (
posInBlock
);
- const { blockContainer, blockContent } = getBlockInfo(
- nearestBlockContainerPos
- );
+ const info = getBlockInfo(nearestBlockContainerPos);
+
+ if (!info.isBlockContainer) {
+ throw new Error(
+ `BlockContainer expected when calling splitBlock, position ${posInBlock}`
+ );
+ }
const types = [
{
- type: blockContainer.node.type, // always keep blockcontainer type
- attrs: keepProps ? { ...blockContainer.node.attrs, id: undefined } : {},
+ type: info.bnBlock.node.type, // always keep blockcontainer type
+ attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {},
},
{
type: keepType
- ? blockContent.node.type
+ ? info.blockContent.node.type
: state.schema.nodes["paragraph"],
- attrs: keepProps ? { ...blockContent.node.attrs } : {},
+ attrs: keepProps ? { ...info.blockContent.node.attrs } : {},
},
];
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 bf548f9141..c4ec545b14 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
@@ -189,11 +189,11 @@ describe("Test updateBlock", () => {
});
it("Update inline content to empty table content", () => {
- updateBlock(getEditor(), "paragraph-0", {
- type: "table",
- });
-
- expect(getEditor().document).toMatchSnapshot();
+ expect(() => {
+ updateBlock(getEditor(), "paragraph-0", {
+ type: "table",
+ });
+ }).toThrow();
});
it("Update table content to empty inline content", () => {
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
index 4055416092..4db6cbd148 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
@@ -1,6 +1,7 @@
-import { Fragment, Node as PMNode, Slice } from "prosemirror-model";
+import { Fragment, NodeType, Node as PMNode, Slice } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
+import { ReplaceStep } from "prosemirror-transform";
import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
@@ -10,7 +11,10 @@ import {
import { InlineContentSchema } from "../../../../schema/inlineContent/types.js";
import { StyleSchema } from "../../../../schema/styles/types.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
-import { getBlockInfoFromResolvedPos } from "../../../getBlockInfoFromPos.js";
+import {
+ BlockInfo,
+ getBlockInfoFromResolvedPos,
+} from "../../../getBlockInfoFromPos.js";
import {
blockToNode,
inlineContentToNodes,
@@ -36,128 +40,74 @@ export const updateBlockCommand =
state: EditorState;
dispatch: ((args?: any) => any) | undefined;
}) => {
- const { blockContainer, blockContent, blockGroup } =
- getBlockInfoFromResolvedPos(state.doc.resolve(posBeforeBlock));
+ const blockInfo = getBlockInfoFromResolvedPos(
+ state.doc.resolve(posBeforeBlock)
+ );
if (dispatch) {
// Adds blockGroup node with child blocks if necessary.
- if (block.children !== undefined) {
- const childNodes = [];
-
- // Creates ProseMirror nodes for each child block, including their descendants.
- for (const child of block.children) {
- childNodes.push(
- blockToNode(child, state.schema, editor.schema.styleSchema)
- );
- }
-
- // Checks if a blockGroup node already exists.
- if (blockGroup) {
- // Replaces all child nodes in the existing blockGroup with the ones created earlier.
- state.tr.replace(
- blockGroup.beforePos + 1,
- blockGroup.afterPos - 1,
- new Slice(Fragment.from(childNodes), 0, 0)
- );
- } else {
- // Inserts a new blockGroup containing the child nodes created earlier.
- state.tr.insert(
- blockContent.afterPos,
- state.schema.nodes["blockGroup"].create({}, childNodes)
- );
- }
- }
-
- const oldType = blockContent.node.type.name;
- const newType = block.type || oldType;
- // The code below determines the new content of the block.
- // or "keep" to keep as-is
- let content: PMNode[] | "keep" = "keep";
+ const oldNodeType = state.schema.nodes[blockInfo.blockNoteType];
+ const newNodeType =
+ state.schema.nodes[block.type || blockInfo.blockNoteType];
+ const newBnBlockNodeType = newNodeType.isInGroup("bnBlock")
+ ? newNodeType
+ : state.schema.nodes["blockContainer"];
- // Has there been any custom content provided?
- if (block.content) {
- if (typeof block.content === "string") {
- // Adds a single text node with no marks to the content.
- content = inlineContentToNodes(
- [block.content],
- state.schema,
- editor.schema.styleSchema
- );
- } else if (Array.isArray(block.content)) {
- // Adds a text node with the provided styles converted into marks to the content,
- // for each InlineContent object.
- content = inlineContentToNodes(
- block.content,
- state.schema,
- editor.schema.styleSchema
- );
- } else if (block.content.type === "tableContent") {
- content = tableContentToNodes(
- block.content,
- state.schema,
- editor.schema.styleSchema
- );
- } else {
- throw new UnreachableCaseError(block.content.type);
- }
+ if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) {
+ updateChildren(block, state, editor, blockInfo);
+ // The code below determines the new content of the block.
+ // or "keep" to keep as-is
+ updateBlockContentNode(
+ block,
+ state,
+ editor,
+ oldNodeType,
+ newNodeType,
+ blockInfo
+ );
+ } else if (
+ !blockInfo.isBlockContainer &&
+ newNodeType.isInGroup("bnBlock")
+ ) {
+ updateChildren(block, state, editor, blockInfo);
+ // old node was a bnBlock type (like column or columnList) and new block as well
+ // No op, we just update the bnBlock below (at end of function) and have already updated the children
} else {
- // 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.
- const oldContentType = state.schema.nodes[oldType].spec.content;
- const newContentType = state.schema.nodes[newType].spec.content;
-
- if (oldContentType === "") {
- // keep old content, because it's empty anyway and should be compatible with
- // any newContentType
- } else if (newContentType !== oldContentType) {
- // the content type changed, replace the previous content
- content = [];
- } else {
- // keep old content, because the content type is the same and should be compatible
- }
- }
+ // switching from blockContainer to non-blockContainer or v.v.
+ // currently breaking for column slash menu items converting empty block
+ // to column.
- // Now, changes the blockContent node type and adds the provided props
- // as attributes. Also preserves all existing attributes that are
- // compatible with the new type.
- //
- // Use either setNodeMarkup or replaceWith depending on whether the
- // content is being replaced or not.
- if (content === "keep") {
- // use setNodeMarkup to only update the type and attributes
- state.tr.setNodeMarkup(
- blockContent.beforePos,
- block.type === undefined ? undefined : state.schema.nodes[block.type],
- {
- ...blockContent.node.attrs,
- ...block.props,
- }
+ // 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.bnBlock.node,
+ editor.schema.blockSchema,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema,
+ editor.blockCache
);
- } else {
- // use replaceWith to replace the content and the block itself
- // also reset the selection since replacing the block content
- // sets it to the next block.
state.tr.replaceWith(
- blockContent.beforePos,
- blockContent.afterPos,
- state.schema.nodes[newType].create(
+ blockInfo.bnBlock.beforePos,
+ blockInfo.bnBlock.afterPos,
+ blockToNode(
{
- ...blockContent.node.attrs,
- ...block.props,
+ children: existingBlock.children, // if no children are passed in, use existing children
+ ...block,
},
- content
+ state.schema,
+ editor.schema.styleSchema
)
);
+
+ return true;
}
// Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing
// attributes.
- state.tr.setNodeMarkup(blockContainer.beforePos, undefined, {
- ...blockContainer.node.attrs,
+ state.tr.setNodeMarkup(blockInfo.bnBlock.beforePos, newBnBlockNodeType, {
+ ...blockInfo.bnBlock.node.attrs,
...block.props,
});
}
@@ -165,6 +115,141 @@ export const updateBlockCommand =
return true;
};
+function updateBlockContentNode<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ block: PartialBlock,
+ state: EditorState,
+ editor: BlockNoteEditor,
+ oldNodeType: NodeType,
+ newNodeType: NodeType,
+ blockInfo: {
+ childContainer?:
+ | { node: PMNode; beforePos: number; afterPos: number }
+ | undefined;
+ blockContent: { node: PMNode; beforePos: number; afterPos: number };
+ }
+) {
+ let content: PMNode[] | "keep" = "keep";
+
+ // Has there been any custom content provided?
+ if (block.content) {
+ if (typeof block.content === "string") {
+ // Adds a single text node with no marks to the content.
+ content = inlineContentToNodes(
+ [block.content],
+ state.schema,
+ editor.schema.styleSchema
+ );
+ } else if (Array.isArray(block.content)) {
+ // Adds a text node with the provided styles converted into marks to the content,
+ // for each InlineContent object.
+ content = inlineContentToNodes(
+ block.content,
+ state.schema,
+ editor.schema.styleSchema
+ );
+ } else if (block.content.type === "tableContent") {
+ content = tableContentToNodes(
+ block.content,
+ state.schema,
+ editor.schema.styleSchema
+ );
+ } else {
+ throw new UnreachableCaseError(block.content.type);
+ }
+ } else {
+ // 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.
+ if (oldNodeType.spec.content === "") {
+ // keep old content, because it's empty anyway and should be compatible with
+ // any newContentType
+ } else if (newNodeType.spec.content !== oldNodeType.spec.content) {
+ // the content type changed, replace the previous content
+ content = [];
+ } else {
+ // keep old content, because the content type is the same and should be compatible
+ }
+ }
+
+ // Now, changes the blockContent node type and adds the provided props
+ // as attributes. Also preserves all existing attributes that are
+ // compatible with the new type.
+ //
+ // Use either setNodeMarkup or replaceWith depending on whether the
+ // content is being replaced or not.
+ if (content === "keep") {
+ // use setNodeMarkup to only update the type and attributes
+ state.tr.setNodeMarkup(
+ blockInfo.blockContent.beforePos,
+ block.type === undefined ? undefined : state.schema.nodes[block.type],
+ {
+ ...blockInfo.blockContent.node.attrs,
+ ...block.props,
+ }
+ );
+ } else {
+ // use replaceWith to replace the content and the block itself
+ // also reset the selection since replacing the block content
+ // sets it to the next block.
+ state.tr.replaceWith(
+ blockInfo.blockContent.beforePos,
+ blockInfo.blockContent.afterPos,
+ newNodeType.createChecked(
+ {
+ ...blockInfo.blockContent.node.attrs,
+ ...block.props,
+ },
+ content
+ )
+ );
+ }
+}
+
+function updateChildren<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ block: PartialBlock,
+ state: EditorState,
+ editor: BlockNoteEditor,
+ blockInfo: BlockInfo
+) {
+ if (block.children !== undefined) {
+ const childNodes = block.children.map((child) => {
+ return blockToNode(child, state.schema, editor.schema.styleSchema);
+ });
+
+ // Checks if a blockGroup node already exists.
+ if (blockInfo.childContainer) {
+ // Replaces all child nodes in the existing blockGroup with the ones created earlier.
+
+ // use a replacestep to avoid the fitting algorithm
+ state.tr.step(
+ new ReplaceStep(
+ blockInfo.childContainer.beforePos + 1,
+ blockInfo.childContainer.afterPos - 1,
+ new Slice(Fragment.from(childNodes), 0, 0)
+ )
+ );
+ } else {
+ if (!blockInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
+ // Inserts a new blockGroup containing the child nodes created earlier.
+ state.tr.insert(
+ blockInfo.blockContent.afterPos,
+ state.schema.nodes["blockGroup"].createChecked({}, childNodes)
+ );
+ }
+ }
+}
+
export function updateBlock<
BSchema extends BlockSchema,
I extends InlineContentSchema,
diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts
index d7237b0b2c..e38ccf301d 100644
--- a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts
+++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts
@@ -21,30 +21,31 @@ export function getTextCursorPosition<
I extends InlineContentSchema,
S extends StyleSchema
>(editor: BlockNoteEditor): TextCursorPosition {
- const { blockContainer } = getBlockInfoFromSelection(
- editor._tiptapEditor.state
- );
+ const { bnBlock } = getBlockInfoFromSelection(editor._tiptapEditor.state);
- const resolvedPos = editor._tiptapEditor.state.doc.resolve(
- blockContainer.beforePos
- );
+ const resolvedPos = editor._tiptapEditor.state.doc.resolve(bnBlock.beforePos);
// Gets previous blockContainer node at the same nesting level, if the current node isn't the first child.
const prevNode = resolvedPos.nodeBefore;
// Gets next blockContainer node at the same nesting level, if the current node isn't the last child.
const nextNode = editor._tiptapEditor.state.doc.resolve(
- blockContainer.afterPos
+ bnBlock.afterPos
).nodeAfter;
// Gets parent blockContainer node, if the current node is nested.
let parentNode: Node | undefined = undefined;
if (resolvedPos.depth > 1) {
- parentNode = resolvedPos.node(resolvedPos.depth - 1);
+ // for nodes nested in bnBlocks
+ parentNode = resolvedPos.node();
+ if (!parentNode.type.isInGroup("bnBlock")) {
+ // for blockGroups, we need to go one level up
+ parentNode = resolvedPos.node(resolvedPos.depth - 1);
+ }
}
return {
block: nodeToBlock(
- blockContainer.node,
+ bnBlock.node,
editor.schema.blockSchema,
editor.schema.inlineContentSchema,
editor.schema.styleSchema,
@@ -95,36 +96,50 @@ export function setTextCursorPosition<
const id = typeof targetBlock === "string" ? targetBlock : targetBlock.id;
const posInfo = getNodeById(id, editor._tiptapEditor.state.doc);
- const { blockContent } = getBlockInfo(posInfo);
+ const info = getBlockInfo(posInfo);
const contentType: "none" | "inline" | "table" =
- editor.schema.blockSchema[blockContent.node.type.name]!.content;
-
- if (contentType === "none") {
- editor._tiptapEditor.commands.setNodeSelection(blockContent.beforePos);
- return;
- }
+ editor.schema.blockSchema[info.blockNoteType]!.content;
- if (contentType === "inline") {
- if (placement === "start") {
- editor._tiptapEditor.commands.setTextSelection(
- blockContent.beforePos + 1
- );
- } else {
- editor._tiptapEditor.commands.setTextSelection(blockContent.afterPos - 1);
+ if (info.isBlockContainer) {
+ const blockContent = info.blockContent;
+ if (contentType === "none") {
+ editor._tiptapEditor.commands.setNodeSelection(blockContent.beforePos);
+ return;
}
- } else if (contentType === "table") {
- if (placement === "start") {
- // Need to offset the position as we have to get through the `tableRow`
- // and `tableCell` nodes to get to the `tableParagraph` node we want to
- // set the selection in.
- editor._tiptapEditor.commands.setTextSelection(
- blockContent.beforePos + 4
- );
+
+ if (contentType === "inline") {
+ if (placement === "start") {
+ editor._tiptapEditor.commands.setTextSelection(
+ blockContent.beforePos + 1
+ );
+ } else {
+ editor._tiptapEditor.commands.setTextSelection(
+ blockContent.afterPos - 1
+ );
+ }
+ } else if (contentType === "table") {
+ if (placement === "start") {
+ // Need to offset the position as we have to get through the `tableRow`
+ // and `tableCell` nodes to get to the `tableParagraph` node we want to
+ // set the selection in.
+ editor._tiptapEditor.commands.setTextSelection(
+ blockContent.beforePos + 4
+ );
+ } else {
+ editor._tiptapEditor.commands.setTextSelection(
+ blockContent.afterPos - 4
+ );
+ }
} else {
- editor._tiptapEditor.commands.setTextSelection(blockContent.afterPos - 4);
+ throw new UnreachableCaseError(contentType);
}
} else {
- throw new UnreachableCaseError(contentType);
+ const child =
+ placement === "start"
+ ? info.childContainer.node.firstChild!
+ : info.childContainer.node.lastChild!;
+
+ setTextCursorPosition(editor, child.attrs.id, placement);
}
}
diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
index c7944bf4a2..2369dd248e 100644
--- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
+++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts
@@ -144,7 +144,7 @@ export async function handleFileInsertion<
insertedBlockId = editor.insertBlocks(
[fileBlock],
- blockInfo.blockContainer.node.attrs.id,
+ blockInfo.bnBlock.node.attrs.id,
"after"
)[0].id;
} else {
diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
index 72d92d285b..87e2c7c81e 100644
--- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
+++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts
@@ -49,7 +49,7 @@ function fragmentToExternalHTML<
isWithinBlockContent =
children.find(
(child) =>
- child.type.name === "blockContainer" ||
+ child.type.isInGroup("bnBlock") ||
child.type.name === "blockGroup" ||
child.type.spec.group === "blockContent"
) === undefined;
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
index 65256a67fa..7f734c3f7d 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
@@ -205,7 +205,13 @@ function serializeBlock<
}
}
- fragment.append(childFragment);
+ if (editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")) {
+ // default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment
+ fragment.append(childFragment);
+ } else {
+ // for columns / column lists, do use nesting
+ ret.contentDOM?.append(childFragment);
+ }
}
}
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
index 1f65ef275c..b3d3ed89c0 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
@@ -82,16 +82,6 @@ function serializeBlock<
}
}
- const bc = BC_NODE.spec?.toDOM?.(
- BC_NODE.create({
- id: block.id,
- ...props,
- })
- ) as {
- dom: HTMLElement;
- contentDOM?: HTMLElement;
- };
-
const impl = editor.blockImplementations[block.type as any].implementation;
const ret = impl.toInternalHTML({ ...block, props } as any, editor as any);
@@ -115,6 +105,33 @@ function serializeBlock<
ret.contentDOM.appendChild(ic);
}
+ const pmType = editor.pmSchema.nodes[block.type as any];
+
+ if (pmType.isInGroup("bnBlock")) {
+ if (block.children && block.children.length > 0) {
+ const fragment = serializeBlocks(
+ editor,
+ block.children,
+ serializer,
+ options
+ );
+
+ ret.contentDOM?.append(fragment);
+ }
+ return ret.dom;
+ }
+
+ // wrap the block in a blockContainer
+ const bc = BC_NODE.spec?.toDOM?.(
+ BC_NODE.create({
+ id: block.id,
+ ...props,
+ })
+ ) as {
+ dom: HTMLElement;
+ contentDOM?: HTMLElement;
+ };
+
bc.contentDOM?.appendChild(ret.dom);
if (block.children && block.children.length > 0) {
@@ -125,7 +142,7 @@ function serializeBlock<
return bc.dom;
}
-export const serializeBlocksInternalHTML = <
+function serializeBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema
@@ -134,13 +151,9 @@ export const serializeBlocksInternalHTML = <
blocks: PartialBlock[],
serializer: DOMSerializer,
options?: { document?: Document }
-) => {
- const BG_NODE = editor.pmSchema.nodes["blockGroup"];
-
- const bg = BG_NODE.spec!.toDOM!(BG_NODE.create({})) as {
- dom: HTMLElement;
- contentDOM?: HTMLElement;
- };
+) {
+ const doc = options?.document ?? document;
+ const fragment = doc.createDocumentFragment();
let listIndex = 0;
for (const block of blocks) {
@@ -156,8 +169,32 @@ export const serializeBlocksInternalHTML = <
listIndex,
options
);
- bg.contentDOM!.appendChild(blockDOM);
+ fragment.appendChild(blockDOM);
}
+ return fragment;
+}
+
+export const serializeBlocksInternalHTML = <
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+ serializer: DOMSerializer,
+ options?: { document?: Document }
+) => {
+ const BG_NODE = editor.pmSchema.nodes["blockGroup"];
+
+ const bg = BG_NODE.spec!.toDOM!(BG_NODE.create({})) as {
+ dom: HTMLElement;
+ contentDOM?: HTMLElement;
+ };
+
+ const fragment = serializeBlocks(editor, blocks, serializer, options);
+
+ bg.contentDOM?.appendChild(fragment);
+
return bg.dom;
};
diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts
index e5f3562d28..7235725242 100644
--- a/packages/core/src/api/getBlockInfoFromPos.ts
+++ b/packages/core/src/api/getBlockInfoFromPos.ts
@@ -8,10 +8,41 @@ type SingleBlockInfo = {
};
export type BlockInfo = {
- blockContainer: SingleBlockInfo;
- blockContent: SingleBlockInfo;
- blockGroup?: SingleBlockInfo;
-};
+ /**
+ * The outer node that represents a BlockNote block. This is the node that has the ID.
+ * Most of the time, this will be a blockContainer node, but it could also be a Column or ColumnList
+ */
+ bnBlock: SingleBlockInfo;
+ /**
+ * The type of BlockNote block that this node represents.
+ * When dealing with a blockContainer, this is retrieved from the blockContent node, otherwise it's retrieved from the bnBlock node.
+ */
+ blockNoteType: string;
+} & (
+ | {
+ // In case we're not dealing with a BlockContainer, we're dealing with a "wrapper node" (like a Column or ColumnList), so it will always have children
+
+ /**
+ * The Prosemirror node that holds block.children. For non-blockContainer, this node will be the same as bnBlock.
+ */
+ childContainer: SingleBlockInfo;
+ isBlockContainer: false;
+ }
+ | {
+ /**
+ * The Prosemirror node that holds block.children. For blockContainers, this is the blockGroup node, if it exists.
+ */
+ childContainer?: SingleBlockInfo;
+ /**
+ * The Prosemirror node that wraps block.content and has most of the props
+ */
+ blockContent: SingleBlockInfo;
+ /**
+ * Whether bnBlock is a blockContainer node
+ */
+ isBlockContainer: true;
+ }
+);
/**
* Retrieves the position just before the nearest blockContainer node in a
@@ -30,7 +61,7 @@ export function getNearestBlockContainerPos(doc: Node, pos: number) {
// Checks if the position provided is already just before a blockContainer
// node, in which case we return the position.
- if ($pos.nodeAfter && $pos.nodeAfter.type.name === "blockContainer") {
+ if ($pos.nodeAfter && $pos.nodeAfter.type.isInGroup("bnBlock")) {
return {
posBeforeNode: $pos.pos,
node: $pos.nodeAfter,
@@ -42,7 +73,7 @@ export function getNearestBlockContainerPos(doc: Node, pos: number) {
let depth = $pos.depth;
let node = $pos.node(depth);
while (depth > 0) {
- if (node.type.name === "blockContainer") {
+ if (node.type.isInGroup("bnBlock")) {
return {
posBeforeNode: $pos.before(depth),
node: node,
@@ -62,7 +93,7 @@ export function getNearestBlockContainerPos(doc: Node, pos: number) {
// collaboration plugin.
const allBlockContainerPositions: number[] = [];
doc.descendants((node, pos) => {
- if (node.type.name === "blockContainer") {
+ if (node.type.isInGroup("bnBlock")) {
allBlockContainerPositions.push(pos);
}
});
@@ -93,57 +124,80 @@ export function getNearestBlockContainerPos(doc: Node, pos: number) {
*/
export function getBlockInfoWithManualOffset(
node: Node,
- blockContainerBeforePosOffset: number
+ bnBlockBeforePosOffset: number
): BlockInfo {
- const blockContainerNode = node;
- const blockContainerBeforePos = blockContainerBeforePosOffset;
- const blockContainerAfterPos =
- blockContainerBeforePos + blockContainerNode.nodeSize;
-
- const blockContainer: SingleBlockInfo = {
- node: blockContainerNode,
- beforePos: blockContainerBeforePos,
- afterPos: blockContainerAfterPos,
- };
- let blockContent: SingleBlockInfo | undefined = undefined;
- let blockGroup: SingleBlockInfo | undefined = undefined;
-
- blockContainerNode.forEach((node, offset) => {
- if (node.type.spec.group === "blockContent") {
- // console.log(beforePos, offset);
- const blockContentNode = node;
- const blockContentBeforePos = blockContainerBeforePos + offset + 1;
- const blockContentAfterPos = blockContentBeforePos + node.nodeSize;
-
- blockContent = {
- node: blockContentNode,
- beforePos: blockContentBeforePos,
- afterPos: blockContentAfterPos,
- };
- } else if (node.type.name === "blockGroup") {
- const blockGroupNode = node;
- const blockGroupBeforePos = blockContainerBeforePos + offset + 1;
- const blockGroupAfterPos = blockGroupBeforePos + node.nodeSize;
-
- blockGroup = {
- node: blockGroupNode,
- beforePos: blockGroupBeforePos,
- afterPos: blockGroupAfterPos,
- };
- }
- });
-
- if (!blockContent) {
+ if (!node.type.isInGroup("bnBlock")) {
throw new Error(
- `blockContainer node does not contain a blockContent node in its children: ${blockContainerNode}`
+ `Attempted to get bnBlock node at position but found node of different type ${node.type}`
);
}
- return {
- blockContainer,
- blockContent,
- blockGroup,
+ const bnBlockNode = node;
+ const bnBlockBeforePos = bnBlockBeforePosOffset;
+ const bnBlockAfterPos = bnBlockBeforePos + bnBlockNode.nodeSize;
+
+ const bnBlock: SingleBlockInfo = {
+ node: bnBlockNode,
+ beforePos: bnBlockBeforePos,
+ afterPos: bnBlockAfterPos,
};
+
+ if (bnBlockNode.type.name === "blockContainer") {
+ let blockContent: SingleBlockInfo | undefined;
+ let blockGroup: SingleBlockInfo | undefined;
+
+ bnBlockNode.forEach((node, offset) => {
+ if (node.type.spec.group === "blockContent") {
+ // console.log(beforePos, offset);
+ const blockContentNode = node;
+ const blockContentBeforePos = bnBlockBeforePos + offset + 1;
+ const blockContentAfterPos = blockContentBeforePos + node.nodeSize;
+
+ blockContent = {
+ node: blockContentNode,
+ beforePos: blockContentBeforePos,
+ afterPos: blockContentAfterPos,
+ };
+ } else if (node.type.name === "blockGroup") {
+ const blockGroupNode = node;
+ const blockGroupBeforePos = bnBlockBeforePos + offset + 1;
+ const blockGroupAfterPos = blockGroupBeforePos + node.nodeSize;
+
+ blockGroup = {
+ node: blockGroupNode,
+ beforePos: blockGroupBeforePos,
+ afterPos: blockGroupAfterPos,
+ };
+ }
+ });
+
+ if (!blockContent) {
+ throw new Error(
+ `blockContainer node does not contain a blockContent node in its children: ${bnBlockNode}`
+ );
+ }
+
+ return {
+ isBlockContainer: true,
+ bnBlock,
+ blockContent,
+ childContainer: blockGroup,
+ blockNoteType: blockContent.node.type.name,
+ };
+ } else {
+ if (!bnBlock.node.type.isInGroup("childContainer")) {
+ throw new Error(
+ `bnBlock node is not in the childContainer group: ${bnBlock.node}`
+ );
+ }
+
+ return {
+ isBlockContainer: false,
+ bnBlock: bnBlock,
+ childContainer: bnBlock,
+ blockNoteType: bnBlock.node.type.name,
+ };
+ }
}
/**
@@ -173,11 +227,6 @@ export function getBlockInfoFromResolvedPos(resolvedPos: ResolvedPos) {
`Attempted to get blockContainer node at position ${resolvedPos.pos} but a node at this position does not exist`
);
}
- if (resolvedPos.nodeAfter.type.name !== "blockContainer") {
- throw new Error(
- `Attempted to get blockContainer node at position ${resolvedPos.pos} but found node of different type ${resolvedPos.nodeAfter}`
- );
- }
return getBlockInfoWithManualOffset(resolvedPos.nodeAfter, resolvedPos.pos);
}
@@ -192,5 +241,11 @@ export function getBlockInfoFromSelection(state: EditorState) {
state.doc,
state.selection.anchor
);
- return getBlockInfo(posInfo);
+ const ret = getBlockInfo(posInfo);
+ if (!ret.isBlockContainer) {
+ throw new Error(
+ `selection always expected to return blockContainer ${state.selection.anchor}`
+ );
+ }
+ return ret;
}
diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts
index b4df390c16..cd5be3c539 100644
--- a/packages/core/src/api/nodeConversions/blockToNode.ts
+++ b/packages/core/src/api/nodeConversions/blockToNode.ts
@@ -54,7 +54,7 @@ function styledTextToNodes(
// Converts text & line breaks to nodes.
.map((text) => {
if (text === "\n") {
- return schema.nodes["hardBreak"].create();
+ return schema.nodes["hardBreak"].createChecked();
} else {
return schema.text(text, marks);
}
@@ -165,15 +165,18 @@ export function tableContentToNodes<
const cell = row.cells[i];
let pNode: Node;
if (!cell) {
- pNode = schema.nodes["tableParagraph"].create({});
+ pNode = schema.nodes["tableParagraph"].createChecked({});
} else if (typeof cell === "string") {
- pNode = schema.nodes["tableParagraph"].create({}, schema.text(cell));
+ pNode = schema.nodes["tableParagraph"].createChecked(
+ {},
+ schema.text(cell)
+ );
} else {
const textNodes = inlineContentToNodes(cell, schema, styleSchema);
- pNode = schema.nodes["tableParagraph"].create({}, textNodes);
+ pNode = schema.nodes["tableParagraph"].createChecked({}, textNodes);
}
- const cellNode = schema.nodes["tableCell"].create(
+ const cellNode = schema.nodes["tableCell"].createChecked(
{
// The colwidth array should have multiple values when the colspan of
// a cell is greater than 1. However, this is not yet implemented so
@@ -186,7 +189,7 @@ export function tableContentToNodes<
);
columnNodes.push(cellNode);
}
- const rowNode = schema.nodes["tableRow"].create({}, columnNodes);
+ const rowNode = schema.nodes["tableRow"].createChecked({}, columnNodes);
rowNodes.push(rowNode);
}
return rowNodes;
@@ -212,16 +215,16 @@ function blockOrInlineContentToContentNode(
}
if (!block.content) {
- contentNode = schema.nodes[type].create(block.props);
+ contentNode = schema.nodes[type].createChecked(block.props);
} else if (typeof block.content === "string") {
const nodes = inlineContentToNodes([block.content], schema, styleSchema);
- contentNode = schema.nodes[type].create(block.props, nodes);
+ contentNode = schema.nodes[type].createChecked(block.props, nodes);
} else if (Array.isArray(block.content)) {
const nodes = inlineContentToNodes(block.content, schema, styleSchema);
- contentNode = schema.nodes[type].create(block.props, nodes);
+ contentNode = schema.nodes[type].createChecked(block.props, nodes);
} else if (block.content.type === "tableContent") {
const nodes = tableContentToNodes(block.content, schema, styleSchema);
- contentNode = schema.nodes[type].create(block.props, nodes);
+ contentNode = schema.nodes[type].createChecked(block.props, nodes);
} else {
throw new UnreachableCaseError(block.content.type);
}
@@ -229,7 +232,7 @@ function blockOrInlineContentToContentNode(
}
/**
- * Converts a BlockNote block to a TipTap node.
+ * Converts a BlockNote block to a Prosemirror node.
*/
export function blockToNode(
block: PartialBlock,
@@ -242,12 +245,6 @@ export function blockToNode(
id = UniqueID.options.generateID();
}
- const contentNode = blockOrInlineContentToContentNode(
- block,
- schema,
- styleSchema
- );
-
const children: Node[] = [];
if (block.children) {
@@ -256,13 +253,41 @@ export function blockToNode(
}
}
- const groupNode = schema.nodes["blockGroup"].create({}, children);
+ const nodeTypeCorrespondingToBlock = schema.nodes[block.type];
- return schema.nodes["blockContainer"].create(
- {
- id: id,
- ...block.props,
- },
- children.length > 0 ? [contentNode, groupNode] : contentNode
- );
+ if (nodeTypeCorrespondingToBlock.isInGroup("blockContent")) {
+ // Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer
+
+ const contentNode = blockOrInlineContentToContentNode(
+ block,
+ schema,
+ styleSchema
+ );
+
+ const groupNode =
+ children.length > 0
+ ? schema.nodes["blockGroup"].createChecked({}, children)
+ : undefined;
+
+ return schema.nodes["blockContainer"].createChecked(
+ {
+ id: id,
+ ...block.props,
+ },
+ groupNode ? [contentNode, groupNode] : contentNode
+ );
+ } else if (nodeTypeCorrespondingToBlock.isInGroup("bnBlock")) {
+ // this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node
+ return schema.nodes[block.type].createChecked(
+ {
+ id: id,
+ ...block.props,
+ },
+ children
+ );
+ } 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 8b0cd21030..dd9741872f 100644
--- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
+++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
@@ -43,6 +43,24 @@ export function fragmentToBlocks<
// 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,
+ schema.blockSchema,
+ schema.inlineContentSchema,
+ schema.styleSchema
+ )
+ );
+ });
+ return false;
+ }
+
+ if (node.type.isInGroup("bnBlock")) {
blocks.push(
nodeToBlock(
node,
diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts
index 79889cb546..73198c1927 100644
--- a/packages/core/src/api/nodeConversions/nodeToBlock.ts
+++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts
@@ -306,7 +306,9 @@ export function nodeToCustomInlineContent<
}
/**
- * Convert a TipTap node to a BlockNote block.
+ * Convert a Prosemirror node to a BlockNote block.
+ *
+ * TODO: test changes
*/
export function nodeToBlock<
BSchema extends BlockSchema,
@@ -319,11 +321,9 @@ export function nodeToBlock<
styleSchema: S,
blockCache?: WeakMap>
): Block {
- if (node.type.name !== "blockContainer") {
+ if (!node.type.isInGroup("bnBlock")) {
throw Error(
- "Node must be of type blockContainer, but is of type" +
- node.type.name +
- "."
+ "Node must be in bnBlock group, but is of type" + node.type.name
);
}
@@ -333,29 +333,26 @@ export function nodeToBlock<
return cachedBlock;
}
- const { blockContainer, blockContent, blockGroup } =
- getBlockInfoWithManualOffset(node, 0);
+ const blockInfo = getBlockInfoWithManualOffset(node, 0);
- let id = blockContainer.node.attrs.id;
+ let id = blockInfo.bnBlock.node.attrs.id;
// Only used for blocks converted from other formats.
if (id === null) {
id = UniqueID.options.generateID();
}
+ const blockSpec = blockSchema[blockInfo.blockNoteType];
+
+ if (!blockSpec) {
+ throw Error("Block is of an unrecognized type: " + blockInfo.blockNoteType);
+ }
+
const props: any = {};
for (const [attr, value] of Object.entries({
...node.attrs,
- ...blockContent.node.attrs,
+ ...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}),
})) {
- const blockSpec = blockSchema[blockContent.node.type.name];
-
- if (!blockSpec) {
- throw Error(
- "Block is of an unrecognized type: " + blockContent.node.type.name
- );
- }
-
const propSchema = blockSpec.propSchema;
if (attr in propSchema) {
@@ -363,10 +360,10 @@ export function nodeToBlock<
}
}
- const blockConfig = blockSchema[blockContent.node.type.name];
+ const blockConfig = blockSchema[blockInfo.blockNoteType];
const children: Block[] = [];
- blockGroup?.node.forEach((child) => {
+ blockInfo.childContainer?.node.forEach((child) => {
children.push(
nodeToBlock(
child,
@@ -381,14 +378,20 @@ export function nodeToBlock<
let content: Block["content"];
if (blockConfig.content === "inline") {
+ if (!blockInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
content = contentNodeToInlineContent(
- blockContent.node,
+ blockInfo.blockContent.node,
inlineContentSchema,
styleSchema
);
} else if (blockConfig.content === "table") {
+ if (!blockInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
content = contentNodeToTableContent(
- blockContent.node,
+ blockInfo.blockContent.node,
inlineContentSchema,
styleSchema
);
diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts
index 1dcc5ac79d..530d96cd5f 100644
--- a/packages/core/src/api/nodeUtil.ts
+++ b/packages/core/src/api/nodeUtil.ts
@@ -17,7 +17,7 @@ export function getNodeById(
}
// Keeps traversing nodes if block with target ID has not been found.
- if (node.type.name !== "blockContainer" || node.attrs.id !== id) {
+ if (!node.type.isInGroup("bnBlock") || node.attrs.id !== id) {
return true;
}
diff --git a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts
index 96eb8a004d..f65ed00435 100644
--- a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts
+++ b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts
@@ -56,7 +56,7 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
.command(
updateBlockCommand(
this.options.editor,
- blockInfo.blockContainer.beforePos,
+ blockInfo.bnBlock.beforePos,
{
type: "heading",
props: {
@@ -84,16 +84,12 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
// call updateBlockCommand
return this.editor.commands.command(
- updateBlockCommand(
- this.options.editor,
- blockInfo.blockContainer.beforePos,
- {
- type: "heading",
- props: {
- level: 1 as any,
- },
- }
- )
+ updateBlockCommand(this.options.editor, blockInfo.bnBlock.beforePos, {
+ type: "heading",
+ props: {
+ level: 1 as any,
+ },
+ })
);
},
"Mod-Alt-2": () => {
@@ -103,16 +99,12 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
}
return this.editor.commands.command(
- updateBlockCommand(
- this.options.editor,
- blockInfo.blockContainer.beforePos,
- {
- type: "heading",
- props: {
- level: 2 as any,
- },
- }
- )
+ updateBlockCommand(this.options.editor, blockInfo.bnBlock.beforePos, {
+ type: "heading",
+ props: {
+ level: 2 as any,
+ },
+ })
);
},
"Mod-Alt-3": () => {
@@ -122,16 +114,12 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({
}
return this.editor.commands.command(
- updateBlockCommand(
- this.options.editor,
- blockInfo.blockContainer.beforePos,
- {
- type: "heading",
- props: {
- level: 3 as any,
- },
- }
- )
+ updateBlockCommand(this.options.editor, blockInfo.bnBlock.beforePos, {
+ type: "heading",
+ props: {
+ level: 3 as any,
+ },
+ })
);
},
};
diff --git a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
index 3f19c403ac..09f6088e08 100644
--- a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts
@@ -36,7 +36,7 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({
.command(
updateBlockCommand(
this.options.editor,
- blockInfo.blockContainer.beforePos,
+ blockInfo.bnBlock.beforePos,
{
type: "bulletListItem",
props: {},
@@ -60,14 +60,10 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({
}
return this.options.editor.commands.command(
- updateBlockCommand(
- this.options.editor,
- blockInfo.blockContainer.beforePos,
- {
- type: "bulletListItem",
- props: {},
- }
- )
+ updateBlockCommand(this.options.editor, blockInfo.bnBlock.beforePos, {
+ type: "bulletListItem",
+ props: {},
+ })
);
},
};
diff --git a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts
index d6df128556..e2769d3eeb 100644
--- a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts
@@ -57,7 +57,7 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
.command(
updateBlockCommand(
this.options.editor,
- blockInfo.blockContainer.beforePos,
+ blockInfo.bnBlock.beforePos,
{
type: "checkListItem",
props: {
@@ -83,7 +83,7 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
.command(
updateBlockCommand(
this.options.editor,
- blockInfo.blockContainer.beforePos,
+ blockInfo.bnBlock.beforePos,
{
type: "checkListItem",
props: {
@@ -109,14 +109,10 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({
}
return this.editor.commands.command(
- updateBlockCommand(
- this.options.editor,
- blockInfo.blockContainer.beforePos,
- {
- type: "checkListItem",
- props: {},
- }
- )
+ updateBlockCommand(this.options.editor, blockInfo.bnBlock.beforePos, {
+ type: "checkListItem",
+ props: {},
+ })
);
},
};
diff --git a/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
index a9ffa5d684..9ce247e3fa 100644
--- a/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts
@@ -5,7 +5,7 @@ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
export const handleEnter = (editor: BlockNoteEditor) => {
const ttEditor = editor._tiptapEditor;
- const { blockContent, blockContainer } = getBlockInfoFromSelection(
+ const { blockContent, bnBlock: blockContainer } = getBlockInfoFromSelection(
ttEditor.state
);
diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
index ae77be402b..c7f431b2f7 100644
--- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListIndexingPlugin.ts
@@ -27,24 +27,30 @@ export const NumberedListIndexingPlugin = () => {
node,
});
+ if (!blockInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
+
// Checks if this block is the start of a new ordered list, i.e. if it's the first block in the document, the
// first block in its nesting level, or the previous block is not an ordered list item.
const prevBlock = tr.doc.resolve(
- blockInfo.blockContainer.beforePos
+ blockInfo.bnBlock.beforePos
).nodeBefore;
if (prevBlock) {
const prevBlockInfo = getBlockInfo({
- posBeforeNode:
- blockInfo.blockContainer.beforePos - prevBlock.nodeSize,
+ posBeforeNode: blockInfo.bnBlock.beforePos - prevBlock.nodeSize,
node: prevBlock,
});
const isPrevBlockOrderedListItem =
- prevBlockInfo.blockContent.node.type.name === "numberedListItem";
+ prevBlockInfo.blockNoteType === "numberedListItem";
if (isPrevBlockOrderedListItem) {
+ if (!prevBlockInfo.isBlockContainer) {
+ throw new Error("impossible");
+ }
const prevBlockIndex =
prevBlockInfo.blockContent.node.attrs["index"];
diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
index 8c4dc5b16b..123bbc0cd1 100644
--- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
+++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts
@@ -49,7 +49,7 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
.command(
updateBlockCommand(
this.options.editor,
- blockInfo.blockContainer.beforePos,
+ blockInfo.bnBlock.beforePos,
{
type: "numberedListItem",
props: {},
@@ -73,14 +73,10 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({
}
return this.editor.commands.command(
- updateBlockCommand(
- this.options.editor,
- blockInfo.blockContainer.beforePos,
- {
- type: "numberedListItem",
- props: {},
- }
- )
+ updateBlockCommand(this.options.editor, blockInfo.bnBlock.beforePos, {
+ type: "numberedListItem",
+ props: {},
+ })
);
},
};
diff --git a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts
index a089cb3258..817148f76a 100644
--- a/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts
+++ b/packages/core/src/blocks/ParagraphBlockContent/ParagraphBlockContent.ts
@@ -25,14 +25,10 @@ export const ParagraphBlockContent = createStronglyTypedTiptapNode({
}
return this.editor.commands.command(
- updateBlockCommand(
- this.options.editor,
- blockInfo.blockContainer.beforePos,
- {
- type: "paragraph",
- props: {},
- }
- )
+ updateBlockCommand(this.options.editor, blockInfo.bnBlock.beforePos, {
+ type: "paragraph",
+ props: {},
+ })
);
},
};
diff --git a/packages/core/src/blocks/defaultBlockHelpers.ts b/packages/core/src/blocks/defaultBlockHelpers.ts
index 4e758bd16b..8593e5246d 100644
--- a/packages/core/src/blocks/defaultBlockHelpers.ts
+++ b/packages/core/src/blocks/defaultBlockHelpers.ts
@@ -67,8 +67,13 @@ export const defaultBlockToHTML = <
dom: HTMLElement;
contentDOM?: HTMLElement;
} => {
- const node = blockToNode(block, editor.pmSchema, editor.schema.styleSchema)
- .firstChild!;
+ let node = blockToNode(block, editor.pmSchema, editor.schema.styleSchema);
+
+ if (node.type.name === "blockContainer") {
+ // for regular blocks, get the toDOM spec from the blockContent node
+ node = node.firstChild!;
+ }
+
const toDOM = editor.pmSchema.nodes[node.type.name].spec.toDOM;
if (toDOM === undefined) {
diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css
index 40cf64cd14..49adf9b533 100644
--- a/packages/core/src/editor/Block.css
+++ b/packages/core/src/editor/Block.css
@@ -403,6 +403,7 @@ NESTED BLOCKS
[data-file-block] .bn-file-caption {
font-size: 0.8em;
padding-block: 4px;
+ word-break: break-word;
}
[data-file-block] .bn-file-caption:empty {
@@ -515,3 +516,23 @@ NESTED BLOCKS
justify-content: flex-start;
text-align: justify;
}
+
+.bn-block-column-list {
+ display: flex;
+ flex-direction: row;
+}
+
+.bn-block-column {
+ flex: 1;
+ padding: 12px 20px;
+ /* scroll if we overflow, for example when tables or images are in the column */
+ overflow-x: auto;
+}
+
+.bn-block-column:first-child {
+ padding-left: 0;
+}
+
+.bn-block-column:last-child {
+ padding-right: 0;
+}
diff --git a/packages/core/src/editor/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts
index f48abd9b4b..8cfa9b2e44 100644
--- a/packages/core/src/editor/BlockNoteEditor.test.ts
+++ b/packages/core/src/editor/BlockNoteEditor.test.ts
@@ -14,8 +14,8 @@ it("creates an editor", () => {
editor._tiptapEditor.state.doc,
2
);
- const { blockContent } = getBlockInfo(posInfo);
- expect(blockContent.node.type.name).toEqual("paragraph");
+ const info = getBlockInfo(posInfo);
+ expect(info.blockNoteType).toEqual("paragraph");
});
it("immediately replaces doc", async () => {
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index e93111b215..d316528aef 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -7,6 +7,12 @@ import {
moveBlockDown,
moveBlockUp,
} from "../api/blockManipulation/commands/moveBlock/moveBlock.js";
+import {
+ canNestBlock,
+ canUnnestBlock,
+ nestBlock,
+ unnestBlock,
+} from "../api/blockManipulation/commands/nestBlock/nestBlock.js";
import { removeBlocks } from "../api/blockManipulation/commands/removeBlocks/removeBlocks.js";
import { replaceBlocks } from "../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js";
import { updateBlock } from "../api/blockManipulation/commands/updateBlock/updateBlock.js";
@@ -17,7 +23,6 @@ import {
} from "../api/blockManipulation/selections/textCursorPosition/textCursorPosition.js";
import { createExternalHTMLExporter } from "../api/exporters/html/externalHTMLExporter.js";
import { blocksToMarkdown } from "../api/exporters/markdown/markdownExporter.js";
-import { getBlockInfoFromSelection } from "../api/getBlockInfoFromPos.js";
import { HTMLToBlocks } from "../api/parsers/html/parseHTML.js";
import { markdownToBlocks } from "../api/parsers/markdown/parseMarkdown.js";
import {
@@ -66,7 +71,8 @@ import { PlaceholderPlugin } from "../extensions/Placeholder/PlaceholderPlugin.j
import { Dictionary } from "../i18n/dictionary.js";
import { en } from "../i18n/locales/index.js";
-import { Transaction } from "@tiptap/pm/state";
+import { Plugin, Transaction } from "@tiptap/pm/state";
+import { dropCursor } from "prosemirror-dropcursor";
import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js";
import { inlineContentToNodes } from "../api/nodeConversions/blockToNode.js";
import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js";
@@ -90,7 +96,7 @@ export type BlockNoteEditorOptions<
/**
* A dictionary object containing translations for the editor.
*/
- dictionary?: Dictionary;
+ dictionary?: Dictionary & Record;
/**
* @deprecated, provide placeholders via dictionary instead
@@ -190,6 +196,8 @@ export type BlockNoteEditorOptions<
* (note that the id is always set on the `data-id` attribute)
*/
setIdAttribute?: boolean;
+
+ dropCursor?: (opts: any) => Plugin;
};
const blockNoteTipTapOptions = {
@@ -236,7 +244,7 @@ export class BlockNoteEditor<
/**
* The dictionary contains translations for the editor.
*/
- public readonly dictionary: Dictionary;
+ public readonly dictionary: Dictionary & Record;
/**
* The schema of the editor. The schema defines which Blocks, InlineContent, and Styles are available in the editor.
@@ -369,6 +377,7 @@ export class BlockNoteEditor<
setIdAttribute: newOptions.setIdAttribute,
});
+ const dropCursorPlugin: any = this.options.dropCursor ?? dropCursor;
const blockNoteUIExtension = Extension.create({
name: "BlockNoteUIExtension",
@@ -380,6 +389,7 @@ export class BlockNoteEditor<
this.suggestionMenus.plugin,
...(this.filePanel ? [this.filePanel.plugin] : []),
...(this.tableHandles ? [this.tableHandles.plugin] : []),
+ dropCursorPlugin({ width: 5, color: "#ddeeff", editor: this }),
PlaceholderPlugin(this, newOptions.placeholders),
NodeSelectionKeyboardPlugin(),
...(this.options.animations ?? true
@@ -962,41 +972,28 @@ export class BlockNoteEditor<
* Checks if the block containing the text cursor can be nested.
*/
public canNestBlock() {
- const { blockContainer } = getBlockInfoFromSelection(
- this._tiptapEditor.state
- );
-
- return (
- this._tiptapEditor.state.doc.resolve(blockContainer.beforePos)
- .nodeBefore !== null
- );
+ return canNestBlock(this);
}
/**
* Nests the block containing the text cursor into the block above it.
*/
public nestBlock() {
- this._tiptapEditor.commands.sinkListItem("blockContainer");
+ nestBlock(this);
}
/**
* Checks if the block containing the text cursor is nested.
*/
public canUnnestBlock() {
- const { blockContainer } = getBlockInfoFromSelection(
- this._tiptapEditor.state
- );
-
- return (
- this._tiptapEditor.state.doc.resolve(blockContainer.beforePos).depth > 1
- );
+ return canUnnestBlock(this);
}
/**
* Lifts the block containing the text cursor out of its parent.
*/
public unnestBlock() {
- this._tiptapEditor.commands.liftListItem("blockContainer");
+ unnestBlock(this);
}
/**
diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts
index 333fc8fd30..3c7bd94720 100644
--- a/packages/core/src/editor/BlockNoteExtensions.ts
+++ b/packages/core/src/editor/BlockNoteExtensions.ts
@@ -4,7 +4,6 @@ import type { BlockNoteEditor } from "./BlockNoteEditor.js";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
-import { Dropcursor } from "@tiptap/extension-dropcursor";
import { Gapcursor } from "@tiptap/extension-gapcursor";
import { HardBreak } from "@tiptap/extension-hard-break";
import { History } from "@tiptap/extension-history";
@@ -70,7 +69,8 @@ export const getBlockNoteExtensions = <
// DropCursor,
UniqueID.configure({
- types: ["blockContainer"],
+ // everything from bnBlock group (nodes that represent a BlockNote block should have an id)
+ types: ["blockContainer", "columnList", "column"],
setIdAttribute: opts.setIdAttribute,
}),
HardBreak.extend({ priority: 10 }),
@@ -155,7 +155,6 @@ export const getBlockNoteExtensions = <
createPasteFromClipboardExtension(opts.editor),
createDropFileExtension(opts.editor),
- Dropcursor.configure({ width: 5, color: "#ddeeff" }),
// This needs to be at the bottom of this list, because Key events (such as enter, when selecting a /command),
// should be handled before Enter handlers in other components like splitListItem
...(opts.trailingBlock === undefined || opts.trailingBlock
diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css
index 9ee98f1dcd..a9607ea7ca 100644
--- a/packages/core/src/editor/editor.css
+++ b/packages/core/src/editor/editor.css
@@ -151,3 +151,13 @@ Tippy popups that are appended to document.body directly
/* if there's no explicit width set and the column is not being resized, set a default width */
min-width: var(--default-cell-min-width) !important;
}
+
+.prosemirror-dropcursor-block {
+ transition-property: top, bottom;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 0.15s;
+}
+
+.prosemirror-dropcursor-vertical {
+ transition-property: left, right;
+}
diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts
index 334a1aab8e..b5225d6688 100644
--- a/packages/core/src/editor/transformPasted.ts
+++ b/packages/core/src/editor/transformPasted.ts
@@ -32,7 +32,10 @@ export function wrapTableRows(f: Fragment, schema: Schema) {
newItems[newItems.length - 1] = newTable;
} else {
// create new table to wrap tableRow with
- const newTable = schema.nodes.table.create(undefined, f.child(i));
+ const newTable = schema.nodes.table.createChecked(
+ undefined,
+ f.child(i)
+ );
newItems.push(newTable);
}
} else {
@@ -64,7 +67,7 @@ export function transformPasted(slice: Slice, view: EditorView) {
// (if we remove this if-block, the nesting bug will be fixed, but lists won't be nested correctly)
if (
i + 1 < f.childCount &&
- f.child(i + 1).type.spec.group === "blockGroup"
+ f.child(i + 1).type.name === "blockGroup" // TODO
) {
const nestedChild = f
.child(i + 1)
@@ -80,7 +83,7 @@ export function transformPasted(slice: Slice, view: EditorView) {
f = removeChild(f, i + 1);
}
}
- const container = view.state.schema.nodes.blockContainer.create(
+ const container = view.state.schema.nodes.blockContainer.createChecked(
undefined,
content
);
diff --git a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
index cc2b3495c3..5ea58c2420 100644
--- a/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
+++ b/packages/core/src/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
@@ -1,16 +1,17 @@
import { Extension } from "@tiptap/core";
import { TextSelection } from "prosemirror-state";
+import { ReplaceAroundStep } from "prosemirror-transform";
import {
- getPrevBlockPos,
+ getBottomNestedBlockInfo,
+ getParentBlockInfo,
+ getPrevBlockInfo,
mergeBlocksCommand,
} from "../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js";
+import { nestBlock } from "../../api/blockManipulation/commands/nestBlock/nestBlock.js";
import { splitBlockCommand } from "../../api/blockManipulation/commands/splitBlock/splitBlock.js";
import { updateBlockCommand } from "../../api/blockManipulation/commands/updateBlock/updateBlock.js";
-import {
- getBlockInfoFromResolvedPos,
- getBlockInfoFromSelection,
-} from "../../api/getBlockInfoFromPos.js";
+import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
export const KeyboardShortcutsExtension = Extension.create<{
@@ -42,7 +43,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
return commands.command(
updateBlockCommand(
this.options.editor,
- blockInfo.blockContainer.beforePos,
+ blockInfo.bnBlock.beforePos,
{
type: "paragraph",
props: {},
@@ -67,29 +68,20 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // Merges block with the previous one if it isn't indented, isn't the
- // first block in the doc, and the selection is at the start of the
+ // Merges block with the previous one if it isn't indented, and the selection is at the start of the
// block. The target block for merging must contain inline content.
() =>
commands.command(({ state }) => {
- const { blockContainer, blockContent } =
+ const { bnBlock: blockContainer, blockContent } =
getBlockInfoFromSelection(state);
- const { depth } = state.doc.resolve(blockContainer.beforePos);
-
const selectionAtBlockStart =
state.selection.from === blockContent.beforePos + 1;
const selectionEmpty = state.selection.empty;
- const blockAtDocStart = blockContainer.beforePos === 1;
const posBetweenBlocks = blockContainer.beforePos;
- if (
- !blockAtDocStart &&
- selectionAtBlockStart &&
- selectionEmpty &&
- depth === 1
- ) {
+ if (selectionAtBlockStart && selectionEmpty) {
return chain()
.command(mergeBlocksCommand(posBetweenBlocks))
.scrollIntoView()
@@ -98,6 +90,162 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ () =>
+ commands.command(({ state, dispatch }) => {
+ // when at the start of a first block in a column
+ const blockInfo = getBlockInfoFromSelection(state);
+
+ const selectionAtBlockStart =
+ state.selection.from === blockInfo.blockContent.beforePos + 1;
+
+ if (!selectionAtBlockStart) {
+ return false;
+ }
+
+ const prevBlockInfo = getPrevBlockInfo(
+ state.doc,
+ blockInfo.bnBlock.beforePos
+ );
+
+ if (prevBlockInfo) {
+ // should be no previous block
+ return false;
+ }
+
+ const parentBlockInfo = getParentBlockInfo(
+ state.doc,
+ blockInfo.bnBlock.beforePos
+ );
+
+ if (parentBlockInfo?.blockNoteType !== "column") {
+ return false;
+ }
+
+ const column = parentBlockInfo;
+
+ const columnList = getParentBlockInfo(
+ state.doc,
+ column.bnBlock.beforePos
+ );
+ if (columnList?.blockNoteType !== "columnList") {
+ throw new Error("parent of column is not a column list");
+ }
+
+ const shouldRemoveColumn =
+ column.childContainer!.node.childCount === 1;
+
+ const shouldRemoveColumnList =
+ shouldRemoveColumn &&
+ columnList.childContainer!.node.childCount === 2;
+
+ const isFirstColumn =
+ columnList.childContainer!.node.firstChild ===
+ column.bnBlock.node;
+
+ if (dispatch) {
+ const blockToMove = state.doc.slice(
+ blockInfo.bnBlock.beforePos,
+ blockInfo.bnBlock.afterPos,
+ false
+ );
+
+ /*
+ There are 3 different cases:
+ a) remove entire column list (if no columns would be remaining)
+ b) remove just a column (if no blocks inside a column would be remaining)
+ c) keep columns (if there are blocks remaining inside a column)
+
+ Each of these 3 cases has 2 sub-cases, depending on whether the backspace happens at the start of the first (most-left) column,
+ or at the start of a non-first column.
+ */
+ if (shouldRemoveColumnList) {
+ if (isFirstColumn) {
+ state.tr.step(
+ new ReplaceAroundStep(
+ // replace entire column list
+ columnList.bnBlock.beforePos,
+ columnList.bnBlock.afterPos,
+ // select content of remaining column:
+ column.bnBlock.afterPos + 1,
+ columnList.bnBlock.afterPos - 2,
+ blockToMove,
+ blockToMove.size, // append existing content to blockToMove
+ false
+ )
+ );
+ const pos = state.tr.doc.resolve(column.bnBlock.beforePos);
+ state.tr.setSelection(TextSelection.between(pos, pos));
+ } else {
+ // replaces the column list with the blockToMove slice, prepended with the content of the remaining column
+ state.tr.step(
+ new ReplaceAroundStep(
+ // replace entire column list
+ columnList.bnBlock.beforePos,
+ columnList.bnBlock.afterPos,
+ // select content of existing column:
+ columnList.bnBlock.beforePos + 2,
+ column.bnBlock.beforePos - 1,
+ blockToMove,
+ 0, // prepend existing content to blockToMove
+ false
+ )
+ );
+ const pos = state.tr.doc.resolve(
+ state.tr.mapping.map(column.bnBlock.beforePos - 1)
+ );
+ state.tr.setSelection(TextSelection.between(pos, pos));
+ }
+ } else if (shouldRemoveColumn) {
+ if (isFirstColumn) {
+ // delete column
+ state.tr.delete(
+ column.bnBlock.beforePos,
+ column.bnBlock.afterPos
+ );
+
+ // move before columnlist
+ state.tr.insert(
+ columnList.bnBlock.beforePos,
+ blockToMove.content
+ );
+
+ const pos = state.tr.doc.resolve(
+ columnList.bnBlock.beforePos
+ );
+ state.tr.setSelection(TextSelection.between(pos, pos));
+ } else {
+ // just delete the closing and opening tags to merge the columns
+ state.tr.delete(
+ column.bnBlock.beforePos - 1,
+ column.bnBlock.beforePos + 1
+ );
+ }
+ } else {
+ // delete block
+ state.tr.delete(
+ blockInfo.bnBlock.beforePos,
+ blockInfo.bnBlock.afterPos
+ );
+ if (isFirstColumn) {
+ // move before columnlist
+ state.tr.insert(
+ columnList.bnBlock.beforePos - 1,
+ blockToMove.content
+ );
+ } else {
+ // append block to previous column
+ state.tr.insert(
+ column.bnBlock.beforePos - 1,
+ blockToMove.content
+ );
+ }
+ const pos = state.tr.doc.resolve(column.bnBlock.beforePos - 1);
+ state.tr.setSelection(TextSelection.between(pos, pos));
+ }
+ }
+
+ return true;
+ }),
// Deletes previous block if it contains no content and isn't a table,
// when the selection is empty and at the start of the block. Moves the
// current block into the deleted block's place.
@@ -105,47 +253,49 @@ export const KeyboardShortcutsExtension = Extension.create<{
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- const { depth } = state.doc.resolve(
- blockInfo.blockContainer.beforePos
- );
+ if (!blockInfo.isBlockContainer) {
+ // TODO
+ throw new Error(`todo`);
+ }
const selectionAtBlockStart =
state.selection.from === blockInfo.blockContent.beforePos + 1;
const selectionEmpty = state.selection.empty;
- const blockAtDocStart = blockInfo.blockContainer.beforePos === 1;
- if (
- !blockAtDocStart &&
- selectionAtBlockStart &&
- selectionEmpty &&
- depth === 1
- ) {
- const prevBlockPos = getPrevBlockPos(
+ const prevBlockInfo = getPrevBlockInfo(
+ state.doc,
+ blockInfo.bnBlock.beforePos
+ );
+
+ if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) {
+ const bottomBlock = getBottomNestedBlockInfo(
state.doc,
- state.doc.resolve(blockInfo.blockContainer.beforePos)
- );
- const prevBlockInfo = getBlockInfoFromResolvedPos(
- state.doc.resolve(prevBlockPos.pos)
+ prevBlockInfo
);
+ if (!bottomBlock.isBlockContainer) {
+ // TODO
+ throw new Error(`todo`);
+ }
+
const prevBlockNotTableAndNoContent =
- prevBlockInfo.blockContent.node.type.spec.content === "" ||
- (prevBlockInfo.blockContent.node.type.spec.content ===
+ bottomBlock.blockContent.node.type.spec.content === "" ||
+ (bottomBlock.blockContent.node.type.spec.content ===
"inline*" &&
- prevBlockInfo.blockContent.node.childCount === 0);
+ bottomBlock.blockContent.node.childCount === 0);
if (prevBlockNotTableAndNoContent) {
return chain()
.cut(
{
- from: blockInfo.blockContainer.beforePos,
- to: blockInfo.blockContainer.afterPos,
+ from: blockInfo.bnBlock.beforePos,
+ to: blockInfo.bnBlock.afterPos,
},
- prevBlockInfo.blockContainer.afterPos
+ bottomBlock.bnBlock.afterPos
)
.deleteRange({
- from: prevBlockInfo.blockContainer.beforePos,
- to: prevBlockInfo.blockContainer.afterPos,
+ from: bottomBlock.bnBlock.beforePos,
+ to: bottomBlock.bnBlock.afterPos,
})
.run();
}
@@ -165,8 +315,11 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state }) => {
// TODO: Change this to not rely on offsets & schema assumptions
- const { blockContainer, blockContent, blockGroup } =
- getBlockInfoFromSelection(state);
+ const {
+ bnBlock: blockContainer,
+ blockContent,
+ childContainer,
+ } = getBlockInfoFromSelection(state);
const { depth } = state.doc.resolve(blockContainer.beforePos);
const blockAtDocEnd =
@@ -174,7 +327,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
const selectionAtBlockEnd =
state.selection.from === blockContent.afterPos - 1;
const selectionEmpty = state.selection.empty;
- const hasChildBlocks = blockGroup !== undefined;
+ const hasChildBlocks = childContainer !== undefined;
if (
!blockAtDocEnd &&
@@ -205,7 +358,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
// of the block.
() =>
commands.command(({ state }) => {
- const { blockContent, blockContainer } =
+ const { blockContent, bnBlock: blockContainer } =
getBlockInfoFromSelection(state);
const { depth } = state.doc.resolve(blockContainer.beforePos);
@@ -232,7 +385,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
// empty & at the start of the block.
() =>
commands.command(({ state, dispatch }) => {
- const { blockContainer, blockContent } =
+ const { bnBlock: blockContainer, blockContent } =
getBlockInfoFromSelection(state);
const selectionAtBlockStart =
@@ -306,8 +459,8 @@ export const KeyboardShortcutsExtension = Extension.create<{
// don't handle tabs if a toolbar is shown, so we can tab into / out of it
return false;
}
- this.editor.commands.sinkListItem("blockContainer");
- return true;
+ return nestBlock(this.options.editor);
+ // return true;
},
"Shift-Tab": () => {
if (
diff --git a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts
index f50d265607..5397bff2d2 100644
--- a/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts
+++ b/packages/core/src/extensions/NodeSelectionKeyboard/NodeSelectionKeyboardPlugin.ts
@@ -45,7 +45,7 @@ export const NodeSelectionKeyboardPlugin = () => {
tr
.insert(
view.state.tr.selection.$to.after(),
- view.state.schema.nodes["paragraph"].create()
+ view.state.schema.nodes["paragraph"].createChecked()
)
.setSelection(
new TextSelection(
diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
index aa6bd9d50c..8af20491fe 100644
--- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts
@@ -12,12 +12,8 @@ import {
} from "../../schema/index.js";
import { EventEmitter } from "../../util/EventEmitter.js";
import { initializeESMDependencies } from "../../util/esmDependencies.js";
-import {
- dragStart,
- getDraggableBlockFromElement,
- unsetDragImage,
-} from "./dragging.js";
-
+import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
+import { dragStart, unsetDragImage } from "./dragging.js";
export type SideMenuState<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -27,13 +23,45 @@ export type SideMenuState<
block: Block;
};
-const getBlockFromMousePos = (
+const PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP = 0.1;
+
+function getBlockFromCoords(
+ view: EditorView,
+ coords: { left: number; top: number },
+ adjustForColumns = true
+) {
+ const elements = view.root.elementsFromPoint(coords.left, coords.top);
+
+ for (const element of elements) {
+ if (!view.dom.contains(element)) {
+ // probably a ui overlay like formatting toolbar etc
+ continue;
+ }
+ if (adjustForColumns) {
+ const column = element.closest("[data-node-type=columnList]");
+ if (column) {
+ return getBlockFromCoords(
+ view,
+ {
+ 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);
+ }
+ return undefined;
+}
+
+function getBlockFromMousePos(
mousePos: {
x: number;
y: number;
},
view: EditorView
-): { node: HTMLElement; id: string } | undefined => {
+): { 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
// blockGroup that wraps all blocks in the editor) for more accurate side
@@ -50,22 +78,41 @@ const getBlockFromMousePos = (
// Gets block at mouse cursor's vertical position.
const coords = {
- left: editorBoundingBox.left + editorBoundingBox.width / 2, // take middle of editor
+ left: mousePos.x,
top: mousePos.y,
};
- const elements = view.root.elementsFromPoint(coords.left, coords.top);
- let block = undefined;
+ const mouseLeftOfEditor = coords.left < editorBoundingBox.left;
+ const mouseRightOfEditor = coords.left > editorBoundingBox.right;
- for (const element of elements) {
- if (view.dom.contains(element)) {
- block = getDraggableBlockFromElement(element, view);
- break;
- }
+ if (mouseLeftOfEditor) {
+ coords.left = editorBoundingBox.left + 10;
+ }
+
+ if (mouseRightOfEditor) {
+ coords.left = editorBoundingBox.right - 10;
+ }
+
+ let block = getBlockFromCoords(view, coords);
+
+ if (!mouseRightOfEditor && block) {
+ // note: this case is not necessary when we're on the right side of the editor
+
+ /* Now, because blocks can be nested
+ | BlockA |
+ x | BlockB y|
+
+ hovering over position x (the "margin of block B") will return block A instead of block B.
+ to fix this, we get the block from the right side of block A (position y, which will fall in BlockB correctly)
+ */
+
+ const rect = block.node.getBoundingClientRect();
+ coords.left = rect.right - 10;
+ block = getBlockFromCoords(view, coords, false);
}
return block;
-};
+}
/**
* With the sidemenu plugin we can position a menu next to a hovered block.
@@ -158,6 +205,7 @@ export class SideMenuView<
this.hoveredBlock = block.node;
// Gets the block's content node, which lets to ignore child blocks when determining the block menu's position.
+ // TODO: needed?
const blockContent = block.node.firstChild as HTMLElement;
if (!blockContent) {
@@ -168,15 +216,20 @@ export class SideMenuView<
// Shows or updates elements.
if (this.editor.isEditable) {
- const editorBoundingBox = (
- this.pmView.dom.firstChild as HTMLElement
- ).getBoundingClientRect();
const blockContentBoundingBox = blockContent.getBoundingClientRect();
-
+ const column = block.node.closest("[data-node-type=column]");
this.updateState({
show: true,
referencePos: new DOMRect(
- editorBoundingBox.x,
+ 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
@@ -209,22 +262,7 @@ export class SideMenuView<
});
if (!pos || pos.inside === -1) {
- const evt = new Event("drop", event) as any;
- const editorBoundingBox = (
- this.pmView.dom.firstChild as HTMLElement
- ).getBoundingClientRect();
- evt.clientX =
- event.clientX < editorBoundingBox.left ||
- event.clientX > editorBoundingBox.left + editorBoundingBox.width
- ? editorBoundingBox.left + editorBoundingBox.width / 2
- : event.clientX;
- evt.clientY = Math.min(
- Math.max(event.clientY, editorBoundingBox.top),
- editorBoundingBox.top + editorBoundingBox.height
- );
- evt.dataTransfer = event.dataTransfer;
- evt.preventDefault = () => event.preventDefault();
- evt.synthetic = true; // prevent recursion
+ const evt = this.createSyntheticEvent(event);
// console.log("dispatch fake drop");
this.pmView.dom.dispatchEvent(evt);
}
@@ -248,15 +286,7 @@ export class SideMenuView<
});
if (!pos || (pos.inside === -1 && this.pmView.dom.firstChild)) {
- const evt = new Event("dragover", event) as any;
- const editorBoundingBox = (
- this.pmView.dom.firstChild as HTMLElement
- ).getBoundingClientRect();
- evt.clientX = editorBoundingBox.left + editorBoundingBox.width / 2;
- evt.clientY = event.clientY;
- evt.dataTransfer = event.dataTransfer;
- evt.preventDefault = () => event.preventDefault();
- evt.synthetic = true; // prevent recursion
+ const evt = this.createSyntheticEvent(event);
// console.log("dispatch fake dragover");
this.pmView.dom.dispatchEvent(evt);
}
@@ -314,6 +344,62 @@ export class SideMenuView<
this.updateStateFromMousePos();
};
+ private createSyntheticEvent(event: DragEvent) {
+ const evt = new Event(event.type, event) as any;
+ const editorBoundingBox = (
+ this.pmView.dom.firstChild as HTMLElement
+ ).getBoundingClientRect();
+ evt.clientX = event.clientX;
+ evt.clientY = event.clientY;
+ if (
+ event.clientX < editorBoundingBox.left &&
+ event.clientX >
+ editorBoundingBox.left -
+ editorBoundingBox.width *
+ PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
+ ) {
+ // when we're slightly left of the editor, we can drop to the side of the block
+ evt.clientX =
+ editorBoundingBox.left +
+ (editorBoundingBox.width *
+ PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP) /
+ 2;
+ } else if (
+ event.clientX > editorBoundingBox.right &&
+ event.clientX <
+ editorBoundingBox.right +
+ editorBoundingBox.width *
+ PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
+ ) {
+ // when we're slightly right of the editor, we can drop to the side of the block
+ evt.clientX =
+ editorBoundingBox.right -
+ (editorBoundingBox.width *
+ PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP) /
+ 2;
+ } else if (
+ event.clientX < editorBoundingBox.left ||
+ event.clientX > editorBoundingBox.right
+ ) {
+ // when mouse is outside of the editor on x axis, drop it somewhere safe (but not to the side of a block)
+ evt.clientX =
+ editorBoundingBox.left +
+ PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP *
+ editorBoundingBox.width *
+ 2; // put it somewhere in first block, but safe outside of the PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP margin
+ }
+
+ evt.clientY = Math.min(
+ Math.max(event.clientY, editorBoundingBox.top),
+ editorBoundingBox.top + editorBoundingBox.height
+ );
+
+ evt.dataTransfer = event.dataTransfer;
+ evt.preventDefault = () => event.preventDefault();
+ evt.synthetic = true; // prevent recursion
+ return evt;
+ }
+
// Needed in cases where the editor state updates without the mouse cursor
// moving, as some state updates can require a side menu update. For example,
// adding a button to the side menu which removes the block can cause the
@@ -386,11 +472,14 @@ export class SideMenuProsemirrorPlugin<
/**
* Handles drag & drop events for blocks.
*/
- blockDragStart = (event: {
- dataTransfer: DataTransfer | null;
- clientY: number;
- }) => {
- dragStart(event, this.editor);
+ blockDragStart = (
+ event: {
+ dataTransfer: DataTransfer | null;
+ clientY: number;
+ },
+ block: Block
+ ) => {
+ dragStart(event, block, this.editor);
};
/**
diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts
index a16797ecc3..285e5fba68 100644
--- a/packages/core/src/extensions/SideMenu/dragging.ts
+++ b/packages/core/src/extensions/SideMenu/dragging.ts
@@ -6,6 +6,7 @@ import { EditorView } from "prosemirror-view";
import { createExternalHTMLExporter } from "../../api/exporters/html/externalHTMLExporter.js";
import { cleanHTMLToMarkdown } from "../../api/exporters/markdown/markdownExporter.js";
import { fragmentToBlocks } from "../../api/nodeConversions/fragmentToBlocks.js";
+import { getNodeById } from "../../api/nodeUtil.js";
import { Block } from "../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { UiElementPosition } from "../../extensions-shared/UiElementPosition.js";
@@ -27,39 +28,6 @@ export type SideMenuState<
block: Block;
};
-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")! };
-}
-
-function blockPositionFromElement(element: Element, view: EditorView) {
- const block = getDraggableBlockFromElement(element, view);
-
- if (block && block.node.nodeType === 1) {
- // TODO: this uses undocumented PM APIs? do we need this / let's add docs?
- const docView = (view as any).docView;
- const desc = docView.nearestDesc(block.node, true);
- if (!desc || desc === docView) {
- return null;
- }
- return desc.posBefore;
- }
- return null;
-}
-
function blockPositionsFromSelection(selection: Selection, doc: Node) {
// Absolute positions just before the first block spanned by the selection, and just after the last block. Having the
// selection start and end just before and just after the target blocks ensures no whitespace/line breaks are left
@@ -172,6 +140,7 @@ export function dragStart<
S extends StyleSchema
>(
e: { dataTransfer: DataTransfer | null; clientY: number },
+ block: Block,
editor: BlockNoteEditor
) {
if (!e.dataTransfer) {
@@ -180,28 +149,8 @@ export function dragStart<
const view = editor.prosemirrorView;
- const editorBoundingBox = view.dom.getBoundingClientRect();
-
- const coords = {
- left: editorBoundingBox.left + editorBoundingBox.width / 2, // take middle of editor
- top: e.clientY,
- };
-
- const elements = view.root.elementsFromPoint(coords.left, coords.top);
- let blockEl = undefined;
-
- for (const element of elements) {
- if (view.dom.contains(element)) {
- blockEl = getDraggableBlockFromElement(element, view);
- break;
- }
- }
-
- if (!blockEl) {
- return;
- }
+ const pos = getNodeById(block.id, view.state.doc).posBeforeNode;
- const pos = blockPositionFromElement(blockEl.node, view);
if (pos != null) {
const selection = view.state.selection;
const doc = view.state.doc;
diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
index 269babc8ff..1af3dae3fd 100644
--- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
+++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts
@@ -62,12 +62,10 @@ export function insertOrUpdateBlock<
currentBlock.content.length === 0)
) {
newBlock = editor.updateBlock(currentBlock, block);
-
- // Edge case for updating block content as `updateBlock` causes the
- // selection to move into the next block, so we have to set it back.
- if (block.content) {
- editor.setTextCursorPosition(newBlock);
- }
+ // We make sure to reset the cursor position to the new block as calling
+ // `updateBlock` may move it out. This generally happens when the content
+ // changes, or the update makes the block multi-column.
+ editor.setTextCursorPosition(newBlock);
} else {
newBlock = editor.insertBlocks([block], currentBlock, "after")[0];
editor.setTextCursorPosition(editor.getTextCursorPosition().nextBlock!);
diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
index 23a8cb3758..7aab1be9c2 100644
--- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
+++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts
@@ -12,7 +12,7 @@ import {
StyleSchema,
} from "../../schema/index.js";
import { EventEmitter } from "../../util/EventEmitter.js";
-import { getDraggableBlockFromElement } from "../SideMenu/dragging.js";
+import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
let dragImageElement: HTMLElement | undefined;
diff --git a/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts b/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts
index 3440ebd9f0..90a28efa74 100644
--- a/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts
+++ b/packages/core/src/extensions/TrailingNode/TrailingNodeExtension.ts
@@ -62,7 +62,7 @@ export const TrailingNode = Extension.create({
lastNode = lastNode.lastChild;
if (!lastNode || lastNode.type.name !== "blockContainer") {
- throw new Error("Expected blockContainer");
+ return true; // not a blockContainer, but for example Columns. Insert trailing node
}
const lastContentNode = lastNode.firstChild;
diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts
new file mode 100644
index 0000000000..7217242456
--- /dev/null
+++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts
@@ -0,0 +1,19 @@
+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/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts
index 8644895035..6331d722e7 100644
--- a/packages/core/src/i18n/locales/de.ts
+++ b/packages/core/src/i18n/locales/de.ts
@@ -22,13 +22,13 @@ export const de = {
title: "Nummerierte Liste",
subtext: "Liste mit nummerierten Elementen",
aliases: ["ol", "li", "liste", "nummerierteliste", "nummerierte liste"],
- group: "Grundlegende Blöcke",
+ group: "Grundlegende blöcke",
},
bullet_list: {
title: "Aufzählungsliste",
subtext: "Liste mit unnummerierten Elementen",
aliases: ["ul", "li", "liste", "aufzählungsliste", "aufzählung liste"],
- group: "Grundlegende Blöcke",
+ group: "Grundlegende blöcke",
},
check_list: {
title: "Checkliste",
@@ -42,19 +42,19 @@ export const de = {
"geprüfte liste",
"kontrollkästchen",
],
- group: "Grundlegende Blöcke",
+ group: "Grundlegende blöcke",
},
paragraph: {
title: "Absatz",
subtext: "Der Hauptteil Ihres Dokuments",
aliases: ["p", "absatz"],
- group: "Grundlegende Blöcke",
+ group: "Grundlegende blöcke",
},
code_block: {
title: "Codeblock",
subtext: "Codeblock mit Syntaxhervorhebung",
aliases: ["code", "pre"],
- group: "Grundlegende Blöcke",
+ group: "Grundlegende blöcke",
},
table: {
title: "Tabelle",
diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts
index 018d11e174..0f435b8955 100644
--- a/packages/core/src/i18n/locales/pt.ts
+++ b/packages/core/src/i18n/locales/pt.ts
@@ -24,13 +24,13 @@ export const pt: Dictionary = {
title: "Lista Numerada",
subtext: "Usado para exibir uma lista numerada",
aliases: ["ol", "li", "lista", "listanumerada", "lista numerada"],
- group: "Blocos Básicos",
+ group: "Blocos básicos",
},
bullet_list: {
title: "Lista com Marcadores",
subtext: "Usado para exibir uma lista não ordenada",
aliases: ["ul", "li", "lista", "listamarcadores", "lista com marcadores"],
- group: "Blocos Básicos",
+ group: "Blocos básicos",
},
check_list: {
title: "Lista de verificação",
@@ -43,19 +43,19 @@ export const pt: Dictionary = {
"lista marcada",
"caixa de seleção",
],
- group: "Blocos Básicos",
+ group: "Blocos básicos",
},
paragraph: {
title: "Parágrafo",
subtext: "Usado para o corpo do seu documento",
aliases: ["p", "paragrafo"],
- group: "Blocos Básicos",
+ group: "Blocos básicos",
},
code_block: {
title: "Bloco de Código",
subtext: "Usado para exibir código com destaque de sintaxe",
aliases: ["codigo", "pre"],
- group: "Blocos Básicos",
+ group: "Blocos básicos",
},
table: {
title: "Tabela",
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 7a45f99e66..1ccd3bcb3d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -2,13 +2,10 @@ import * as locales from "./i18n/locales/index.js";
export * from "./api/exporters/html/externalHTMLExporter.js";
export * from "./api/exporters/html/internalHTMLSerializer.js";
export * from "./api/getBlockInfoFromPos.js";
+export * from "./api/nodeUtil.js";
export * from "./api/testUtil/index.js";
export * from "./blocks/AudioBlockContent/AudioBlockContent.js";
export * from "./blocks/CodeBlockContent/CodeBlockContent.js";
-export * from "./blocks/defaultBlockHelpers.js";
-export * from "./blocks/defaultBlocks.js";
-export * from "./blocks/defaultBlockTypeGuards.js";
-export * from "./blocks/defaultProps.js";
export * from "./blocks/FileBlockContent/FileBlockContent.js";
export * from "./blocks/FileBlockContent/fileBlockHelpers.js";
export * from "./blocks/FileBlockContent/uploadToTmpFilesDotOrg_DEV_ONLY.js";
@@ -19,6 +16,10 @@ export {
EMPTY_CELL_WIDTH,
} from "./blocks/TableBlockContent/TableExtension.js";
export * from "./blocks/VideoBlockContent/VideoBlockContent.js";
+export * from "./blocks/defaultBlockHelpers.js";
+export * from "./blocks/defaultBlockTypeGuards.js";
+export * from "./blocks/defaultBlocks.js";
+export * from "./blocks/defaultProps.js";
export * from "./editor/BlockNoteEditor.js";
export * from "./editor/BlockNoteExtensions.js";
export * from "./editor/BlockNoteSchema.js";
@@ -30,13 +31,14 @@ export * from "./extensions/LinkToolbar/LinkToolbarPlugin.js";
export * from "./extensions/SideMenu/SideMenuPlugin.js";
export * from "./extensions/SuggestionMenu/DefaultGridSuggestionItem.js";
export * from "./extensions/SuggestionMenu/DefaultSuggestionItem.js";
+export * from "./extensions/SuggestionMenu/SuggestionPlugin.js";
export * from "./extensions/SuggestionMenu/getDefaultEmojiPickerItems.js";
export * from "./extensions/SuggestionMenu/getDefaultSlashMenuItems.js";
-export * from "./extensions/SuggestionMenu/SuggestionPlugin.js";
export * from "./extensions/TableHandles/TableHandlesPlugin.js";
export * from "./i18n/dictionary.js";
export * from "./schema/index.js";
export * from "./util/browser.js";
+export * from "./util/combineByGroup.js";
export * from "./util/esmDependencies.js";
export * from "./util/string.js";
export * from "./util/typescript.js";
diff --git a/packages/core/src/pm-nodes/BlockContainer.ts b/packages/core/src/pm-nodes/BlockContainer.ts
index 81e8b46b14..ecd7f8068f 100644
--- a/packages/core/src/pm-nodes/BlockContainer.ts
+++ b/packages/core/src/pm-nodes/BlockContainer.ts
@@ -21,7 +21,7 @@ export const BlockContainer = Node.create<{
editor: BlockNoteEditor;
}>({
name: "blockContainer",
- group: "blockContainer",
+ group: "blockGroupChild bnBlock",
// A block always contains content, and optionally a blockGroup which contains nested blocks
content: "blockContent blockGroup?",
// Ensures content-specific keyboard handlers trigger first.
diff --git a/packages/core/src/pm-nodes/BlockGroup.ts b/packages/core/src/pm-nodes/BlockGroup.ts
index d98cdbdb8c..820b7eeb9d 100644
--- a/packages/core/src/pm-nodes/BlockGroup.ts
+++ b/packages/core/src/pm-nodes/BlockGroup.ts
@@ -6,8 +6,8 @@ export const BlockGroup = Node.create<{
domAttributes?: BlockNoteDOMAttributes;
}>({
name: "blockGroup",
- group: "blockGroup",
- content: "blockContainer+",
+ group: "childContainer",
+ content: "blockGroupChild+",
parseHTML() {
return [
diff --git a/packages/core/src/pm-nodes/README.md b/packages/core/src/pm-nodes/README.md
index 83ea63c6f4..be57ead212 100644
--- a/packages/core/src/pm-nodes/README.md
+++ b/packages/core/src/pm-nodes/README.md
@@ -2,41 +2,140 @@
Defines the prosemirror nodes and base node structure. See below:
+# Block structure
+
+In the BlockNote API, recall that blocks look like this:
+
+```typescript
+{
+ id: string;
+ type: string;
+ children: Block[];
+ content: InlineContent[] | undefined;
+ props: Record;
+}
+```
+
+`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a `column` or `columnList`.
+
+`content` is the block's Inline Content. Inline content doesn't have any `id`, it's "loose" content within the node.
+
+This is a bit different from the Prosemirror structure we use internally. This document describes the Prosemirror schema architecture.
# Node structure
-We use a Prosemirror document structure where every element is a `block` with 1 `content` element and one optional group of children (`blockgroup`).
+## BlockGroup
+
+```typescript
+name: "blockGroup",
+group: "childContainer",
+content: "blockGroupChild+"
+```
+
+A `blockGroup` is a container node that can contain multiple Blocks. It is used as:
+
+- The root node of the Prosemirror document
+- When a block has nested children, they are wrapped in a `blockGroup`
+
+## BlockContainer
+
+```typescript
+name: "blockContainer",
+group: "blockGroupChild bnBlock",
+// A block always contains content, and optionally a blockGroup which contains nested blocks
+content: "blockContent blockGroup?",
+```
+
+A `blockContainer` is a container node that always contains a `blockContent` node, and optionally a `blockGroup` node (for nested children). It is used as the wrapper for most blocks. This structure makes it possible to nest blocks within blocks.
+
+### BlockContent (group)
+
+Example:
-- A `block` can only appear in a `blockgroup` (which is also the type of the root node)
-- Every `block` element can have attributes (e.g.: is it a heading or a list item)
-- Every `block` element can contain a `blockgroup` as second child. In this case the `blockgroup` is considered nested (indented in the UX)
+```typescript
+name: "paragraph", // name corresponds to the block type in the BlockNote API
+content: "inline*", // can also be no content (for image blocks)
+group: "blockContent",
+```
+
+Blocks that are part of the `blockContent` group define the appearance / behaviour of the main element of the block (i.e.: headings, paragraphs, list items, etc.).
+These are only used for "regular" blocks that are represented as `blockContainer` nodes.
+
+## Multi-column
+
+The `multi-column` package makes it possible to order blocks side by side in
+columns. It adds the `columnList` and `column` nodes to the schema.
+
+### ColumnList
+
+```typescript
+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
+```
+
+The column list contains 2 or more columns.
-This architecture is different from the "default" Prosemirror / Tiptap implementation which would use more semantic HTML node types (`p`, `li`, etc.). We have designed this block structure instead to more easily:
+### Column
-- support indentation of any node (without complex wrapping logic)
-- supporting animations (nodes stay the same type, only attrs are changed)
+```typescript
+name: "column",
+group: "bnBlock childContainer",
+// A block always contains content, and optionally a blockGroup which contains nested blocks
+content: "blockContainer+",
+```
+
+The column contains 1 or more block containers.
+
+# Groups
+
+We use Prosemirror "groups" to help organize this schema. Here is a list of the different groups:
-## Example
+- `blockContent`: described above (contain the content for blocks that are represented as `BlockContainer` nodes)
+- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and `columnList`
+- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`, but for columns, both `columnList` and `column` are considered to be `childContainer` nodes.
+- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. Both `blockContainer`, `column` and `columnList` are part of this group.
+
+_Note that the last two groups, `bnBlock` and `childContainer`, are not used anywhere in the schema. They are however helpful while programming. For example, we can check whether a node is a `bnBlock`, and then we know it corresponds to a BlockNote Block. Or, we can check whether a node is a `childContainer`, and then we know it's a container of a BlockNote Block's `children`. See `getBlockInfoFromPos` for an example of how this is used._
+
+## Example document
```xml
-
-
- Parent element 1
-
-
- Nested / child / indented item
-
-
-
-
- Parent element 2
-
- ...
- ...
-
-
-
- Element 3 without children
-
-
+
+
+ Parent element 1
+
+
+ Nested / child / indented item
+
+
+
+
+ Parent element 2
+
+ ...
+ ...
+
+
+
+ Element 3 without children
+
+
+
+
+ Column 1
+
+
+
+
+ Column 2
+
+
+
+
```
+
+## Tables
+
+Tables are implemented a special type of blockContent node. The rows and columns are stored in the `content` fields, not in the `children`. This is because children of tables (rows / columns / cells) are not considered to be blocks (they don't have an id, for example).
diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts
index 1fa551e532..bdad9482e0 100644
--- a/packages/core/src/schema/blocks/internal.ts
+++ b/packages/core/src/schema/blocks/internal.ts
@@ -201,12 +201,22 @@ export function wrapInBlockStructure<
// Helper type to keep track of the `name` and `content` properties after calling Node.create.
type StronglyTypedTipTapNode<
Name extends string,
- Content extends "inline*" | "tableRow+" | ""
+ Content extends
+ | "inline*"
+ | "tableRow+"
+ | "blockContainer+"
+ | "column column+"
+ | ""
> = Node & { name: Name; config: { content: Content } };
export function createStronglyTypedTiptapNode<
Name extends string,
- Content extends "inline*" | "tableRow+" | ""
+ Content extends
+ | "inline*"
+ | "tableRow+"
+ | "blockContainer+"
+ | "column column+"
+ | ""
>(config: NodeConfig & { name: Name; content: Content }) {
return Node.create(config) as StronglyTypedTipTapNode; // force re-typing (should be safe as it's type-checked from the config)
}
diff --git a/packages/core/src/util/combineByGroup.ts b/packages/core/src/util/combineByGroup.ts
new file mode 100644
index 0000000000..c9fd825d68
--- /dev/null
+++ b/packages/core/src/util/combineByGroup.ts
@@ -0,0 +1,25 @@
+/**
+ * Combines items by group. This can be used to combine multiple slash menu item arrays,
+ * while making sure that items from the same group are adjacent to each other.
+ */
+export function combineByGroup(
+ items: T[],
+ ...additionalItemsArray: {
+ group?: string;
+ }[][]
+) {
+ const combinedItems = [...items];
+ for (const additionalItems of additionalItemsArray) {
+ for (const additionalItem of additionalItems) {
+ const lastItemWithSameGroup = combinedItems.findLastIndex(
+ (item) => item.group === additionalItem.group
+ );
+ if (lastItemWithSameGroup === -1) {
+ combinedItems.push(additionalItem as T);
+ } else {
+ combinedItems.splice(lastItemWithSameGroup + 1, 0, additionalItem as T);
+ }
+ }
+ }
+ return combinedItems;
+}
diff --git a/packages/mantine/package.json b/packages/mantine/package.json
index 0805be4eea..8d297e565c 100644
--- a/packages/mantine/package.json
+++ b/packages/mantine/package.json
@@ -45,7 +45,6 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "build-bundled": "tsc && vite build --config vite.config.bundled.ts && git checkout tmp-releases && rm -rf ../../release && mv ../../release-tmp ../../release",
"preview": "vite preview",
"lint": "eslint src --max-warnings 0",
"clean": "rimraf dist && rimraf types"
diff --git a/packages/react/package.json b/packages/react/package.json
index 394cac5b19..04a4f89181 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -45,7 +45,6 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "build-bundled": "tsc && vite build --config vite.config.bundled.ts && git checkout tmp-releases && rm -rf ../../release && mv ../../release-tmp ../../release",
"preview": "vite preview",
"lint": "eslint src --max-warnings 0",
"test": "vitest --run",
diff --git a/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx
index 8d0ffae2ba..b5039fe947 100644
--- a/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx
+++ b/packages/react/src/components/SideMenu/DefaultButtons/DragHandleButton.tsx
@@ -39,7 +39,7 @@ export const DragHandleButton = <
props.blockDragStart(e, props.block)}
onDragEnd={props.blockDragEnd}
className={"bn-button"}
icon={}
diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json
index 3a77502f90..7553eead9b 100644
--- a/packages/shadcn/package.json
+++ b/packages/shadcn/package.json
@@ -45,7 +45,6 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
- "build-bundled": "tsc && vite build --config vite.config.bundled.ts && git checkout tmp-releases && rm -rf ../../release && mv ../../release-tmp ../../release",
"preview": "vite preview",
"lint": "eslint src --max-warnings 0",
"clean": "rimraf dist && rimraf types"
diff --git a/packages/xl-multi-column/.gitignore b/packages/xl-multi-column/.gitignore
new file mode 100644
index 0000000000..a547bf36d8
--- /dev/null
+++ b/packages/xl-multi-column/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/packages/xl-multi-column/LICENSE b/packages/xl-multi-column/LICENSE
new file mode 100644
index 0000000000..19bae35887
--- /dev/null
+++ b/packages/xl-multi-column/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+ Preamble
+
+The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+0. Definitions.
+
+"This License" refers to version 3 of the GNU Affero General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+
+The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+The Corresponding Source for a work in source code form is that
+same work.
+
+2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+13. Remote Network Interaction; Use with the GNU General Public License.
+
+Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/packages/xl-multi-column/package.json b/packages/xl-multi-column/package.json
new file mode 100644
index 0000000000..1a518621f1
--- /dev/null
+++ b/packages/xl-multi-column/package.json
@@ -0,0 +1,79 @@
+{
+ "name": "@blocknote/xl-multi-column",
+ "homepage": "https://github.com/TypeCellOS/BlockNote",
+ "private": false,
+ "license": "AGPL-3.0 OR PROPRIETARY",
+ "version": "0.18.1",
+ "files": [
+ "dist",
+ "types",
+ "src"
+ ],
+ "keywords": [
+ "react",
+ "javascript",
+ "editor",
+ "typescript",
+ "prosemirror",
+ "wysiwyg",
+ "rich-text-editor",
+ "notion",
+ "yjs",
+ "block-based",
+ "tiptap"
+ ],
+ "description": "A \"Notion-style\" block-based extensible text editor built on top of Prosemirror and Tiptap.",
+ "type": "module",
+ "source": "src/index.ts",
+ "types": "./types/src/index.d.ts",
+ "main": "./dist/blocknote-xl-multi-column.umd.cjs",
+ "module": "./dist/blocknote-xl-multi-column.js",
+ "exports": {
+ ".": {
+ "types": "./types/src/index.d.ts",
+ "import": "./dist/blocknote-xl-multi-column.js",
+ "require": "./dist/blocknote-xl-multi-column.umd.cjs"
+ }
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint src --max-warnings 0",
+ "test": "vitest",
+ "test-watch": "vitest watch",
+ "clean": "rimraf dist && rimraf types"
+ },
+ "dependencies": {
+ "@blocknote/core": "*",
+ "@blocknote/react": "*",
+ "@tiptap/core": "^2.7.1",
+ "prosemirror-model": "^1.23.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.3.7",
+ "prosemirror-transform": "^1.9.0",
+ "prosemirror-view": "^1.33.7",
+ "react-icons": "^5.2.1"
+ },
+ "devDependencies": {
+ "@vitest/ui": "^2.1.4",
+ "eslint": "^8.10.0",
+ "jsdom": "^21.1.0",
+ "prettier": "^2.7.1",
+ "rimraf": "^5.0.5",
+ "rollup-plugin-webpack-stats": "^0.2.2",
+ "typescript": "^5.3.3",
+ "vite": "^5.3.4",
+ "vite-plugin-eslint": "^1.8.1",
+ "vitest": "^2.0.3"
+ },
+ "eslintConfig": {
+ "extends": [
+ "../../.eslintrc.js"
+ ]
+ },
+ "publishConfig": {
+ "access": "public",
+ "registry": "https://registry.npmjs.org/"
+ }
+}
diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts
new file mode 100644
index 0000000000..98bf0ba3d2
--- /dev/null
+++ b/packages/xl-multi-column/src/blocks/Columns/index.ts
@@ -0,0 +1,15 @@
+import { Column } from "../../pm-nodes/Column.js";
+import { ColumnList } from "../../pm-nodes/ColumnList.js";
+
+import { createBlockSpecFromStronglyTypedTiptapNode } from "@blocknote/core";
+
+export const ColumnBlock = createBlockSpecFromStronglyTypedTiptapNode(Column, {
+ width: {
+ default: 1,
+ },
+});
+
+export const ColumnListBlock = createBlockSpecFromStronglyTypedTiptapNode(
+ ColumnList,
+ {}
+);
diff --git a/packages/xl-multi-column/src/blocks/schema.ts b/packages/xl-multi-column/src/blocks/schema.ts
new file mode 100644
index 0000000000..d83f3755f7
--- /dev/null
+++ b/packages/xl-multi-column/src/blocks/schema.ts
@@ -0,0 +1,43 @@
+import {
+ BlockNoteSchema,
+ BlockSchema,
+ InlineContentSchema,
+ StyleSchema,
+} from "@blocknote/core";
+import { ColumnBlock, ColumnListBlock } from "./Columns/index.js";
+
+export const multiColumnSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ column: ColumnBlock,
+ columnList: ColumnListBlock,
+ },
+});
+
+/**
+ * Adds multi-column support to the given schema.
+ */
+export const withMultiColumn = <
+ B extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ schema: BlockNoteSchema
+) => {
+ return BlockNoteSchema.create({
+ blockSpecs: {
+ ...schema.blockSpecs,
+ column: ColumnBlock,
+ columnList: ColumnListBlock,
+ },
+ inlineContentSpecs: schema.inlineContentSpecs,
+ styleSpecs: schema.styleSpecs,
+ }) as any as BlockNoteSchema<
+ // typescript needs some help here
+ B & {
+ column: typeof ColumnBlock.config;
+ columnList: typeof ColumnListBlock.config;
+ },
+ I,
+ S
+ >;
+};
diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
new file mode 100644
index 0000000000..a9a3ba445a
--- /dev/null
+++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
@@ -0,0 +1,357 @@
+import { BlockNoteEditor, getNodeById } from "@blocknote/core";
+import { Extension } from "@tiptap/core";
+import { Node } from "prosemirror-model";
+import { Plugin, PluginKey, PluginView } from "prosemirror-state";
+import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
+
+type ColumnData = {
+ element: HTMLElement;
+ id: string;
+ node: Node;
+ posBeforeNode: number;
+};
+
+type ColumnDataWithWidths = ColumnData & {
+ widthPx: number;
+ widthPercent: number;
+};
+
+type ColumnDefaultState = {
+ type: "default";
+};
+
+type ColumnHoverState = {
+ type: "hover";
+ leftColumn: ColumnData;
+ rightColumn: ColumnData;
+};
+
+type ColumnResizeState = {
+ type: "resize";
+ startPos: number;
+ leftColumn: ColumnDataWithWidths;
+ rightColumn: ColumnDataWithWidths;
+};
+
+type ColumnState = ColumnDefaultState | ColumnHoverState | ColumnResizeState;
+
+const columnResizePluginKey = new PluginKey("ColumnResizePlugin");
+
+class ColumnResizePluginView implements PluginView {
+ editor: BlockNoteEditor;
+ view: EditorView;
+
+ readonly RESIZE_MARGIN_WIDTH_PX = 20;
+ readonly COLUMN_MIN_WIDTH_PERCENT = 0.5;
+
+ constructor(editor: BlockNoteEditor, view: EditorView) {
+ this.editor = editor;
+ this.view = view;
+
+ this.view.dom.addEventListener("mousedown", this.mouseDownHandler);
+ document.body.addEventListener("mousemove", this.mouseMoveHandler);
+ document.body.addEventListener("mouseup", this.mouseUpHandler);
+ }
+
+ getColumnHoverOrDefaultState = (
+ event: MouseEvent
+ ): ColumnDefaultState | ColumnHoverState => {
+ const target = event.target as HTMLElement;
+
+ // Do nothing if the event target is outside the editor.
+ if (!this.view.dom.contains(target)) {
+ return { type: "default" };
+ }
+
+ const columnElement = target.closest(
+ ".bn-block-column"
+ ) as HTMLElement | null;
+
+ // Do nothing if a column element does not exist in the event target's
+ // ancestors.
+ if (!columnElement) {
+ return { type: "default" };
+ }
+
+ const startPos = event.clientX;
+ const columnElementDOMRect = columnElement.getBoundingClientRect();
+
+ // Whether the cursor is within the width margin to trigger a resize.
+ const cursorElementSide =
+ startPos < columnElementDOMRect.left + this.RESIZE_MARGIN_WIDTH_PX
+ ? "left"
+ : startPos > columnElementDOMRect.right - this.RESIZE_MARGIN_WIDTH_PX
+ ? "right"
+ : "none";
+
+ // The column element before or after the one hovered by the cursor,
+ // depending on which side the cursor is on.
+ const adjacentColumnElement =
+ cursorElementSide === "left"
+ ? columnElement.previousElementSibling
+ : cursorElementSide === "right"
+ ? columnElement.nextElementSibling
+ : undefined;
+
+ // Do nothing if the cursor is not within the resize margin or if there
+ // is no column before or after the one hovered by the cursor, depending
+ // on which side the cursor is on.
+ if (!adjacentColumnElement) {
+ return { type: "default" };
+ }
+
+ const leftColumnElement =
+ cursorElementSide === "left"
+ ? (adjacentColumnElement as HTMLElement)
+ : columnElement;
+
+ const rightColumnElement =
+ cursorElementSide === "left"
+ ? columnElement
+ : (adjacentColumnElement as HTMLElement);
+
+ const leftColumnId = leftColumnElement.getAttribute("data-id")!;
+ const rightColumnId = rightColumnElement.getAttribute("data-id")!;
+
+ const leftColumnNodeAndPos = getNodeById(leftColumnId, this.view.state.doc);
+
+ const rightColumnNodeAndPos = getNodeById(
+ rightColumnId,
+ this.view.state.doc
+ );
+
+ if (
+ !leftColumnNodeAndPos ||
+ !rightColumnNodeAndPos ||
+ !leftColumnNodeAndPos.posBeforeNode
+ ) {
+ throw new Error("Column not found");
+ }
+
+ return {
+ type: "hover",
+ leftColumn: {
+ element: leftColumnElement,
+ id: leftColumnId,
+ ...leftColumnNodeAndPos,
+ },
+ rightColumn: {
+ element: rightColumnElement,
+ id: rightColumnId,
+ ...rightColumnNodeAndPos,
+ },
+ };
+ };
+
+ // When the user mouses down near the boundary between two columns, we
+ // want to set the plugin state to resize, so the columns can be resized
+ // by moving the mouse.
+ mouseDownHandler = (event: MouseEvent) => {
+ let newState: ColumnState = this.getColumnHoverOrDefaultState(event);
+ if (newState.type === "default") {
+ return;
+ }
+
+ event.preventDefault();
+
+ const startPos = event.clientX;
+
+ const leftColumnWidthPx =
+ newState.leftColumn.element.getBoundingClientRect().width;
+ const rightColumnWidthPx =
+ newState.rightColumn.element.getBoundingClientRect().width;
+
+ const leftColumnWidthPercent = newState.leftColumn.node.attrs
+ .width as number;
+ const rightColumnWidthPercent = newState.rightColumn.node.attrs
+ .width as number;
+
+ newState = {
+ type: "resize",
+ startPos,
+ leftColumn: {
+ ...newState.leftColumn,
+ widthPx: leftColumnWidthPx,
+ widthPercent: leftColumnWidthPercent,
+ },
+ rightColumn: {
+ ...newState.rightColumn,
+ widthPx: rightColumnWidthPx,
+ widthPercent: rightColumnWidthPercent,
+ },
+ };
+
+ this.view.dispatch(
+ this.view.state.tr.setMeta(columnResizePluginKey, newState)
+ );
+
+ this.editor.sideMenu.freezeMenu();
+ };
+
+ // If the plugin isn't in a resize state, we want to update it to either a
+ // hover state if the mouse is near the boundary between two columns, or
+ // default otherwise. If the plugin is in a resize state, we want to
+ // update the column widths based on the horizontal mouse movement.
+ mouseMoveHandler = (event: MouseEvent) => {
+ const pluginState = columnResizePluginKey.getState(this.view.state);
+ if (!pluginState) {
+ return;
+ }
+
+ // If the user isn't currently resizing columns, we want to update the
+ // plugin state to maybe show or hide the resize border between columns.
+ if (pluginState.type !== "resize") {
+ const newState = this.getColumnHoverOrDefaultState(event);
+
+ // Prevent unnecessary state updates (when the state before and after
+ // is the same).
+ const bothDefaultStates =
+ pluginState.type === "default" && newState.type === "default";
+ const sameColumnIds =
+ pluginState.type !== "default" &&
+ newState.type !== "default" &&
+ pluginState.leftColumn.id === newState.leftColumn.id &&
+ pluginState.rightColumn.id === newState.rightColumn.id;
+ if (bothDefaultStates || sameColumnIds) {
+ return;
+ }
+
+ // Since the resize bar overlaps the side menu, we don't want to show it
+ // if the side menu is already open.
+ if (newState.type === "hover" && this.editor.sideMenu.view?.state?.show) {
+ return;
+ }
+
+ // Update the plugin state.
+ this.view.dispatch(
+ this.view.state.tr.setMeta(columnResizePluginKey, newState)
+ );
+
+ return;
+ }
+
+ const widthChangePx = event.clientX - pluginState.startPos;
+ // We need to scale the width change by the left column's width in
+ // percent, otherwise the rate at which the resizing happens will change
+ // based on the width of the left column.
+ const scaledWidthChangePx =
+ widthChangePx * pluginState.leftColumn.widthPercent;
+ const widthChangePercent =
+ (pluginState.leftColumn.widthPx + scaledWidthChangePx) /
+ pluginState.leftColumn.widthPx -
+ 1;
+
+ let newLeftColumnWidth =
+ pluginState.leftColumn.widthPercent + widthChangePercent;
+ let newRightColumnWidth =
+ pluginState.rightColumn.widthPercent - widthChangePercent;
+
+ // Ensures that the column widths do not go below the minimum width.
+ // There is no maximum width, the user can resize the columns as much as
+ // they want provided the others don't go below the minimum width.
+ if (newLeftColumnWidth < this.COLUMN_MIN_WIDTH_PERCENT) {
+ newRightColumnWidth -= this.COLUMN_MIN_WIDTH_PERCENT - newLeftColumnWidth;
+ newLeftColumnWidth = this.COLUMN_MIN_WIDTH_PERCENT;
+ } else if (newRightColumnWidth < this.COLUMN_MIN_WIDTH_PERCENT) {
+ newLeftColumnWidth -= this.COLUMN_MIN_WIDTH_PERCENT - newRightColumnWidth;
+ newRightColumnWidth = this.COLUMN_MIN_WIDTH_PERCENT;
+ }
+
+ // possible improvement: only dispatch on mouse up, and use a different way
+ // to update the column widths while dragging.
+ // this prevents a lot of document updates
+ this.view.dispatch(
+ this.view.state.tr
+ .setNodeAttribute(
+ pluginState.leftColumn.posBeforeNode,
+ "width",
+ newLeftColumnWidth
+ )
+ .setNodeAttribute(
+ pluginState.rightColumn.posBeforeNode,
+ "width",
+ newRightColumnWidth
+ )
+ .setMeta("addToHistory", false)
+ );
+ };
+
+ // If the plugin is in a resize state, we want to revert it to a default
+ // or hover, depending on where the mouse cursor is, when the user
+ // releases the mouse button.
+ mouseUpHandler = (event: MouseEvent) => {
+ const pluginState = columnResizePluginKey.getState(this.view.state);
+ if (!pluginState || pluginState.type !== "resize") {
+ return;
+ }
+
+ const newState = this.getColumnHoverOrDefaultState(event);
+
+ // Revert plugin state to default or hover, depending on where the mouse
+ // cursor is.
+ this.view.dispatch(
+ this.view.state.tr.setMeta(columnResizePluginKey, newState)
+ );
+
+ this.editor.sideMenu.unfreezeMenu();
+ };
+
+ // This is a required method for PluginView, so we get a type error if we
+ // don't implement it.
+ update: undefined;
+}
+
+const createColumnResizePlugin = (editor: BlockNoteEditor) =>
+ new Plugin({
+ key: columnResizePluginKey,
+ props: {
+ // This adds a border between the columns when the user is
+ // resizing them or when the cursor is near their boundary.
+ decorations: (state) => {
+ const pluginState = columnResizePluginKey.getState(state);
+ if (!pluginState || pluginState.type === "default") {
+ return DecorationSet.empty;
+ }
+
+ return DecorationSet.create(state.doc, [
+ Decoration.node(
+ pluginState.leftColumn.posBeforeNode,
+ pluginState.leftColumn.posBeforeNode +
+ pluginState.leftColumn.node.nodeSize,
+ {
+ style: "box-shadow: 4px 0 0 #ccc; cursor: col-resize",
+ }
+ ),
+ Decoration.node(
+ pluginState.rightColumn.posBeforeNode,
+ pluginState.rightColumn.posBeforeNode +
+ pluginState.rightColumn.node.nodeSize,
+ {
+ style: "cursor: col-resize",
+ }
+ ),
+ ]);
+ },
+ },
+ state: {
+ init: () => ({ type: "default" } as ColumnState),
+ apply: (tr, oldPluginState) => {
+ const newPluginState = tr.getMeta(columnResizePluginKey) as
+ | ColumnState
+ | undefined;
+
+ return newPluginState === undefined ? oldPluginState : newPluginState;
+ },
+ },
+ view: (view) => new ColumnResizePluginView(editor, view),
+ });
+
+export const createColumnResizeExtension = (
+ editor: BlockNoteEditor
+) =>
+ Extension.create({
+ name: "columnResize",
+ addProseMirrorPlugins() {
+ return [createColumnResizePlugin(editor)];
+ },
+ });
diff --git a/packages/xl-multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts
new file mode 100644
index 0000000000..c586a0f942
--- /dev/null
+++ b/packages/xl-multi-column/src/extensions/DropCursor/MultiColumnDropCursorPlugin.ts
@@ -0,0 +1,480 @@
+import type { BlockNoteEditor } from "@blocknote/core";
+import {
+ UniqueID,
+ getBlockInfo,
+ getNearestBlockContainerPos,
+ nodeToBlock,
+} from "@blocknote/core";
+import { EditorState, Plugin } from "prosemirror-state";
+import { dropPoint } from "prosemirror-transform";
+import { EditorView } from "prosemirror-view";
+
+const PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP = 0.1;
+
+function eventCoords(event: MouseEvent) {
+ return { left: event.clientX, top: event.clientY };
+}
+
+interface DropCursorOptions {
+ /// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class.
+ color?: string | false;
+
+ /// The precise width of the cursor in pixels. Defaults to 1.
+ width?: number;
+
+ /// A CSS class name to add to the cursor element.
+ class?: string;
+}
+
+/// Create a plugin that, when added to a ProseMirror instance,
+/// causes a decoration to show up at the drop position when something
+/// is dragged over the editor.
+///
+/// Nodes may add a `disableDropCursor` property to their spec to
+/// control the showing of a drop cursor inside them. This may be a
+/// boolean or a function, which will be called with a view and a
+/// position, and should return a boolean.
+export function multiColumnDropCursor(
+ options: DropCursorOptions & {
+ editor: BlockNoteEditor;
+ }
+): Plugin {
+ const editor = options.editor;
+ return new Plugin({
+ view(editorView) {
+ return new DropCursorView(editorView, options);
+ },
+ props: {
+ handleDrop(view, event, slice, _moved) {
+ const eventPos = view.posAtCoords(eventCoords(event));
+
+ if (!eventPos) {
+ throw new Error("Could not get event position");
+ }
+
+ const posInfo = getTargetPosInfo(view.state, eventPos);
+ const blockInfo = getBlockInfo(posInfo);
+
+ const blockElement = view.nodeDOM(posInfo.posBeforeNode);
+ const blockRect = (blockElement as HTMLElement).getBoundingClientRect();
+ let position: "regular" | "left" | "right" = "regular";
+ if (
+ event.clientX <=
+ blockRect.left +
+ blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
+ ) {
+ position = "left";
+ }
+ if (
+ event.clientX >=
+ blockRect.right -
+ blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
+ ) {
+ position = "right";
+ }
+
+ if (position === "regular") {
+ // handled by default prosemirror drop behaviour
+ return false;
+ }
+
+ const draggedBlock = nodeToBlock(
+ slice.content.child(0),
+ editor.schema.blockSchema,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema
+ // TODO: cache?
+ );
+
+ // const block = blockInfo.block(editor);
+ if (blockInfo.blockNoteType === "column") {
+ // insert new column in existing columnList
+ const parentBlock = view.state.doc
+ .resolve(blockInfo.bnBlock.beforePos)
+ .node();
+
+ const columnList = nodeToBlock(
+ parentBlock,
+ editor.schema.blockSchema,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema
+ );
+
+ // In a `columnList`, we expect that the average width of each column
+ // is 1. However, there are cases in which this stops being true. For
+ // example, having one wider column and then removing it will cause
+ // the average width to go down. This isn't really an issue until the
+ // user tries to add a new column, which will, in this case, be wider
+ // than expected. Therefore, we normalize the column widths to an
+ // average of 1 here to avoid this issue.
+ let sumColumnWidthPercent = 0;
+ columnList.children.forEach((column) => {
+ sumColumnWidthPercent += column.props.width as number;
+ });
+ const avgColumnWidthPercent =
+ sumColumnWidthPercent / columnList.children.length;
+
+ // If the average column width is not 1, normalize it. We're dealing
+ // with floats so we need a small margin to account for precision
+ // errors.
+ if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) {
+ const scalingFactor = 1 / avgColumnWidthPercent;
+
+ columnList.children.forEach((column) => {
+ column.props.width =
+ (column.props.width as number) * scalingFactor;
+ });
+ }
+
+ const index = columnList.children.findIndex(
+ (b) => b.id === blockInfo.bnBlock.node.attrs.id
+ );
+
+ const newChildren = columnList.children.toSpliced(
+ position === "left" ? index : index + 1,
+ 0,
+ {
+ type: "column",
+ children: [draggedBlock],
+ props: {},
+ content: undefined,
+ id: UniqueID.options.generateID(),
+ }
+ );
+
+ editor.removeBlocks([draggedBlock]);
+
+ editor.updateBlock(columnList, {
+ children: newChildren,
+ });
+ } else {
+ // create new columnList with blocks as columns
+ const block = nodeToBlock(
+ blockInfo.bnBlock.node,
+ editor.schema.blockSchema,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema
+ );
+
+ const blocks =
+ position === "left" ? [draggedBlock, block] : [block, draggedBlock];
+ editor.removeBlocks([draggedBlock]);
+ editor.replaceBlocks(
+ [block],
+ [
+ {
+ type: "columnList",
+ children: blocks.map((b) => {
+ return {
+ type: "column",
+ children: [b],
+ };
+ }),
+ },
+ ]
+ );
+ }
+
+ return true;
+ },
+ },
+ });
+}
+
+class DropCursorView {
+ width: number;
+ color: string | undefined;
+ class: string | undefined;
+ cursorPos:
+ | { pos: number; position: "left" | "right" | "regular" }
+ | undefined = undefined;
+ element: HTMLElement | null = null;
+ timeout: ReturnType | undefined = undefined;
+ handlers: { name: string; handler: (event: Event) => void }[];
+
+ constructor(readonly editorView: EditorView, options: DropCursorOptions) {
+ this.width = options.width ?? 1;
+ this.color = options.color === false ? undefined : options.color || "black";
+ this.class = options.class;
+
+ this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
+ const handler = (e: Event) => {
+ (this as any)[name](e);
+ };
+ editorView.dom.addEventListener(name, handler);
+ return { name, handler };
+ });
+ }
+
+ destroy() {
+ this.handlers.forEach(({ name, handler }) =>
+ this.editorView.dom.removeEventListener(name, handler)
+ );
+ }
+
+ update(editorView: EditorView, prevState: EditorState) {
+ if (this.cursorPos != null && prevState.doc !== editorView.state.doc) {
+ if (this.cursorPos.pos > editorView.state.doc.content.size) {
+ this.setCursor(undefined);
+ } else {
+ // update overlay because document has changed
+ this.updateOverlay();
+ }
+ }
+ }
+
+ setCursor(
+ cursorPos:
+ | { pos: number; position: "left" | "right" | "regular" }
+ | undefined
+ ) {
+ if (
+ cursorPos === this.cursorPos ||
+ (cursorPos?.pos === this.cursorPos?.pos &&
+ cursorPos?.position === this.cursorPos?.position)
+ ) {
+ // no change
+ return;
+ }
+ this.cursorPos = cursorPos;
+ if (!cursorPos) {
+ this.element!.parentNode!.removeChild(this.element!);
+ this.element = null;
+ } else {
+ // update overlay because cursor has changed
+ this.updateOverlay();
+ }
+ }
+
+ updateOverlay() {
+ if (!this.cursorPos) {
+ throw new Error("updateOverlay called with no cursor position");
+ }
+ const $pos = this.editorView.state.doc.resolve(this.cursorPos.pos);
+ const isBlock = !$pos.parent.inlineContent;
+ let rect;
+ const editorDOM = this.editorView.dom;
+ const editorRect = editorDOM.getBoundingClientRect();
+ const scaleX = editorRect.width / editorDOM.offsetWidth;
+ const scaleY = editorRect.height / editorDOM.offsetHeight;
+ if (isBlock) {
+ const before = $pos.nodeBefore;
+ const after = $pos.nodeAfter;
+ if (before || after) {
+ if (
+ this.cursorPos.position === "left" ||
+ this.cursorPos.position === "right"
+ ) {
+ const block = this.editorView.nodeDOM(this.cursorPos.pos);
+
+ const blockRect = (block as HTMLElement).getBoundingClientRect();
+ const halfWidth = (this.width / 2) * scaleY;
+ const left =
+ this.cursorPos.position === "left"
+ ? blockRect.left
+ : blockRect.right;
+ rect = {
+ left: left - halfWidth,
+ right: left + halfWidth,
+ top: blockRect.top,
+ bottom: blockRect.bottom,
+ // left: blockRect.left,
+ // right: blockRect.right,
+ };
+ } else {
+ // regular logic
+ const node = this.editorView.nodeDOM(
+ this.cursorPos.pos - (before ? before.nodeSize : 0)
+ );
+ if (node) {
+ const nodeRect = (node as HTMLElement).getBoundingClientRect();
+
+ let top = before ? nodeRect.bottom : nodeRect.top;
+ if (before && after) {
+ // find the middle between the node above and below
+ top =
+ (top +
+ (
+ this.editorView.nodeDOM(this.cursorPos.pos) as HTMLElement
+ ).getBoundingClientRect().top) /
+ 2;
+ }
+ // console.log("node");
+ const halfWidth = (this.width / 2) * scaleY;
+
+ if (this.cursorPos.position === "regular") {
+ rect = {
+ left: nodeRect.left,
+ right: nodeRect.right,
+ top: top - halfWidth,
+ bottom: top + halfWidth,
+ };
+ }
+ }
+ }
+ }
+ }
+
+ if (!rect) {
+ // Cursor is an inline vertical dropcursor
+ const coords = this.editorView.coordsAtPos(this.cursorPos.pos);
+ const halfWidth = (this.width / 2) * scaleX;
+ rect = {
+ left: coords.left - halfWidth,
+ right: coords.left + halfWidth,
+ top: coords.top,
+ bottom: coords.bottom,
+ };
+ }
+
+ // Code below positions the cursor overlay based on the rect
+ const parent = this.editorView.dom.offsetParent as HTMLElement;
+ if (!this.element) {
+ this.element = parent.appendChild(document.createElement("div"));
+ if (this.class) {
+ this.element.className = this.class;
+ }
+ this.element.style.cssText =
+ "position: absolute; z-index: 50; pointer-events: none;";
+ if (this.color) {
+ this.element.style.backgroundColor = this.color;
+ }
+ }
+ this.element.classList.toggle("prosemirror-dropcursor-block", isBlock);
+ this.element.classList.toggle(
+ "prosemirror-dropcursor-vertical",
+ this.cursorPos.position !== "regular"
+ );
+ this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock);
+ let parentLeft, parentTop;
+ if (
+ !parent ||
+ (parent === document.body &&
+ getComputedStyle(parent).position === "static")
+ ) {
+ parentLeft = -window.scrollX;
+ parentTop = -window.scrollY;
+ } else {
+ const rect = parent.getBoundingClientRect();
+ const parentScaleX = rect.width / parent.offsetWidth;
+ const parentScaleY = rect.height / parent.offsetHeight;
+ parentLeft = rect.left - parent.scrollLeft * parentScaleX;
+ parentTop = rect.top - parent.scrollTop * parentScaleY;
+ }
+ this.element.style.left = (rect.left - parentLeft) / scaleX + "px";
+ this.element.style.top = (rect.top - parentTop) / scaleY + "px";
+ this.element.style.width = (rect.right - rect.left) / scaleX + "px";
+ this.element.style.height = (rect.bottom - rect.top) / scaleY + "px";
+ }
+
+ scheduleRemoval(timeout: number) {
+ clearTimeout(this.timeout);
+ this.timeout = setTimeout(() => this.setCursor(undefined), timeout);
+ }
+
+ // this gets executed on every mouse move when dragging (drag over)
+ dragover(event: DragEvent) {
+ if (!this.editorView.editable) {
+ return;
+ }
+ const pos = this.editorView.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+
+ // console.log("posatcoords", pos);
+
+ const node =
+ pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
+ const disableDropCursor = node && node.type.spec.disableDropCursor;
+ const disabled =
+ typeof disableDropCursor == "function"
+ ? disableDropCursor(this.editorView, pos, event)
+ : disableDropCursor;
+
+ if (pos && !disabled) {
+ let position: "regular" | "left" | "right" = "regular";
+ let target: number | null = pos.pos;
+
+ const posInfo = getTargetPosInfo(this.editorView.state, pos);
+
+ const block = this.editorView.nodeDOM(posInfo.posBeforeNode);
+ const blockRect = (block as HTMLElement).getBoundingClientRect();
+
+ if (
+ event.clientX <=
+ blockRect.left +
+ blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
+ ) {
+ position = "left";
+ target = posInfo.posBeforeNode;
+ }
+ if (
+ event.clientX >=
+ blockRect.right -
+ blockRect.width * PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP
+ ) {
+ position = "right";
+ target = posInfo.posBeforeNode;
+ }
+
+ // "regular logic"
+ if (
+ position === "regular" &&
+ this.editorView.dragging &&
+ this.editorView.dragging.slice
+ ) {
+ const point = dropPoint(
+ this.editorView.state.doc,
+ target,
+ this.editorView.dragging.slice
+ );
+
+ if (point != null) {
+ target = point;
+ }
+ }
+ // console.log("target", target);
+ this.setCursor({ pos: target, position });
+ this.scheduleRemoval(5000);
+ }
+ }
+
+ dragend() {
+ this.scheduleRemoval(20);
+ }
+
+ drop() {
+ this.scheduleRemoval(20);
+ }
+
+ dragleave(event: DragEvent) {
+ if (
+ event.target === this.editorView.dom ||
+ !this.editorView.dom.contains((event as any).relatedTarget)
+ ) {
+ this.setCursor(undefined);
+ }
+ }
+}
+
+/**
+ * From a position inside the document get the block that should be the "drop target" block.
+ */
+function getTargetPosInfo(
+ state: EditorState,
+ eventPos: { pos: number; inside: number }
+) {
+ const blockPos = getNearestBlockContainerPos(state.doc, eventPos.pos);
+
+ // if we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it
+ // why? because we want to insert a new column in the columnList, instead of a new columnList inside of the column
+ let resolved = state.doc.resolve(blockPos.posBeforeNode);
+ if (resolved.parent.type.name === "column") {
+ resolved = state.doc.resolve(resolved.before());
+ }
+ return {
+ posBeforeNode: resolved.pos,
+ node: resolved.nodeAfter!,
+ };
+}
diff --git a/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.tsx b/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.tsx
new file mode 100644
index 0000000000..291d2fab71
--- /dev/null
+++ b/packages/xl-multi-column/src/extensions/SuggestionMenu/getMultiColumnSlashMenuItems.tsx
@@ -0,0 +1,105 @@
+import {
+ BlockNoteEditor,
+ BlockSchema,
+ InlineContentSchema,
+ insertOrUpdateBlock,
+ StyleSchema,
+} from "@blocknote/core";
+import { DefaultReactSuggestionItem } from "@blocknote/react";
+import { TbColumns2, TbColumns3 } from "react-icons/tb";
+
+import { multiColumnSchema } from "../../blocks/schema.js";
+import { getMultiColumnDictionary } from "../../i18n/dictionary.js";
+
+export function checkMultiColumnBlocksInSchema<
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ editor: BlockNoteEditor
+): editor is BlockNoteEditor {
+ return (
+ "column" in editor.schema.blockSchema &&
+ editor.schema.blockSchema["columnList"] ===
+ multiColumnSchema.blockSchema["columnList"] &&
+ "column" in editor.schema.blockSchema &&
+ editor.schema.blockSchema["column"] ===
+ multiColumnSchema.blockSchema["column"]
+ );
+}
+
+export function getMultiColumnSlashMenuItems<
+ BSchema extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(editor: BlockNoteEditor) {
+ const items: Omit[] = [];
+
+ if (checkMultiColumnBlocksInSchema(editor)) {
+ items.push(
+ {
+ ...getMultiColumnDictionary(editor).slash_menu.two_columns,
+ icon: ,
+ onItemClick: () => {
+ insertOrUpdateBlock(editor, {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph" as any,
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph" as any,
+ },
+ ],
+ },
+ ],
+ });
+ },
+ },
+ {
+ ...getMultiColumnDictionary(editor).slash_menu.three_columns,
+ icon: ,
+ onItemClick: () => {
+ insertOrUpdateBlock(editor, {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph" as any,
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph" as any,
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph" as any,
+ },
+ ],
+ },
+ ],
+ });
+ },
+ }
+ );
+ }
+
+ return items;
+}
diff --git a/packages/xl-multi-column/src/i18n/dictionary.ts b/packages/xl-multi-column/src/i18n/dictionary.ts
new file mode 100644
index 0000000000..6feb2a62b8
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/dictionary.ts
@@ -0,0 +1,27 @@
+// function scramble(dict: any) {
+// const newDict: any = {} as any;
+
+import type { en } from "./locales/index.js";
+import { BlockNoteEditor } from "@blocknote/core";
+
+// for (const key in dict) {
+// if (typeof dict[key] === "object") {
+// newDict[key] = scramble(dict[key]);
+// } else {
+// newDict[key] = dict[key].split("").reverse().join("");
+// }
+// }
+
+// return newDict;
+// }
+
+export function getMultiColumnDictionary(
+ editor: BlockNoteEditor
+) {
+ if (!(editor.dictionary as any).multi_column) {
+ throw new Error("Multi-column dictionary not found");
+ }
+ return (editor.dictionary as any).multi_column as MultiColumnDictionary;
+}
+
+export type MultiColumnDictionary = typeof en;
diff --git a/packages/xl-multi-column/src/i18n/locales/ar.ts b/packages/xl-multi-column/src/i18n/locales/ar.ts
new file mode 100644
index 0000000000..7c6c8f1e82
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/ar.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const ar: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "عمودان",
+ subtext: "عمودان جنبًا إلى جنب",
+ aliases: ["أعمدة", "صف", "تقسيم"],
+ group: "الكتل الأساسية",
+ },
+ three_columns: {
+ title: "ثلاثة أعمدة",
+ subtext: "ثلاثة أعمدة جنبًا إلى جنب",
+ aliases: ["أعمدة", "صف", "تقسيم"],
+ group: "الكتل الأساسية",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/de.ts b/packages/xl-multi-column/src/i18n/locales/de.ts
new file mode 100644
index 0000000000..fb6056c9a3
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/de.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const de: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Zwei Spalten",
+ subtext: "Zwei Spalten nebeneinander",
+ aliases: ["Spalten", "Reihe", "teilen"],
+ group: "Grundlegende blöcke",
+ },
+ three_columns: {
+ title: "Drei Spalten",
+ subtext: "Drei Spalten nebeneinander",
+ aliases: ["Spalten", "Reihe", "teilen"],
+ group: "Grundlegende blöcke",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/en.ts b/packages/xl-multi-column/src/i18n/locales/en.ts
new file mode 100644
index 0000000000..f4d613d816
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/en.ts
@@ -0,0 +1,16 @@
+export const en = {
+ slash_menu: {
+ two_columns: {
+ title: "Two Columns",
+ subtext: "Two columns side by side",
+ aliases: ["columns", "row", "split"],
+ group: "Basic blocks",
+ },
+ three_columns: {
+ title: "Three Columns",
+ subtext: "Three columns side by side",
+ aliases: ["columns", "row", "split"],
+ group: "Basic blocks",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/es.ts b/packages/xl-multi-column/src/i18n/locales/es.ts
new file mode 100644
index 0000000000..65bd49493b
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/es.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const es: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Dos Columnas",
+ subtext: "Dos columnas lado a lado",
+ aliases: ["columnas", "fila", "dividir"],
+ group: "Bloques básicos",
+ },
+ three_columns: {
+ title: "Tres Columnas",
+ subtext: "Tres columnas lado a lado",
+ aliases: ["columnas", "fila", "dividir"],
+ group: "Bloques básicos",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/fr.ts b/packages/xl-multi-column/src/i18n/locales/fr.ts
new file mode 100644
index 0000000000..e2c39e48b1
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/fr.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const fr: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Deux Colonnes",
+ subtext: "Deux colonnes côte à côte",
+ aliases: ["colonnes", "rangée", "partager"],
+ group: "Blocs de base",
+ },
+ three_columns: {
+ title: "Trois Colonnes",
+ subtext: "Trois colonnes côte à côte",
+ aliases: ["colonnes", "rangée", "partager"],
+ group: "Blocs de base",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/hr.ts b/packages/xl-multi-column/src/i18n/locales/hr.ts
new file mode 100644
index 0000000000..6ef2e905a1
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/hr.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const hr: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Dva Stupca",
+ subtext: "Dva stupca jedan pored drugog",
+ aliases: ["stupci", "redak", "podijeli"],
+ group: "Osnovni blokovi",
+ },
+ three_columns: {
+ title: "Tri Stupca",
+ subtext: "Tri stupca jedan pored drugog",
+ aliases: ["stupci", "redak", "podijeli"],
+ group: "Osnovni blokovi",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/index.ts b/packages/xl-multi-column/src/i18n/locales/index.ts
new file mode 100644
index 0000000000..d17fc75f24
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/index.ts
@@ -0,0 +1,15 @@
+export * from "./ar.js";
+export * from "./de.js";
+export * from "./en.js";
+export * from "./es.js";
+export * from "./fr.js";
+export * from "./hr.js";
+export * from "./is.js";
+export * from "./ja.js";
+export * from "./ko.js";
+export * from "./nl.js";
+export * from "./pl.js";
+export * from "./pt.js";
+export * from "./ru.js";
+export * from "./vi.js";
+export * from "./zh.js";
diff --git a/packages/xl-multi-column/src/i18n/locales/is.ts b/packages/xl-multi-column/src/i18n/locales/is.ts
new file mode 100644
index 0000000000..a254e06d13
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/is.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const is: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Tvær Dálkar",
+ subtext: "Tvær dálkar hlið við hlið",
+ aliases: ["dálkar", "röð", "skipta"],
+ group: "Grunnblokkar",
+ },
+ three_columns: {
+ title: "Þrír Dálkar",
+ subtext: "Þrír dálkar hlið við hlið",
+ aliases: ["dálkar", "röð", "skipta"],
+ group: "Grunnblokkar",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/ja.ts b/packages/xl-multi-column/src/i18n/locales/ja.ts
new file mode 100644
index 0000000000..cee1548470
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/ja.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const ja: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "二列",
+ subtext: "二列並んで",
+ aliases: ["列", "行", "分割"],
+ group: "基本ブロック",
+ },
+ three_columns: {
+ title: "三列",
+ subtext: "三列並んで",
+ aliases: ["列", "行", "分割"],
+ group: "基本ブロック",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/ko.ts b/packages/xl-multi-column/src/i18n/locales/ko.ts
new file mode 100644
index 0000000000..82c52140b9
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/ko.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const ko: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "두 열",
+ subtext: "두 열 나란히",
+ aliases: ["열", "행", "분할"],
+ group: "기본 블록",
+ },
+ three_columns: {
+ title: "세 열",
+ subtext: "세 열 나란히",
+ aliases: ["열", "행", "분할"],
+ group: "기본 블록",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/nl.ts b/packages/xl-multi-column/src/i18n/locales/nl.ts
new file mode 100644
index 0000000000..69cc96aa67
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/nl.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const nl: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Twee Kolommen",
+ subtext: "Twee kolommen naast elkaar",
+ aliases: ["kolommen", "rij", "verdelen"],
+ group: "Basisblokken",
+ },
+ three_columns: {
+ title: "Drie Kolommen",
+ subtext: "Drie kolommen naast elkaar",
+ aliases: ["kolommen", "rij", "verdelen"],
+ group: "Basisblokken",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/pl.ts b/packages/xl-multi-column/src/i18n/locales/pl.ts
new file mode 100644
index 0000000000..08018fa7ff
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/pl.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const pl: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Dwie Kolumny",
+ subtext: "Dwie kolumny obok siebie",
+ aliases: ["kolumny", "rząd", "podzielić"],
+ group: "Podstawowe bloki",
+ },
+ three_columns: {
+ title: "Trzy Kolumny",
+ subtext: "Trzy kolumny obok siebie",
+ aliases: ["kolumny", "rząd", "podzielić"],
+ group: "Podstawowe bloki",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/pt.ts b/packages/xl-multi-column/src/i18n/locales/pt.ts
new file mode 100644
index 0000000000..b310c7dfaa
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/pt.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const pt: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Duas Colunas",
+ subtext: "Duas colunas lado a lado",
+ aliases: ["colunas", "linha", "dividir"],
+ group: "Blocos básicos",
+ },
+ three_columns: {
+ title: "Três Colunas",
+ subtext: "Três colunas lado a lado",
+ aliases: ["colunas", "linha", "dividir"],
+ group: "Blocos básicos",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/ru.ts b/packages/xl-multi-column/src/i18n/locales/ru.ts
new file mode 100644
index 0000000000..4b100856d0
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/ru.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const ru: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Два Столбца",
+ subtext: "Два столбца рядом",
+ aliases: ["столбцы", "ряд", "разделить"],
+ group: "Базовые блоки",
+ },
+ three_columns: {
+ title: "Три Столбца",
+ subtext: "Три столбца рядом",
+ aliases: ["столбцы", "ряд", "разделить"],
+ group: "Базовые блоки",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/vi.ts b/packages/xl-multi-column/src/i18n/locales/vi.ts
new file mode 100644
index 0000000000..bf8522b78d
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/vi.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const vi: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "Hai Cột",
+ subtext: "Hai cột cạnh nhau",
+ aliases: ["cột", "hàng", "chia"],
+ group: "Khối cơ bản",
+ },
+ three_columns: {
+ title: "Ba Cột",
+ subtext: "Ba cột cạnh nhau",
+ aliases: ["cột", "hàng", "chia"],
+ group: "Khối cơ bản",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/i18n/locales/zh.ts b/packages/xl-multi-column/src/i18n/locales/zh.ts
new file mode 100644
index 0000000000..339b0132d7
--- /dev/null
+++ b/packages/xl-multi-column/src/i18n/locales/zh.ts
@@ -0,0 +1,18 @@
+import type { MultiColumnDictionary } from "../dictionary.js";
+
+export const zh: MultiColumnDictionary = {
+ slash_menu: {
+ two_columns: {
+ title: "两列",
+ subtext: "两列并排",
+ aliases: ["列", "行", "分割"],
+ group: "基础",
+ },
+ three_columns: {
+ title: "三列",
+ subtext: "三列并排",
+ aliases: ["列", "行", "分割"],
+ group: "基础",
+ },
+ },
+};
diff --git a/packages/xl-multi-column/src/index.ts b/packages/xl-multi-column/src/index.ts
new file mode 100644
index 0000000000..5676e55a7a
--- /dev/null
+++ b/packages/xl-multi-column/src/index.ts
@@ -0,0 +1,7 @@
+import * as locales from "./i18n/locales/index.js";
+export { locales };
+export * from "./i18n/dictionary.js";
+export * from "./blocks/Columns/index.js";
+export * from "./blocks/schema.js";
+export * from "./extensions/DropCursor/MultiColumnDropCursorPlugin.js";
+export * from "./extensions/SuggestionMenu/getMultiColumnSlashMenuItems.js";
diff --git a/packages/xl-multi-column/src/pm-nodes/Column.ts b/packages/xl-multi-column/src/pm-nodes/Column.ts
new file mode 100644
index 0000000000..319d6f569f
--- /dev/null
+++ b/packages/xl-multi-column/src/pm-nodes/Column.ts
@@ -0,0 +1,87 @@
+import { createStronglyTypedTiptapNode } from "@blocknote/core";
+
+import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js";
+
+export const Column = createStronglyTypedTiptapNode({
+ name: "column",
+ group: "bnBlock childContainer",
+ // A block always contains content, and optionally a blockGroup which contains nested blocks
+ content: "blockContainer+",
+ priority: 40,
+ defining: true,
+ 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
new file mode 100644
index 0000000000..d760ae002e
--- /dev/null
+++ b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts
@@ -0,0 +1,44 @@
+import { createStronglyTypedTiptapNode } from "@blocknote/core";
+
+export const ColumnList = createStronglyTypedTiptapNode({
+ 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,
+
+ 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
new file mode 100644
index 0000000000..704b463b2d
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/__snapshots__/insertBlocks.test.ts.snap
@@ -0,0 +1,757 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test insertBlocks > Insert column list into paragraph 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Nested Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "nested-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {},
+ "type": "columnList",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test insertBlocks > Insert column with paragraph into 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": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "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",
+ },
+ ],
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test insertBlocks > Insert paragraph into column 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": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column List Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test insertBlocks > Insert valid column list with two columns 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": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Second Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/textCursorPosition.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/textCursorPosition.test.ts.snap
new file mode 100644
index 0000000000..1737df4765
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/__snapshots__/textCursorPosition.test.ts.snap
@@ -0,0 +1,169 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test getTextCursorPosition & setTextCursorPosition > Column 1`] = `
+{
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "nextBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "parentBlock": {
+ "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",
+ },
+ "prevBlock": undefined,
+}
+`;
+
+exports[`Test getTextCursorPosition & setTextCursorPosition > Column list 1`] = `
+{
+ "block": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "nextBlock": {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Column Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "id": "column-paragraph-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ "parentBlock": {
+ "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",
+ },
+ "prevBlock": undefined,
+}
+`;
diff --git a/packages/xl-multi-column/src/test/commands/__snapshots__/updateBlock.test.ts.snap b/packages/xl-multi-column/src/test/commands/__snapshots__/updateBlock.test.ts.snap
new file mode 100644
index 0000000000..4f0d931434
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/__snapshots__/updateBlock.test.ts.snap
@@ -0,0 +1,964 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test updateBlock > Update column list new children 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": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "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": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "3",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "2",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "column-list-0",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update column new children 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": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "0",
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update nested paragraph to column 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "2",
+ "props": {},
+ "type": "columnList",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update nested paragraph to column list 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {},
+ "type": "columnList",
+ },
+ ],
+ "content": [
+ {
+ "styles": {},
+ "text": "Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "id": "paragraph-0",
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update paragraph to column 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "2",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`Test updateBlock > Update paragraph to column list 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "2",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "1",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Inserted Column Paragraph",
+ "type": "text",
+ },
+ ],
+ "id": "4",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "3",
+ "props": {
+ "width": 1,
+ },
+ "type": "column",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {},
+ "type": "columnList",
+ },
+ {
+ "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": [],
+ "id": "trailing-paragraph",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
diff --git a/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts
new file mode 100644
index 0000000000..110c1ca26d
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/insertBlocks.test.ts
@@ -0,0 +1,206 @@
+import { describe, expect, it } from "vitest";
+
+import { setupTestEnv } from "../setupTestEnv.js";
+
+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();
+ });
+
+ 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();
+ });
+
+ 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();
+ });
+
+ it("Insert valid column list with two columns", () => {
+ getEditor().insertBlocks(
+ [
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Second Column Paragraph",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ "paragraph-0",
+ "after"
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Insert column with paragraph into column list", () => {
+ getEditor().insertBlocks(
+ [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ "column-0",
+ "before"
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Insert column list into paragraph", () => {
+ getEditor().insertBlocks(
+ [
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ "nested-paragraph-0",
+ "after"
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ // TODO: failing because prosemirror "insert" finds a place to insert this using the fitting algorithm
+ it.skip("Insert column into paragraph", () => {
+ // should throw an error as we don't allow columns to be children of paragraphs
+ expect(() => {
+ getEditor().insertBlocks(
+ [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ "nested-paragraph-0",
+ "after"
+ );
+ }).toThrow();
+ });
+
+ // TODO: failing because prosemirror "insert" finds a place to insert this using the fitting algorithm
+ it.skip("Insert paragraph into column list", () => {
+ // should throw an error as we don't allow paragraphs to be children of column lists
+ expect(() => {
+ getEditor().insertBlocks(
+ [
+ {
+ type: "paragraph",
+ content: "Inserted Column List Paragraph",
+ },
+ ],
+ "column-0",
+ "after"
+ );
+ }).toThrow();
+ });
+
+ it("Insert paragraph into column", () => {
+ getEditor().insertBlocks(
+ [
+ {
+ type: "paragraph",
+ content: "Inserted Column List Paragraph",
+ },
+ ],
+ "column-paragraph-0",
+ "after"
+ );
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+});
diff --git a/packages/xl-multi-column/src/test/commands/textCursorPosition.test.ts b/packages/xl-multi-column/src/test/commands/textCursorPosition.test.ts
new file mode 100644
index 0000000000..db6756487d
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/textCursorPosition.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+
+import { setupTestEnv } from "../setupTestEnv.js";
+
+const getEditor = setupTestEnv();
+
+describe("Test getTextCursorPosition & setTextCursorPosition", () => {
+ it("Column list", () => {
+ getEditor().setTextCursorPosition("column-list-0");
+
+ expect(getEditor().getTextCursorPosition()).toMatchSnapshot();
+ });
+
+ it("Column", () => {
+ getEditor().setTextCursorPosition("column-0");
+
+ expect(getEditor().getTextCursorPosition()).toMatchSnapshot();
+ });
+});
diff --git a/packages/xl-multi-column/src/test/commands/updateBlock.test.ts b/packages/xl-multi-column/src/test/commands/updateBlock.test.ts
new file mode 100644
index 0000000000..95c200eecf
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/updateBlock.test.ts
@@ -0,0 +1,212 @@
+import { describe, expect, it } from "vitest";
+
+import { setupTestEnv } from "../setupTestEnv.js";
+
+const getEditor = setupTestEnv();
+
+describe("Test updateBlock", () => {
+ it("Update column list new children", () => {
+ getEditor().updateBlock("column-list-0", {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update column list new empty children", () => {
+ // should throw because we don't allow empty columns / single columns
+ expect(() => {
+ getEditor().updateBlock("column-list-0", {
+ type: "columnList",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ });
+ }).toThrow();
+ });
+
+ it("Update column new children", () => {
+ getEditor().updateBlock("column-0", {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ });
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update paragraph to column list", () => {
+ getEditor().updateBlock("paragraph-0", {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update nested paragraph to column list", () => {
+ getEditor().updateBlock("nested-paragraph-0", {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update column to column list", () => {
+ // should throw an error as we don't allow a column list inside a columnlist
+ expect(() => {
+ getEditor().updateBlock("column-0", {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ },
+ ],
+ });
+ }).toThrow();
+ });
+
+ it("Update paragraph to column", () => {
+ getEditor().updateBlock("paragraph-0", {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ });
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update nested paragraph to column", () => {
+ getEditor().updateBlock("nested-paragraph-0", {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ });
+
+ expect(getEditor().document).toMatchSnapshot();
+ });
+
+ it("Update column list to column", () => {
+ // this would cause a column to become a child of a node that's not a column list, and should thus throw an error
+ expect(() => {
+ getEditor().updateBlock("column-list-0", {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ },
+ ],
+ });
+ }).toThrow();
+ });
+
+ it("Update column list to paragraph", () => {
+ // this would cause columns to become children of a paragraph, and should thus throw an error
+ expect(() => {
+ getEditor().updateBlock("column-list-0", {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ });
+ }).toThrow();
+ });
+
+ // TODO: this should throw, but currently doesn't, probably because of the fitting algorithm
+ it.skip("Update column to paragraph", () => {
+ expect(() => {
+ getEditor().updateBlock("column-0", {
+ type: "paragraph",
+ content: "Inserted Column Paragraph",
+ });
+ }).toThrow();
+ });
+});
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
new file mode 100644
index 0000000000..cbbcb6592b
--- /dev/null
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
@@ -0,0 +1 @@
+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
new file mode 100644
index 0000000000..5876b3bd03
--- /dev/null
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/nodeConversion.test.ts.snap b/packages/xl-multi-column/src/test/conversions/__snapshots__/nodeConversion.test.ts.snap
new file mode 100644
index 0000000000..b73d6a8253
--- /dev/null
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/nodeConversion.test.ts.snap
@@ -0,0 +1,118 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test BlockNote-Prosemirror conversion > Case: multi-column-schema > Convert multi-column to/from prosemirror 1`] = `
+{
+ "attrs": {
+ "id": "1",
+ },
+ "content": [
+ {
+ "attrs": {
+ "id": "2",
+ "width": 1,
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "id": "3",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "attrs": {
+ "textAlignment": "left",
+ },
+ "content": [
+ {
+ "text": "Column Paragraph 0",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "id": "4",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "attrs": {
+ "textAlignment": "left",
+ },
+ "content": [
+ {
+ "text": "Column Paragraph 1",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "column",
+ },
+ {
+ "attrs": {
+ "id": "5",
+ "width": 1,
+ },
+ "content": [
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "id": "6",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "attrs": {
+ "textAlignment": "left",
+ },
+ "content": [
+ {
+ "text": "Column Paragraph 2",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ {
+ "attrs": {
+ "backgroundColor": "default",
+ "id": "7",
+ "textColor": "default",
+ },
+ "content": [
+ {
+ "attrs": {
+ "textAlignment": "left",
+ },
+ "content": [
+ {
+ "text": "Column Paragraph 3",
+ "type": "text",
+ },
+ ],
+ "type": "paragraph",
+ },
+ ],
+ "type": "blockContainer",
+ },
+ ],
+ "type": "column",
+ },
+ ],
+ "type": "columnList",
+}
+`;
diff --git a/packages/xl-multi-column/src/test/conversions/htmlConversion.test.ts b/packages/xl-multi-column/src/test/conversions/htmlConversion.test.ts
new file mode 100644
index 0000000000..149a71d03d
--- /dev/null
+++ b/packages/xl-multi-column/src/test/conversions/htmlConversion.test.ts
@@ -0,0 +1,103 @@
+// @vitest-environment jsdom
+
+import {
+ BlockNoteEditor,
+ BlockSchema,
+ InlineContentSchema,
+ PartialBlock,
+ StyleSchema,
+ addIdsToBlocks,
+ createExternalHTMLExporter,
+ createInternalHTMLSerializer,
+ partialBlocksToBlocksForTesting,
+} from "@blocknote/core";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { multiColumnSchemaTestCases } from "./testCases.js";
+
+// TODO: code same from @blocknote/core, maybe create separate test util package
+async function convertToHTMLAndCompareSnapshots<
+ B extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema
+>(
+ editor: BlockNoteEditor,
+ blocks: PartialBlock[],
+ snapshotDirectory: string,
+ snapshotName: string
+) {
+ addIdsToBlocks(blocks);
+ const serializer = createInternalHTMLSerializer(editor.pmSchema, editor);
+ const internalHTML = serializer.serializeBlocks(blocks, {});
+ const internalHTMLSnapshotPath =
+ "./__snapshots__/" +
+ snapshotDirectory +
+ "/" +
+ snapshotName +
+ "/internal.html";
+ expect(internalHTML).toMatchFileSnapshot(internalHTMLSnapshotPath);
+
+ // turn the internalHTML back into blocks, and make sure no data was lost
+ const fullBlocks = partialBlocksToBlocksForTesting(
+ editor.schema.blockSchema,
+ blocks
+ );
+ const parsed = await editor.tryParseHTMLToBlocks(internalHTML);
+
+ expect(parsed).toStrictEqual(fullBlocks);
+
+ // Create the "external" HTML, which is a cleaned up HTML representation, but lossy
+ const exporter = createExternalHTMLExporter(editor.pmSchema, editor);
+ const externalHTML = exporter.exportBlocks(blocks, {});
+ const externalHTMLSnapshotPath =
+ "./__snapshots__/" +
+ snapshotDirectory +
+ "/" +
+ snapshotName +
+ "/external.html";
+ expect(externalHTML).toMatchFileSnapshot(externalHTMLSnapshotPath);
+}
+
+const testCases = [multiColumnSchemaTestCases];
+
+describe("Test multi-column HTML conversion", () => {
+ for (const testCase of testCases) {
+ describe("Case: " + testCase.name, () => {
+ let editor: BlockNoteEditor;
+ const div = document.createElement("div");
+ beforeEach(() => {
+ editor = testCase.createEditor();
+
+ // Note that we don't necessarily need to mount a root
+ // Currently, we do mount to a root so that it reflects the "production" use-case more closely.
+
+ // However, it would be nice to increased converage and share the same set of tests for these cases:
+ // - does render to a root
+ // - does not render to a root
+ // - runs in server (jsdom) environment using server-util
+ editor.mount(div);
+ });
+
+ afterEach(() => {
+ editor.mount(undefined);
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+
+ delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+ });
+
+ for (const document of testCase.documents) {
+ // eslint-disable-next-line no-loop-func
+ it("Convert " + document.name + " to HTML", async () => {
+ const nameSplit = document.name.split("/");
+ await convertToHTMLAndCompareSnapshots(
+ editor,
+ document.blocks,
+ nameSplit[0],
+ nameSplit[1]
+ );
+ });
+ }
+ });
+ }
+});
diff --git a/packages/xl-multi-column/src/test/conversions/nodeConversion.test.ts b/packages/xl-multi-column/src/test/conversions/nodeConversion.test.ts
new file mode 100644
index 0000000000..cd82664962
--- /dev/null
+++ b/packages/xl-multi-column/src/test/conversions/nodeConversion.test.ts
@@ -0,0 +1,84 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import {
+ BlockNoteEditor,
+ PartialBlock,
+ UniqueID,
+ blockToNode,
+ nodeToBlock,
+ partialBlockToBlockForTesting,
+} from "@blocknote/core";
+
+import { multiColumnSchemaTestCases } from "./testCases.js";
+
+function addIdsToBlock(block: PartialBlock) {
+ if (!block.id) {
+ block.id = UniqueID.options.generateID();
+ }
+ for (const child of block.children || []) {
+ addIdsToBlock(child);
+ }
+}
+
+function validateConversion(
+ block: PartialBlock,
+ editor: BlockNoteEditor
+) {
+ addIdsToBlock(block);
+ const node = blockToNode(block, editor.pmSchema, editor.schema.styleSchema);
+
+ expect(node).toMatchSnapshot();
+
+ const outputBlock = nodeToBlock(
+ node,
+ editor.schema.blockSchema,
+ editor.schema.inlineContentSchema,
+ editor.schema.styleSchema
+ );
+
+ const fullOriginalBlock = partialBlockToBlockForTesting(
+ editor.schema.blockSchema,
+ block
+ );
+
+ expect(outputBlock).toStrictEqual(fullOriginalBlock);
+}
+
+const testCases = [multiColumnSchemaTestCases];
+
+describe("Test BlockNote-Prosemirror conversion", () => {
+ for (const testCase of testCases) {
+ describe("Case: " + testCase.name, () => {
+ let editor: BlockNoteEditor;
+ const div = document.createElement("div");
+
+ beforeEach(() => {
+ editor = testCase.createEditor();
+ // Note that we don't necessarily need to mount a root
+ // Currently, we do mount to a root so that it reflects the "production" use-case more closely.
+
+ // However, it would be nice to increased converage and share the same set of tests for these cases:
+ // - does render to a root
+ // - does not render to a root
+ // - runs in server (jsdom) environment using server-util
+ editor.mount(div);
+ });
+
+ afterEach(() => {
+ editor.mount(undefined);
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+
+ delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+ });
+
+ for (const document of testCase.documents) {
+ // eslint-disable-next-line no-loop-func
+ it("Convert " + document.name + " to/from prosemirror", () => {
+ // NOTE: only converts first block
+ validateConversion(document.blocks[0], editor);
+ });
+ }
+ });
+ }
+});
diff --git a/packages/xl-multi-column/src/test/conversions/testCases.ts b/packages/xl-multi-column/src/test/conversions/testCases.ts
new file mode 100644
index 0000000000..fd3ba6dbeb
--- /dev/null
+++ b/packages/xl-multi-column/src/test/conversions/testCases.ts
@@ -0,0 +1,54 @@
+import { BlockNoteEditor, EditorTestCases } from "@blocknote/core";
+
+import { testEditorSchema } from "../setupTestEnv.js";
+
+export const multiColumnSchemaTestCases: EditorTestCases<
+ typeof testEditorSchema.blockSchema,
+ typeof testEditorSchema.inlineContentSchema,
+ typeof testEditorSchema.styleSchema
+> = {
+ name: "multi-column-schema",
+ createEditor: () => {
+ return BlockNoteEditor.create({
+ schema: testEditorSchema,
+ });
+ },
+ documents: [
+ {
+ name: "multi-column",
+ blocks: [
+ {
+ type: "columnList",
+ children: [
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Column Paragraph 0",
+ },
+ {
+ type: "paragraph",
+ content: "Column Paragraph 1",
+ },
+ ],
+ },
+ {
+ type: "column",
+ children: [
+ {
+ type: "paragraph",
+ content: "Column Paragraph 2",
+ },
+ {
+ type: "paragraph",
+ content: "Column Paragraph 3",
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+};
diff --git a/packages/xl-multi-column/src/test/setupTestEnv.ts b/packages/xl-multi-column/src/test/setupTestEnv.ts
new file mode 100644
index 0000000000..d4bb63ded4
--- /dev/null
+++ b/packages/xl-multi-column/src/test/setupTestEnv.ts
@@ -0,0 +1,99 @@
+import {
+ BlockNoteEditor,
+ BlockNoteSchema,
+ PartialBlock,
+} from "@blocknote/core";
+import { afterAll, beforeAll, beforeEach } from "vitest";
+
+import { withMultiColumn } from "../blocks/schema.js";
+
+export const testEditorSchema = withMultiColumn(BlockNoteSchema.create());
+
+export function setupTestEnv() {
+ let editor: BlockNoteEditor<
+ typeof testEditorSchema.blockSchema,
+ typeof testEditorSchema.inlineContentSchema,
+ typeof testEditorSchema.styleSchema
+ >;
+ const div = document.createElement("div");
+
+ beforeAll(() => {
+ editor = BlockNoteEditor.create({
+ schema: testEditorSchema,
+ });
+ editor.mount(div);
+ });
+
+ afterAll(() => {
+ editor.mount(undefined);
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+ });
+
+ beforeEach(() => {
+ editor.replaceBlocks(editor.document, testDocument);
+ });
+
+ return () => editor;
+}
+
+const testDocument: PartialBlock<
+ typeof testEditorSchema.blockSchema,
+ typeof testEditorSchema.inlineContentSchema,
+ typeof testEditorSchema.styleSchema
+>[] = [
+ {
+ id: "paragraph-0",
+ type: "paragraph",
+ content: "Paragraph 0",
+ children: [
+ {
+ id: "nested-paragraph-0",
+ type: "paragraph",
+ content: "Nested Paragraph 0",
+ },
+ ],
+ },
+ {
+ id: "column-list-0",
+ type: "columnList",
+ children: [
+ {
+ id: "column-0",
+ type: "column",
+ children: [
+ {
+ id: "column-paragraph-0",
+ type: "paragraph",
+ content: "Column Paragraph 0",
+ },
+ {
+ id: "column-paragraph-1",
+ type: "paragraph",
+ content: "Column Paragraph 1",
+ },
+ ],
+ },
+ {
+ id: "column-1",
+ type: "column",
+ children: [
+ {
+ id: "column-paragraph-2",
+ type: "paragraph",
+ content: "Column Paragraph 2",
+ },
+ {
+ id: "column-paragraph-3",
+ type: "paragraph",
+ content: "Column Paragraph 3",
+ },
+ ],
+ },
+ ],
+ },
+ {
+ id: "trailing-paragraph",
+ type: "paragraph",
+ },
+];
diff --git a/packages/xl-multi-column/src/vite-env.d.ts b/packages/xl-multi-column/src/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/packages/xl-multi-column/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/xl-multi-column/tsconfig.json b/packages/xl-multi-column/tsconfig.json
new file mode 100644
index 0000000000..8841d64b1c
--- /dev/null
+++ b/packages/xl-multi-column/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ESNext", "DOM"],
+ "moduleResolution": "Node",
+ "jsx": "react-jsx",
+ "strict": true,
+ "sourceMap": true,
+ "resolveJsonModule": true,
+ "esModuleInterop": true,
+ "noEmit": false,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true,
+ "outDir": "dist",
+ "declaration": true,
+ "declarationDir": "types",
+ "composite": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/xl-multi-column/vite.config.ts b/packages/xl-multi-column/vite.config.ts
new file mode 100644
index 0000000000..4aa18f6711
--- /dev/null
+++ b/packages/xl-multi-column/vite.config.ts
@@ -0,0 +1,49 @@
+import * as path from "path";
+import { webpackStats } from "rollup-plugin-webpack-stats";
+import { defineConfig } from "vite";
+import pkg from "./package.json";
+// import eslintPlugin from "vite-plugin-eslint";
+
+const deps = Object.keys(pkg.dependencies);
+
+// https://vitejs.dev/config/
+export default defineConfig((conf) => ({
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./vitestSetup.ts"],
+ },
+ plugins: [webpackStats()],
+ resolve: {
+ alias:
+ conf.command === "build"
+ ? ({} as Record)
+ : ({
+ // load live from sources with live reload working
+ "@blocknote/core": path.resolve(__dirname, "../core/src/"),
+ } as Record),
+ },
+ build: {
+ sourcemap: true,
+ lib: {
+ entry: path.resolve(__dirname, "src/index.ts"),
+ name: "blocknote-xl-multi-column",
+ fileName: "blocknote-xl-multi-column",
+ },
+ rollupOptions: {
+ // make sure to externalize deps that shouldn't be bundled
+ // into your library
+ external: (source: string) => {
+ if (deps.includes(source)) {
+ return true;
+ }
+ return source.startsWith("prosemirror-");
+ },
+ output: {
+ // Provide global variables to use in the UMD build
+ // for externalized deps
+ globals: {},
+ interop: "compat", // https://rollupjs.org/migration/#changed-defaults
+ },
+ },
+ },
+}));
diff --git a/packages/xl-multi-column/vitestSetup.ts b/packages/xl-multi-column/vitestSetup.ts
new file mode 100644
index 0000000000..caef6fecf2
--- /dev/null
+++ b/packages/xl-multi-column/vitestSetup.ts
@@ -0,0 +1,9 @@
+import { afterEach, beforeEach } from "vitest";
+
+beforeEach(() => {
+ (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
+});
+
+afterEach(() => {
+ delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+});
diff --git a/playground/package.json b/playground/package.json
index bd399eb55c..734b8fddb2 100644
--- a/playground/package.json
+++ b/playground/package.json
@@ -15,6 +15,7 @@
"@blocknote/ariakit": "^0.18.1",
"@blocknote/core": "^0.18.0",
"@blocknote/mantine": "^0.18.1",
+ "@blocknote/xl-multi-column": "^0.18.1",
"@blocknote/react": "^0.18.1",
"@blocknote/server-util": "^0.18.1",
"@blocknote/shadcn": "^0.18.1",
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 1941512e3f..48d6e1b52f 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -42,10 +42,32 @@
"slug": "basic"
}
},
+ {
+ "projectSlug": "multi-column",
+ "fullSlug": "basic/multi-column",
+ "pathFromRoot": "examples/01-basic/03-multi-column",
+ "config": {
+ "playground": true,
+ "docs": true,
+ "author": "yousefed",
+ "tags": [
+ "Basic",
+ "Blocks"
+ ],
+ "dependencies": {
+ "@blocknote/xl-multi-column": "latest"
+ } as any
+ },
+ "title": "Multi-Column Blocks",
+ "group": {
+ "pathFromRoot": "examples/01-basic",
+ "slug": "basic"
+ }
+ },
{
"projectSlug": "all-blocks",
"fullSlug": "basic/all-blocks",
- "pathFromRoot": "examples/01-basic/03-all-blocks",
+ "pathFromRoot": "examples/01-basic/04-all-blocks",
"config": {
"playground": true,
"docs": true,
@@ -54,7 +76,10 @@
"Basic",
"Blocks",
"Inline Content"
- ]
+ ],
+ "dependencies": {
+ "@blocknote/xl-multi-column": "latest"
+ } as any
},
"title": "Default Schema Showcase",
"group": {
@@ -65,7 +90,7 @@
{
"projectSlug": "removing-default-blocks",
"fullSlug": "basic/removing-default-blocks",
- "pathFromRoot": "examples/01-basic/04-removing-default-blocks",
+ "pathFromRoot": "examples/01-basic/05-removing-default-blocks",
"config": {
"playground": true,
"docs": true,
@@ -85,7 +110,7 @@
{
"projectSlug": "block-manipulation",
"fullSlug": "basic/block-manipulation",
- "pathFromRoot": "examples/01-basic/05-block-manipulation",
+ "pathFromRoot": "examples/01-basic/06-block-manipulation",
"config": {
"playground": true,
"docs": true,
@@ -104,7 +129,7 @@
{
"projectSlug": "selection-blocks",
"fullSlug": "basic/selection-blocks",
- "pathFromRoot": "examples/01-basic/06-selection-blocks",
+ "pathFromRoot": "examples/01-basic/07-selection-blocks",
"config": {
"playground": true,
"docs": true,
@@ -123,7 +148,7 @@
{
"projectSlug": "ariakit",
"fullSlug": "basic/ariakit",
- "pathFromRoot": "examples/01-basic/07-ariakit",
+ "pathFromRoot": "examples/01-basic/08-ariakit",
"config": {
"playground": true,
"docs": true,
@@ -141,7 +166,7 @@
{
"projectSlug": "shadcn",
"fullSlug": "basic/shadcn",
- "pathFromRoot": "examples/01-basic/08-shadcn",
+ "pathFromRoot": "examples/01-basic/09-shadcn",
"config": {
"playground": true,
"docs": true,
@@ -159,7 +184,7 @@
{
"projectSlug": "localization",
"fullSlug": "basic/localization",
- "pathFromRoot": "examples/01-basic/09-localization",
+ "pathFromRoot": "examples/01-basic/10-localization",
"config": {
"playground": true,
"docs": true,
diff --git a/playground/tsconfig.json b/playground/tsconfig.json
index 17fba07d26..344df3819e 100644
--- a/playground/tsconfig.json
+++ b/playground/tsconfig.json
@@ -23,6 +23,7 @@
{ "path": "./tsconfig.node.json" },
{ "path": "../packages/core/" },
{ "path": "../packages/react/" },
- { "path": "../packages/shadcn/" }
+ { "path": "../packages/shadcn/" },
+ { "path": "../packages/xl-multi-column/" }
]
}
diff --git a/playground/vite.config.ts b/playground/vite.config.ts
index ead9639a7a..83a9b701c5 100644
--- a/playground/vite.config.ts
+++ b/playground/vite.config.ts
@@ -50,6 +50,10 @@ export default defineConfig((conf) => ({
__dirname,
"../packages/shadcn/src/"
),
+ "@blocknote/xl-multi-column": path.resolve(
+ __dirname,
+ "../packages/xl-multi-column/src/"
+ ),
},
},
}));
diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png
index 7f5d6c4c9b..025144361e 100644
Binary files a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-chromium-linux.png differ
diff --git a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png
index 2a31a61200..12b21109da 100644
Binary files a/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/ariakit/ariakit.test.ts-snapshots/ariakit-emoji-picker-webkit-linux.png differ
diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png
index a891246a12..b31771551d 100644
Binary files a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-chromium-linux.png differ
diff --git a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png
index b112870f11..3c6df41907 100644
Binary files a/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/shadcn/shadcn.test.ts-snapshots/shadcn-emoji-picker-webkit-linux.png differ
diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png
index 50b29b5367..29be7bae5d 100644
Binary files a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-chromium-linux.png differ
diff --git a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png
index 56531fe120..83133efd93 100644
Binary files a/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png and b/tests/src/end-to-end/theming/theming.test.ts-snapshots/dark-emoji-picker-webkit-linux.png differ