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 @@ +

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__/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