From 1b44296e93152353bbfea9be3991472507c8c6d7 Mon Sep 17 00:00:00 2001 From: yousefed Date: Fri, 10 Jan 2025 10:30:57 +0100 Subject: [PATCH 01/30] wip --- examples/01-basic/01-minimal/App.tsx | 2 +- .../02-liveblocks/tsconfig.json | 14 +- package-lock.json | 357 ++++++++---------- .../src/api/nodeConversions/nodeToBlock.ts | 5 +- packages/core/src/editor/Block.css | 10 +- packages/core/src/editor/BlockNoteEditor.ts | 49 ++- .../core/src/editor/BlockNoteExtensions.ts | 6 + packages/core/src/editor/editor.css | 4 + .../src/extensions/Comments/CommentMark.ts | 45 +++ .../src/extensions/Comments/CommentsPlugin.ts | 336 +++++++++++++++++ packages/mantine/src/comments/Composer.tsx | 39 ++ packages/mantine/src/index.tsx | 5 +- .../src/components/Comments/Composer.tsx | 55 +++ .../Comments/FloatingComposerController.tsx | 76 ++++ .../Comments/FloatingThreadsController.tsx | 76 ++++ .../DefaultButtons/AddCommentButton.tsx | 5 +- .../react/src/editor/BlockNoteDefaultUI.tsx | 5 + .../react/src/editor/ComponentsContext.tsx | 8 + playground/package.json | 9 +- playground/src/style.css | 4 + playground/tsconfig.json | 2 + 21 files changed, 881 insertions(+), 231 deletions(-) create mode 100644 packages/core/src/extensions/Comments/CommentMark.ts create mode 100644 packages/core/src/extensions/Comments/CommentsPlugin.ts create mode 100644 packages/mantine/src/comments/Composer.tsx create mode 100644 packages/react/src/components/Comments/Composer.tsx create mode 100644 packages/react/src/components/Comments/FloatingComposerController.tsx create mode 100644 packages/react/src/components/Comments/FloatingThreadsController.tsx diff --git a/examples/01-basic/01-minimal/App.tsx b/examples/01-basic/01-minimal/App.tsx index a3b92bafd2..d4fd6f2e12 100644 --- a/examples/01-basic/01-minimal/App.tsx +++ b/examples/01-basic/01-minimal/App.tsx @@ -5,7 +5,7 @@ import { useCreateBlockNote } from "@blocknote/react"; export default function App() { // Creates a new editor instance. - const editor = useCreateBlockNote(); + const editor = useCreateBlockNote({}); // Renders the editor instance using a React component. return ; diff --git a/examples/07-collaboration/02-liveblocks/tsconfig.json b/examples/07-collaboration/02-liveblocks/tsconfig.json index 1bd8ab3c57..4a76cf4c7d 100644 --- a/examples/07-collaboration/02-liveblocks/tsconfig.json +++ b/examples/07-collaboration/02-liveblocks/tsconfig.json @@ -3,11 +3,7 @@ "compilerOptions": { "target": "ESNext", "useDefineForClassFields": true, - "lib": [ - "DOM", - "DOM.Iterable", - "ESNext" - ], + "lib": ["DOM", "DOM.Iterable", "ESNext"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": false, @@ -22,10 +18,8 @@ "jsx": "react-jsx", "composite": true }, - "include": [ - "." - ], - "__ADD_FOR_LOCAL_DEV_references": [ + "include": ["."], + "references": [ { "path": "../../../packages/core/" }, @@ -33,4 +27,4 @@ "path": "../../../packages/react/" } ] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index a1ed9a79e0..6da0a2ab6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,146 @@ "typescript": "^5.3.3" } }, + "../liveblocks/packages/liveblocks-client": { + "name": "@liveblocks/client", + "version": "2.15.2", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/core": "2.15.2" + }, + "devDependencies": { + "@liveblocks/eslint-config": "*", + "@liveblocks/jest-config": "*", + "@types/ws": "^8.5.10", + "dotenv": "^16.4.5", + "msw": "^0.39.1", + "ws": "^8.17.1" + } + }, + "../liveblocks/packages/liveblocks-react": { + "name": "@liveblocks/react", + "version": "2.15.2", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/client": "2.15.2", + "@liveblocks/core": "2.15.2" + }, + "devDependencies": { + "@liveblocks/eslint-config": "*", + "@liveblocks/jest-config": "*", + "@liveblocks/query-parser": "^0.0.4", + "@testing-library/jest-dom": "6.4.6", + "@testing-library/react": "14.1.2", + "date-fns": "^3.6.0", + "eslint-plugin-react-hooks": "^4.6.2", + "itertools": "^2.3.2", + "msw": "1.3.2", + "react-error-boundary": "^4.0.13" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc" + } + }, + "../liveblocks/packages/liveblocks-react-blocknote": { + "version": "2.15.2", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/client": "2.15.2", + "@liveblocks/core": "2.15.2", + "@liveblocks/react": "2.15.2", + "@liveblocks/react-tiptap": "2.15.2", + "@liveblocks/react-ui": "2.15.2", + "@liveblocks/yjs": "2.15.2", + "@tiptap/core": "^2.7.2" + }, + "devDependencies": { + "@liveblocks/eslint-config": "*", + "@liveblocks/jest-config": "*", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^5.0.5", + "@rollup/plugin-typescript": "^11.1.2", + "@testing-library/jest-dom": "^5.16.5", + "@types/use-sync-external-store": "^0.0.6", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "msw": "^0.27.1", + "rollup": "^3.28.0", + "rollup-plugin-dts": "^5.3.1", + "rollup-plugin-esbuild": "^5.0.0", + "rollup-plugin-preserve-directives": "^0.2.0", + "stylelint": "^15.10.2", + "stylelint-config-standard": "^34.0.0", + "stylelint-order": "^6.0.3", + "stylelint-plugin-logical-css": "^0.13.2" + }, + "peerDependencies": { + "@blocknote/core": "^0.19.1", + "@blocknote/react": "^0.19.1", + "@tiptap/core": "^2.7.2", + "react": "^16.14.0 || ^17 || ^18", + "react-dom": "^16.14.0 || ^17 || ^18" + } + }, + "../liveblocks/packages/liveblocks-react-ui": { + "name": "@liveblocks/react-ui", + "version": "2.15.2", + "license": "Apache-2.0", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@liveblocks/client": "2.15.2", + "@liveblocks/core": "2.15.2", + "@liveblocks/react": "2.15.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toggle": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.3", + "react-virtuoso": "^4.12.0", + "slate": "^0.110.2", + "slate-history": "^0.110.3", + "slate-hyperscript": "^0.100.0", + "slate-react": "^0.110.3" + }, + "devDependencies": { + "@liveblocks/eslint-config": "*", + "@liveblocks/jest-config": "*", + "@liveblocks/rollup-config": "*", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^13.1.1", + "emojibase": "^15.3.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "msw": "^0.27.1", + "rollup": "3.28.0", + "stylelint": "^15.10.2", + "stylelint-config-standard": "^34.0.0", + "stylelint-order": "^6.0.3", + "stylelint-plugin-logical-css": "^0.13.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc" + } + }, + "../liveblocks/packages/liveblocks-yjs": { + "name": "@liveblocks/yjs", + "version": "2.15.2", + "license": "Apache-2.0", + "dependencies": { + "@liveblocks/client": "2.15.2", + "@liveblocks/core": "2.15.2", + "js-base64": "^3.7.7", + "y-indexeddb": "^9.0.12" + }, + "devDependencies": { + "@liveblocks/eslint-config": "*", + "@liveblocks/jest-config": "*", + "@testing-library/jest-dom": "^6.4.6", + "msw": "^0.47.4" + }, + "peerDependencies": { + "yjs": "^13.6.1" + } + }, "docs": { "version": "0.22.0", "dependencies": { @@ -4703,11 +4843,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@juggle/resize-observer": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", - "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" - }, "node_modules/@lerna/add": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/@lerna/add/-/add-5.6.2.tgz", @@ -5773,99 +5908,24 @@ } }, "node_modules/@liveblocks/client": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@liveblocks/client/-/client-2.11.0.tgz", - "integrity": "sha512-ZRayUwwOzucYn4QqOTz0vcQSZlqGaP8TBROzE9Ye/PwfACNasyfa3roZqfizhBlYlYQQ/8qQlvGl2n3LPf8Byg==", - "dependencies": { - "@liveblocks/core": "2.11.0" - } - }, - "node_modules/@liveblocks/core": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@liveblocks/core/-/core-2.11.0.tgz", - "integrity": "sha512-2WQlJvJ1NVJ/CpKhDNJJHXrh2Yzz6eXchqn64JqTHk5TLGbx1s4kaTahEXaAOXmu2abnJWnv06v1mhv3YXYg+A==" + "resolved": "../liveblocks/packages/liveblocks-client", + "link": true }, "node_modules/@liveblocks/react": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@liveblocks/react/-/react-2.11.0.tgz", - "integrity": "sha512-uAPEh9ZS03ANTnbbU5PTiFVusI05H7EqOH4aDVaKDrogkKi70RYbCek4PKY8TpK2vLhmutBxyVd3tp2uBwvTFQ==", - "dependencies": { - "@liveblocks/client": "2.11.0", - "@liveblocks/core": "2.11.0", - "use-sync-external-store": "^1.2.2" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" - } - }, - "node_modules/@liveblocks/react-ui": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@liveblocks/react-ui/-/react-ui-2.11.0.tgz", - "integrity": "sha512-aBrgLWclSoV+3tOXxf45pL6Uaf8mRvHTmda79kwyCB/8yjqoeqNe2PThcELL8WWyaZsl7XU6Bgjpat3zY5rRHQ==", - "dependencies": { - "@floating-ui/react-dom": "^2.1.2", - "@liveblocks/client": "2.11.0", - "@liveblocks/core": "2.11.0", - "@liveblocks/react": "2.11.0", - "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.3", - "react-virtuoso": "^4.12.0", - "slate": "^0.110.2", - "slate-history": "^0.110.3", - "slate-hyperscript": "^0.100.0", - "slate-react": "^0.110.3", - "use-sync-external-store": "^1.2.2" - }, - "peerDependencies": { - "react": "^16.14.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" - } + "resolved": "../liveblocks/packages/liveblocks-react", + "link": true }, - "node_modules/@liveblocks/react-ui/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "node_modules/@liveblocks/react-blocknote": { + "resolved": "../liveblocks/packages/liveblocks-react-blocknote", + "link": true }, - "node_modules/@liveblocks/react-ui/node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } + "node_modules/@liveblocks/react-ui": { + "resolved": "../liveblocks/packages/liveblocks-react-ui", + "link": true }, "node_modules/@liveblocks/yjs": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@liveblocks/yjs/-/yjs-2.11.0.tgz", - "integrity": "sha512-o9RfjrVUMW4htHvHGwXxptZxnjC+evEvEkBqS2uPhOBBtOSMe15rWqzhoHqRXngkMUs8BIZ0EOkM5a0qsFIF0w==", - "dependencies": { - "@liveblocks/client": "2.11.0", - "@liveblocks/core": "2.11.0", - "js-base64": "^3.7.7" - }, - "peerDependencies": { - "yjs": "^13.6.1" - } + "resolved": "../liveblocks/packages/liveblocks-yjs", + "link": true }, "node_modules/@mantine/core": { "version": "7.10.1", @@ -15685,18 +15745,6 @@ "node": ">=8" } }, - "node_modules/direction": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/direction/-/direction-1.0.4.tgz", - "integrity": "sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==", - "bin": { - "direction": "cli.js" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -19186,15 +19234,6 @@ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" }, - "node_modules/immer": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", - "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -19724,11 +19763,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-hotkey": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz", - "integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==" - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -20291,11 +20325,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-base64": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.7.tgz", - "integrity": "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -26616,18 +26645,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/react-virtuoso": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.12.0.tgz", - "integrity": "sha512-oHrKlU7xHsrnBQ89ecZoMPAK0tHnI9s1hsFW3KKg5ZGeZ5SWvbGhg/QFJFY4XETAzoCUeu+Xaxn1OUb/PGtPlA==", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16 || >=17 || >= 18", - "react-dom": ">=16 || >=17 || >= 18" - } - }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -28270,57 +28287,6 @@ "node": ">=8" } }, - "node_modules/slate": { - "version": "0.110.2", - "resolved": "https://registry.npmjs.org/slate/-/slate-0.110.2.tgz", - "integrity": "sha512-4xGULnyMCiEQ0Ml7JAC1A6HVE6MNpPJU7Eq4cXh1LxlrR0dFXC3XC+rNfQtUJ7chHoPkws57x7DDiWiZAt+PBA==", - "dependencies": { - "immer": "^10.0.3", - "is-plain-object": "^5.0.0", - "tiny-warning": "^1.0.3" - } - }, - "node_modules/slate-history": { - "version": "0.110.3", - "resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.110.3.tgz", - "integrity": "sha512-sgdff4Usdflmw5ZUbhDkxFwCBQ2qlDKMMkF93w66KdV48vHOgN2BmLrf+2H8SdX8PYIpP/cTB0w8qWC2GwhDVA==", - "dependencies": { - "is-plain-object": "^5.0.0" - }, - "peerDependencies": { - "slate": ">=0.65.3" - } - }, - "node_modules/slate-hyperscript": { - "version": "0.100.0", - "resolved": "https://registry.npmjs.org/slate-hyperscript/-/slate-hyperscript-0.100.0.tgz", - "integrity": "sha512-fb2KdAYg6RkrQGlqaIi4wdqz3oa0S4zKNBJlbnJbNOwa23+9FLD6oPVx9zUGqCSIpy+HIpOeqXrg0Kzwh/Ii4A==", - "dependencies": { - "is-plain-object": "^5.0.0" - }, - "peerDependencies": { - "slate": ">=0.65.3" - } - }, - "node_modules/slate-react": { - "version": "0.110.3", - "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.110.3.tgz", - "integrity": "sha512-AS8PPjwmsFS3Lq0MOEegLVlFoxhyos68G6zz2nW4sh3WeTXV7pX0exnwtY1a/docn+J3LGQO11aZXTenPXA/kg==", - "dependencies": { - "@juggle/resize-observer": "^3.4.0", - "direction": "^1.0.4", - "is-hotkey": "^0.2.0", - "is-plain-object": "^5.0.0", - "lodash": "^4.17.21", - "scroll-into-view-if-needed": "^3.1.0", - "tiny-invariant": "1.3.1" - }, - "peerDependencies": { - "react": ">=18.2.0", - "react-dom": ">=18.2.0", - "slate": ">=0.99.0" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -29172,16 +29138,6 @@ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" }, - "node_modules/tiny-invariant": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", - "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -32444,10 +32400,11 @@ "@blocknote/xl-multi-column": "^0.22.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", - "@liveblocks/client": "^2.11.0", - "@liveblocks/react": "^2.11.0", - "@liveblocks/react-ui": "^2.11.0", - "@liveblocks/yjs": "^2.11.0", + "@liveblocks/client": "file:../../liveblocks/packages/liveblocks-client", + "@liveblocks/react": "file:../../liveblocks/packages/liveblocks-react", + "@liveblocks/react-blocknote": "file:../../liveblocks/packages/liveblocks-react-blocknote", + "@liveblocks/react-ui": "file:../../liveblocks/packages/liveblocks-react-ui", + "@liveblocks/yjs": "file:../../liveblocks/packages/liveblocks-yjs", "@mantine/core": "^7.10.1", "@mui/icons-material": "^5.16.1", "@mui/material": "^5.16.1", diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 4d73393dde..455499b01d 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -131,7 +131,10 @@ export function contentNodeToInlineContent< } else { const config = styleSchema[mark.type.name]; if (!config) { - if (mark.type.name === "liveblocksCommentMark") { + if ( + mark.type.spec.group?.includes("blocknoteIgnore") || + mark.type.name === "liveblocksCommentMark" + ) { // TODO continue; } diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 54849c2b6c..ace7353fce 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -336,7 +336,7 @@ NESTED BLOCKS .bn-editor[contenteditable="true"] [data-file-block] .bn-add-file-button:hover, [data-file-block] .bn-file-name-with-icon:hover, -.ProseMirror-selectednode .bn-file-name-with-icon{ +.ProseMirror-selectednode .bn-file-name-with-icon { background-color: rgb(225, 225, 225); } @@ -523,3 +523,11 @@ NESTED BLOCKS .bn-block-column:last-child { padding-right: 0; } + +.bn-thread-mark { + background: rgba(255, 200, 0, 0.15); +} + +.bn-thread-mark-selected { + background: rgba(255, 200, 0, 0.25); +} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 0231b47735..9b19014c81 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -3,18 +3,14 @@ import { EditorOptions, Extension, getSchema, + isNodeSelection, Mark, + posToDOMRect, Node as TipTapNode, } from "@tiptap/core"; import { Node, Schema } from "prosemirror-model"; // import "./blocknote.css"; import * as Y from "yjs"; -import { - getBlock, - getNextBlock, - getParentBlock, - getPrevBlock, -} from "../api/blockManipulation/getBlock/getBlock.js"; import { insertBlocks } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import { moveBlocksDown, @@ -29,15 +25,21 @@ import { 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"; -import { insertContentAt } from "../api/blockManipulation/insertContentAt.js"; import { - getTextCursorPosition, - setTextCursorPosition, -} from "../api/blockManipulation/selections/textCursorPosition/textCursorPosition.js"; + getBlock, + getNextBlock, + getParentBlock, + getPrevBlock, +} from "../api/blockManipulation/getBlock/getBlock.js"; +import { insertContentAt } from "../api/blockManipulation/insertContentAt.js"; import { getSelection, setSelection, } from "../api/blockManipulation/selections/selection.js"; +import { + getTextCursorPosition, + setTextCursorPosition, +} from "../api/blockManipulation/selections/textCursorPosition/textCursorPosition.js"; import { createExternalHTMLExporter } from "../api/exporters/html/externalHTMLExporter.js"; import { blocksToMarkdown } from "../api/exporters/markdown/markdownExporter.js"; import { HTMLToBlocks } from "../api/parsers/html/parseHTML.js"; @@ -89,11 +91,12 @@ import { en } from "../i18n/locales/index.js"; import { Plugin, Transaction } from "@tiptap/pm/state"; import { dropCursor } from "prosemirror-dropcursor"; +import { EditorView } from "prosemirror-view"; import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js"; import { inlineContentToNodes } from "../api/nodeConversions/blockToNode.js"; import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js"; +import { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.js"; import "../style.css"; -import { EditorView } from "prosemirror-view"; export type BlockNoteExtension = | AnyExtension @@ -329,6 +332,7 @@ export class BlockNoteEditor< ISchema, SSchema >; + public readonly comments?: CommentsPlugin; /** * The `uploadFile` method is what the editor uses when files need to be uploaded (for example when selecting an image to upload). @@ -441,6 +445,7 @@ export class BlockNoteEditor< this.suggestionMenus = this.extensions["suggestionMenus"] as any; this.filePanel = this.extensions["filePanel"] as any; this.tableHandles = this.extensions["tableHandles"] as any; + this.comments = this.extensions["comments"] as any; if (newOptions.uploadFile) { const uploadFile = newOptions.uploadFile; @@ -1206,6 +1211,28 @@ export class BlockNoteEditor< }; } + public getSelectionBoundingBox() { + if (!this.prosemirrorView) { + return undefined; + } + const state = this.prosemirrorView?.state; + const { selection } = state; + + // support for CellSelections + const { ranges } = selection; + const from = Math.min(...ranges.map((range) => range.$from.pos)); + const to = Math.max(...ranges.map((range) => range.$to.pos)); + + if (isNodeSelection(selection)) { + const node = this.prosemirrorView.nodeDOM(from) as HTMLElement; + if (node) { + return node.getBoundingClientRect(); + } + } + + return posToDOMRect(this.prosemirrorView, from, to); + } + public openSuggestionMenu( triggerCharacter: string, pluginState?: { diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index b6ce68a7c7..33052cb241 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -15,6 +15,8 @@ import { createDropFileExtension } from "../api/clipboard/fromClipboard/fileDrop import { createPasteFromClipboardExtension } from "../api/clipboard/fromClipboard/pasteExtension.js"; import { createCopyToClipboardExtension } from "../api/clipboard/toClipboard/copyExtension.js"; import { BackgroundColorExtension } from "../extensions/BackgroundColor/BackgroundColorExtension.js"; +import { CommentMark } from "../extensions/Comments/CommentMark.js"; +import { CommentsPlugin } from "../extensions/Comments/CommentsPlugin.js"; import { FilePanelProsemirrorPlugin } from "../extensions/FilePanel/FilePanelPlugin.js"; import { FormattingToolbarProsemirrorPlugin } from "../extensions/FormattingToolbar/FormattingToolbarPlugin.js"; import { KeyboardShortcutsExtension } from "../extensions/KeyboardShortcuts/KeyboardShortcutsExtension.js"; @@ -120,6 +122,9 @@ export const getBlockNoteExtensions = < ret["nodeSelectionKeyboard"] = new NodeSelectionKeyboardPlugin(); + // TODO + ret["comments"] = new CommentsPlugin(opts.editor, CommentMark.name); + const disableExtensions: string[] = opts.disableExtensions || []; for (const ext of disableExtensions) { delete ret[ext]; @@ -240,6 +245,7 @@ const getTipTapExtensions = < ...(opts.trailingBlock === undefined || opts.trailingBlock ? [TrailingNode] : []), + CommentMark, ]; if (opts.collaboration) { diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index ba9dffb39d..5f3d97a6df 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -10,6 +10,10 @@ --N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */ } +.bn-comment-composer .bn-editor { + padding: 0; +} + /* bn-root should be applied to all top-level elements diff --git a/packages/core/src/extensions/Comments/CommentMark.ts b/packages/core/src/extensions/Comments/CommentMark.ts new file mode 100644 index 0000000000..f7c6f7faa8 --- /dev/null +++ b/packages/core/src/extensions/Comments/CommentMark.ts @@ -0,0 +1,45 @@ +import { Mark, mergeAttributes } from "@tiptap/core"; + +export const CommentMark = Mark.create({ + name: "comment", + excludes: "", + inclusive: false, + keepOnSplit: true, + group: "blocknoteIgnore", // ignore in blocknote json + + addAttributes() { + // Return an object with attribute configuration + return { + // TODO: check if needed + orphan: { + parseHTML: (element) => !!element.getAttribute("data-orphan"), + renderHTML: (attributes) => { + return (attributes as { orphan: boolean }).orphan + ? { + "data-orphan": "true", + } + : {}; + }, + default: false, + }, + threadId: { + parseHTML: (element) => element.getAttribute("data-lb-thread-id"), + renderHTML: (attributes) => { + return { + "data-lb-thread-id": (attributes as { threadId: string }).threadId, + }; + }, + default: "", + }, + }; + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + return [ + "span", + mergeAttributes(HTMLAttributes, { + class: "bn-thread-mark", + }), + ]; + }, +}); diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts new file mode 100644 index 0000000000..0a01f421fa --- /dev/null +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -0,0 +1,336 @@ +import { Node } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import { v4 } from "uuid"; +import * as Y from "yjs"; +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { EventEmitter } from "../../util/EventEmitter.js"; +const PLUGIN_KEY = new PluginKey(`blocknote-comments`); + +enum CommentsPluginActions { + SET_SELECTED_THREAD_ID = "SET_SELECTED_THREAD_ID", +} + +type CommentsPluginAction = { + name: CommentsPluginActions; + data: string | null; +}; + +type CommentsPluginState = { + threadPositions: Map; + selectedThreadId: string | null; + // selectedThreadPos: number | null; + decorations: DecorationSet; +}; + +function updateState( + doc: Node, + selectedThreadId: string | null, + markType: string +) { + const threadPositions = new Map(); + const decorations: Decoration[] = []; + // find all thread marks and store their position + create decoration for selected thread + doc.descendants((node, pos) => { + node.marks.forEach((mark) => { + if (mark.type.name === markType) { + const thisThreadId = (mark.attrs as { threadId: string | undefined }) + .threadId; + if (!thisThreadId) { + return; + } + const from = pos; + const to = from + node.nodeSize; + + // FloatingThreads component uses "to" as the position, so always store the largest "to" found + // AnchoredThreads component uses "from" as the position, so always store the smallest "from" found + const currentPosition = threadPositions.get(thisThreadId) ?? { + from: Infinity, + to: 0, + }; + threadPositions.set(thisThreadId, { + from: Math.min(from, currentPosition.from), + to: Math.max(to, currentPosition.to), + }); + + if (selectedThreadId === thisThreadId) { + decorations.push( + Decoration.inline(from, to, { + class: "bn-thread-mark-selected", + }) + ); + } + } + }); + }); + return { + decorations: DecorationSet.create(doc, decorations), + selectedThreadId, + threadPositions, + selectedThreadPos: + selectedThreadId !== null + ? threadPositions.get(selectedThreadId)?.to ?? null + : null, + }; +} + +export class CommentsPlugin extends EventEmitter { + public readonly plugin: Plugin; + private provider: CommentProvider; + private pendingComment = false; + + constructor( + private readonly editor: BlockNoteEditor, + private readonly markType: string + ) { + super(); + + const doc = new Y.Doc(); + this.provider = new YjsCommentProvider( + editor, + "blablauserid", + doc.getMap("threads") + ); + + // TODO + setTimeout(() => { + editor.onSelectionChange(() => { + // TODO: filter out yjs transactions + if (this.pendingComment) { + this.pendingComment = false; + this.emit("update", { + pendingComment: this.pendingComment, + }); + } + }); + }, 600); + + this.plugin = new Plugin({ + key: PLUGIN_KEY, + state: { + init() { + return { + threadPositions: new Map(), + selectedThreadId: null, + decorations: DecorationSet.empty, + } satisfies CommentsPluginState; + }, + apply(tr, state) { + const action = tr.getMeta(PLUGIN_KEY) as CommentsPluginAction; + if (!tr.docChanged && !action) { + return state; + } + + if (!action) { + // Doc changed, but no action, just update rects + return updateState(tr.doc, state.selectedThreadId, markType); + } + // handle actions, possibly support more actions + if ( + action.name === CommentsPluginActions.SET_SELECTED_THREAD_ID && + state.selectedThreadId !== action.data + ) { + return updateState(tr.doc, action.data, markType); + } + + return state; + }, + }, + props: { + decorations(state) { + return PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty; + }, + handleClick: (view, pos, event) => { + if (event.button !== 0) { + return; + } + + const selectThread = (threadId: string | null) => { + view.dispatch( + view.state.tr.setMeta(PLUGIN_KEY, { + name: CommentsPluginActions.SET_SELECTED_THREAD_ID, + data: threadId, + }) + ); + }; + + const node = view.state.doc.nodeAt(pos); + if (!node) { + selectThread(null); + return; + } + const commentMark = node.marks.find( + (mark) => mark.type.name === markType + ); + // don't allow selecting orphaned threads + if (commentMark?.attrs.orphan) { + selectThread(null); + return; + } + const threadId = commentMark?.attrs.threadId as string | undefined; + selectThread(threadId ?? null); + }, + }, + }); + } + + public onUpdate(callback: (state: { pendingComment: boolean }) => void) { + return this.on("update", callback); + } + + public addPendingComment() { + this.pendingComment = true; + this.emit("update", { + pendingComment: this.pendingComment, + }); + } + + public async createThread(body: CommentBody) { + const thread = await this.provider.createThread({ + initialComment: { + body, + }, + }); + this.editor._tiptapEditor.commands.setMark(this.markType, { + threadId: thread.id, + }); + } +} + +type CommentBody = any; + +type CommentReaction = { + emoji: string; + createdAt: Date; + users: { + id: string; + }[]; +}; + +type Comment = { + type: "comment"; + id: string; + userId: string; + createdAt: Date; + updatedAt: Date; + reactions: CommentReaction[]; + // attachments: CommentAttachment[]; + metadata: any; + body: CommentBody; +}; + +type Thread = { + type: "thread"; + id: string; + createdAt: Date; + updatedAt: Date; + comments: Comment[]; + resolved: boolean; + resolvedUpdatedAt?: Date; + metadata: any; +}; + +export abstract class CommentProvider { + abstract createThread(options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }): Promise; +} + +export class YjsCommentProvider extends CommentProvider { + constructor( + private readonly editor: BlockNoteEditor, + private readonly userId: string, + private readonly threadsYMap: Y.Map + ) { + super(); + } + + private commentToYMap(comment: Comment) { + const yMap = new Y.Map(); + yMap.set("id", comment.id); + yMap.set("userId", comment.userId); + yMap.set("createdAt", comment.createdAt.toISOString()); + yMap.set("updatedAt", comment.updatedAt.toISOString()); + if (comment.reactions.length > 0) { + throw new Error("Reactions should be empty in commentToYMap"); + } + yMap.set("reactions", new Y.Array()); + yMap.set("metadata", comment.metadata); + yMap.set("body", comment.body); + return yMap; + } + + private threadToYMap(thread: Thread) { + const yMap = new Y.Map(); + yMap.set("id", thread.id); + yMap.set("createdAt", thread.createdAt.toISOString()); + yMap.set("updatedAt", thread.updatedAt.toISOString()); + const commentsArray = new Y.Array>(); + + commentsArray.push( + thread.comments.map((comment) => this.commentToYMap(comment)) + ); + + yMap.set("comments", commentsArray); + yMap.set("resolved", thread.resolved); + yMap.set("resolvedUpdatedAt", thread.resolvedUpdatedAt?.toISOString()); + yMap.set("metadata", thread.metadata); + return yMap; + } + + public async createThread(options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }) { + const date = new Date(); + + const comment: Comment = { + type: "comment", + id: v4(), + userId: this.userId, + createdAt: date, + updatedAt: date, + reactions: [], + metadata: options.metadata, + body: options.initialComment.body, + }; + + const thread: Thread = { + type: "thread", + id: v4(), + createdAt: date, + updatedAt: date, + comments: [comment], + resolved: false, + metadata: options.metadata, + }; + + this.threadsYMap.set(thread.id, this.threadToYMap(thread)); + + return thread; + } +} + +export class LiveblocksCommentProvider { + constructor(private readonly editor: BlockNoteEditor) {} + + public async createThread() { + const x = useCreateThread(); + return x; + } +} + +export class TiptapCommentProvider { + constructor(private readonly editor: BlockNoteEditor) {} + + public async createThread() { + this.editor._tiptapEditor.commands.setMark(this.markType, { threadId: id }); + } +} diff --git a/packages/mantine/src/comments/Composer.tsx b/packages/mantine/src/comments/Composer.tsx new file mode 100644 index 0000000000..5b61ff4d5e --- /dev/null +++ b/packages/mantine/src/comments/Composer.tsx @@ -0,0 +1,39 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { Button, Flex, Paper } from "@mantine/core"; +import { forwardRef } from "react"; +import { BlockNoteView } from "../index.js"; + +export const Composer = forwardRef< + HTMLDivElement, + ComponentProps["Comments"]["Composer"] +>((props, ref) => { + const { className, editor, onSubmit, ...rest } = props; + + assertEmpty(rest, false); + + return ( + + + {/* TODO: extract / change to icon? */} + + + + + ); +}); diff --git a/packages/mantine/src/index.tsx b/packages/mantine/src/index.tsx index 136e122806..7c93c5b8d7 100644 --- a/packages/mantine/src/index.tsx +++ b/packages/mantine/src/index.tsx @@ -20,6 +20,7 @@ import { removeBlockNoteCSSVariables, Theme, } from "./BlockNoteTheme.js"; +import { Composer } from "./comments/Composer.js"; import { TextInput } from "./form/TextInput.js"; import { Menu, @@ -52,7 +53,6 @@ import { TableHandle } from "./tableHandle/TableHandle.js"; import { Toolbar } from "./toolbar/Toolbar.js"; import { ToolbarButton } from "./toolbar/ToolbarButton.js"; import { ToolbarSelect } from "./toolbar/ToolbarSelect.js"; - export * from "./BlockNoteTheme.js"; export * from "./defaultThemes.js"; @@ -113,6 +113,9 @@ export const components: Components = { Content: PopoverContent, }, }, + Comments: { + Composer, + }, }; const mantineTheme = { diff --git a/packages/react/src/components/Comments/Composer.tsx b/packages/react/src/components/Comments/Composer.tsx new file mode 100644 index 0000000000..8c60fae364 --- /dev/null +++ b/packages/react/src/components/Comments/Composer.tsx @@ -0,0 +1,55 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; + +import { useCreateBlockNote } from "@blocknote/react"; +import { + ComponentProps, + useComponentsContext, +} from "../../editor/ComponentsContext.js"; +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useDictionary } from "../../i18n/dictionary.js"; + +type PanelProps = ComponentProps["FilePanel"]["Root"]; + +// TODO: disable props on paragraph +const schema = BlockNoteSchema.create({ + blockSpecs: { + paragraph: defaultBlockSpecs.paragraph, + }, +}); + +/** + * By default, the FilePanel component will render with default tabs. However, + * you can override the tabs to render by passing the `tabs` prop. You can use + * the default tab panels in the `DefaultTabPanels` directory or make your own + * using the `FilePanelPanel` component. + */ +export const Composer = () => { + const dict = useDictionary(); + const editor = useBlockNoteEditor(); + + const commentEditor = useCreateBlockNote({ + trailingBlock: false, + dictionary: { + ...dict, + placeholders: { + ...dict.placeholders, + default: "Write a comment...", // TODO: only for empty doc + }, + }, + schema, + }); + + const components = useComponentsContext()!; + + return ( + { + editor.comments!.createThread({ + body: editor.document, + }); + }} + /> + ); +}; diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx new file mode 100644 index 0000000000..40807ee537 --- /dev/null +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -0,0 +1,76 @@ +import { + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { UseFloatingOptions, flip, offset } from "@floating-ui/react"; +import { FC, useMemo } from "react"; + +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning.js"; +import { useUIPluginState } from "../../hooks/useUIPluginState.js"; +import { Composer } from "./Composer.js"; + +export const FloatingComposerController = < + B extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>(props: { + filePanel?: FC; + floatingOptions?: Partial; +}) => { + const editor = useBlockNoteEditor(); + + if (!editor.comments) { + throw new Error( + "FloatingComposerController can only be used when BlockNote editor has enabled comments" + ); + } + + const state = useUIPluginState( + editor.comments.onUpdate.bind(editor.comments) + ); + + const referencePos = useMemo(() => { + if (!state?.pendingComment) { + return null; + } + + // TODO: update referencepos when doc changes (remote updates) + return editor.getSelectionBoundingBox(); + }, [editor, state?.pendingComment]); + + // TODO: review + const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning( + state?.pendingComment || false, + referencePos || null, + 5000, + { + placement: "bottom", + middleware: [offset(10), flip()], + onOpenChange: (open) => { + if (!open) { + editor.filePanel!.closeMenu(); + editor.focus(); + } + }, + ...props.floatingOptions, + } + ); + + if (!isMounted || !state) { + return null; + } + + const Component = props.filePanel || Composer; + + return ( +
+ {/*
hello
*/} + +
+ ); +}; diff --git a/packages/react/src/components/Comments/FloatingThreadsController.tsx b/packages/react/src/components/Comments/FloatingThreadsController.tsx new file mode 100644 index 0000000000..aa6c72db1c --- /dev/null +++ b/packages/react/src/components/Comments/FloatingThreadsController.tsx @@ -0,0 +1,76 @@ +import { + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { UseFloatingOptions, flip, offset } from "@floating-ui/react"; +import { FC, useMemo } from "react"; + +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning.js"; +import { useUIPluginState } from "../../hooks/useUIPluginState.js"; +import { Composer } from "./Composer.js"; + +export const FloatingThreadController = < + B extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>(props: { + filePanel?: FC; + floatingOptions?: Partial; +}) => { + const editor = useBlockNoteEditor(); + + if (!editor.comments) { + throw new Error( + "FloatingComposerController can only be used when BlockNote editor has enabled comments" + ); + } + + const state = useUIPluginState( + editor.comments.onUpdate.bind(editor.comments) + ); + + const referencePos = useMemo(() => { + if (!state?.pendingComment) { + return null; + } + + // TODO: update referencepos when doc changes (remote updates) + return editor.getSelectionBoundingBox(); + }, [editor, state?.pendingComment]); + + // TODO: review + const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning( + state?.pendingComment || false, + referencePos || null, + 5000, + { + placement: "bottom", + middleware: [offset(10), flip()], + onOpenChange: (open) => { + if (!open) { + editor.filePanel!.closeMenu(); + editor.focus(); + } + }, + ...props.floatingOptions, + } + ); + + if (!isMounted || !state) { + return null; + } + + const Component = props.filePanel || Composer; + + return ( +
+ {/*
hello
*/} + +
+ ); +}; diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx index 50c8a6911e..c37cfcf0d4 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx @@ -17,13 +17,14 @@ export const AddCommentButton = () => { >(); const onClick = useCallback(() => { - (editor._tiptapEditor as any).chain().focus().addPendingComment().run(); + editor.comments?.addPendingComment(); + editor.formattingToolbar.closeMenu(); }, [editor]); if ( // We manually check if a comment extension (like liveblocks) is installed // By adding default support for this, the user doesn't need to customize the formatting toolbar - !(editor._tiptapEditor.commands as any)["addPendingComment"] || + !editor.comments || !editor.isEditable ) { return null; diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 757dac7989..88cf55c01f 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -1,3 +1,4 @@ +import { FloatingComposerController } from "../components/Comments/FloatingComposerController.js"; import { FilePanelController } from "../components/FilePanel/FilePanelController.js"; import { FormattingToolbarController } from "../components/FormattingToolbar/FormattingToolbarController.js"; import { LinkToolbarController } from "../components/LinkToolbar/LinkToolbarController.js"; @@ -15,6 +16,7 @@ export type BlockNoteDefaultUIProps = { filePanel?: boolean; tableHandles?: boolean; emojiPicker?: boolean; + comments?: boolean; }; export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { @@ -45,6 +47,9 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { {editor.tableHandles && props.tableHandles !== false && ( )} + {editor.comments && props.comments !== false && ( + + )} ); } diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 123264a199..af1e758bb5 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -9,6 +9,7 @@ import { useContext, } from "react"; +import { BlockNoteEditor } from "@blocknote/core"; import { DefaultReactGridSuggestionItem } from "../components/SuggestionMenu/GridSuggestionMenu/types.js"; import { DefaultReactSuggestionItem } from "../components/SuggestionMenu/types.js"; @@ -258,6 +259,13 @@ export type ComponentProps = { }; }; }; + Comments: { + Composer: { + className?: string; + editor: BlockNoteEditor; + onSubmit: () => void; + }; + }; }; export type Components = { diff --git a/playground/package.json b/playground/package.json index ca5a6c881f..d1e04bc77f 100644 --- a/playground/package.json +++ b/playground/package.json @@ -24,10 +24,11 @@ "@tiptap/suggestion": "^2.7.1", "@tiptap/core": "^2.7.1", "@tiptap/react": "^2.7.1", - "@liveblocks/client": "^2.11.0", - "@liveblocks/react": "^2.11.0", - "@liveblocks/react-ui": "^2.11.0", - "@liveblocks/yjs": "^2.11.0", + "@liveblocks/client": "file:../../liveblocks/packages/liveblocks-client", + "@liveblocks/react": "file:../../liveblocks/packages/liveblocks-react", + "@liveblocks/react-blocknote": "file:../../liveblocks/packages/liveblocks-react-blocknote", + "@liveblocks/react-ui": "file:../../liveblocks/packages/liveblocks-react-ui", + "@liveblocks/yjs": "file:../../liveblocks/packages/liveblocks-yjs", "@mantine/core": "^7.10.1", "@mui/icons-material": "^5.16.1", "@mui/material": "^5.16.1", diff --git a/playground/src/style.css b/playground/src/style.css index 7e716f2b47..43b27bc082 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -10,6 +10,10 @@ body { max-width: 731px; } +.bn-comment-composer .bn-container { + padding-top: 0; +} + .mantine-AppShell-navbar { background-color: #f7f7f5; } diff --git a/playground/tsconfig.json b/playground/tsconfig.json index 6ca41017b8..04c99061c5 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -23,6 +23,8 @@ { "path": "./tsconfig.node.json" }, { "path": "../packages/core/" }, { "path": "../packages/react/" }, + { "path": "../packages/ariakit/" }, + { "path": "../packages/mantine/" }, { "path": "../packages/shadcn/" }, { "path": "../packages/xl-pdf-exporter/" }, { "path": "../packages/xl-docx-exporter/" }, From 23275a07eef92c71f7437b377785448b05a24514 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 13 Jan 2025 17:16:31 +0100 Subject: [PATCH 02/30] wip --- .../src/extensions/Comments/CommentMark.ts | 4 +- .../src/extensions/Comments/CommentsPlugin.ts | 167 ++--- .../core/src/extensions/Comments/types.ts | 32 + packages/core/src/index.ts | 4 +- packages/core/src/util/browser.ts | 2 +- packages/mantine/src/BlockNoteView.tsx | 100 +++ packages/mantine/src/comments/Card.tsx | 46 ++ packages/mantine/src/comments/Comment.tsx | 25 + packages/mantine/src/comments/Composer.tsx | 2 +- packages/mantine/src/components.tsx | 104 +++ packages/mantine/src/index.tsx | 196 +----- .../react/src/components/Comments/Comment.tsx | 599 ++++++++++++++++++ .../src/components/Comments/Composer.tsx | 30 +- ...oller.tsx => FloatingThreadController.tsx} | 66 +- .../react/src/components/Comments/Thread.tsx | 280 ++++++++ .../react/src/components/Comments/schema.ts | 8 + .../react/src/editor/BlockNoteDefaultUI.tsx | 6 +- .../react/src/editor/ComponentsContext.tsx | 13 + .../src/hooks/useUIElementPositioning.ts | 3 + 19 files changed, 1361 insertions(+), 326 deletions(-) create mode 100644 packages/core/src/extensions/Comments/types.ts create mode 100644 packages/mantine/src/BlockNoteView.tsx create mode 100644 packages/mantine/src/comments/Card.tsx create mode 100644 packages/mantine/src/comments/Comment.tsx create mode 100644 packages/mantine/src/components.tsx create mode 100644 packages/react/src/components/Comments/Comment.tsx rename packages/react/src/components/Comments/{FloatingThreadsController.tsx => FloatingThreadController.tsx} (52%) create mode 100644 packages/react/src/components/Comments/Thread.tsx create mode 100644 packages/react/src/components/Comments/schema.ts diff --git a/packages/core/src/extensions/Comments/CommentMark.ts b/packages/core/src/extensions/Comments/CommentMark.ts index f7c6f7faa8..3b4f2e8fee 100644 --- a/packages/core/src/extensions/Comments/CommentMark.ts +++ b/packages/core/src/extensions/Comments/CommentMark.ts @@ -23,10 +23,10 @@ export const CommentMark = Mark.create({ default: false, }, threadId: { - parseHTML: (element) => element.getAttribute("data-lb-thread-id"), + parseHTML: (element) => element.getAttribute("data-bn-thread-id"), renderHTML: (attributes) => { return { - "data-lb-thread-id": (attributes as { threadId: string }).threadId, + "data-bn-thread-id": (attributes as { threadId: string }).threadId, }; }, default: "", diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index 0a01f421fa..0b0bb40c5c 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -5,6 +5,7 @@ import { v4 } from "uuid"; import * as Y from "yjs"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { EventEmitter } from "../../util/EventEmitter.js"; +import { CommentBody, CommentData, ThreadData } from "./types.js"; const PLUGIN_KEY = new PluginKey(`blocknote-comments`); enum CommentsPluginActions { @@ -18,14 +19,14 @@ type CommentsPluginAction = { type CommentsPluginState = { threadPositions: Map; - selectedThreadId: string | null; + // selectedThreadId: string | null; // selectedThreadPos: number | null; decorations: DecorationSet; }; function updateState( doc: Node, - selectedThreadId: string | null, + selectedThreadId: string | undefined, markType: string ) { const threadPositions = new Map(); @@ -65,19 +66,22 @@ function updateState( }); return { decorations: DecorationSet.create(doc, decorations), - selectedThreadId, threadPositions, - selectedThreadPos: - selectedThreadId !== null - ? threadPositions.get(selectedThreadId)?.to ?? null - : null, }; } export class CommentsPlugin extends EventEmitter { public readonly plugin: Plugin; - private provider: CommentProvider; + public readonly store: ThreadStore; private pendingComment = false; + private selectedThreadId: string | undefined; + + private emitStateUpdate() { + this.emit("update", { + selectedThreadId: this.selectedThreadId, + pendingComment: this.pendingComment, + }); + } constructor( private readonly editor: BlockNoteEditor, @@ -86,7 +90,7 @@ export class CommentsPlugin extends EventEmitter { super(); const doc = new Y.Doc(); - this.provider = new YjsCommentProvider( + this.store = new YjsThreadStore( editor, "blablauserid", doc.getMap("threads") @@ -98,20 +102,19 @@ export class CommentsPlugin extends EventEmitter { // TODO: filter out yjs transactions if (this.pendingComment) { this.pendingComment = false; - this.emit("update", { - pendingComment: this.pendingComment, - }); + this.emitStateUpdate(); } }); }, 600); + const self = this; + this.plugin = new Plugin({ key: PLUGIN_KEY, state: { init() { return { threadPositions: new Map(), - selectedThreadId: null, decorations: DecorationSet.empty, } satisfies CommentsPluginState; }, @@ -121,19 +124,8 @@ export class CommentsPlugin extends EventEmitter { return state; } - if (!action) { - // Doc changed, but no action, just update rects - return updateState(tr.doc, state.selectedThreadId, markType); - } - // handle actions, possibly support more actions - if ( - action.name === CommentsPluginActions.SET_SELECTED_THREAD_ID && - state.selectedThreadId !== action.data - ) { - return updateState(tr.doc, action.data, markType); - } - - return state; + // Doc changed, but no action, just update rects + return updateState(tr.doc, self.selectedThreadId, markType); }, }, props: { @@ -145,18 +137,19 @@ export class CommentsPlugin extends EventEmitter { return; } - const selectThread = (threadId: string | null) => { + const selectThread = (threadId: string | undefined) => { + self.selectedThreadId = threadId; + self.emitStateUpdate(); view.dispatch( view.state.tr.setMeta(PLUGIN_KEY, { name: CommentsPluginActions.SET_SELECTED_THREAD_ID, - data: threadId, }) ); }; const node = view.state.doc.nodeAt(pos); if (!node) { - selectThread(null); + selectThread(undefined); return; } const commentMark = node.marks.find( @@ -164,83 +157,57 @@ export class CommentsPlugin extends EventEmitter { ); // don't allow selecting orphaned threads if (commentMark?.attrs.orphan) { - selectThread(null); + selectThread(undefined); return; } const threadId = commentMark?.attrs.threadId as string | undefined; - selectThread(threadId ?? null); + selectThread(threadId); }, }, }); } - public onUpdate(callback: (state: { pendingComment: boolean }) => void) { + public onUpdate( + callback: (state: { + pendingComment: boolean; + selectedThreadId: string | undefined; + }) => void + ) { return this.on("update", callback); } public addPendingComment() { this.pendingComment = true; - this.emit("update", { - pendingComment: this.pendingComment, - }); + this.emitStateUpdate(); } - public async createThread(body: CommentBody) { - const thread = await this.provider.createThread({ - initialComment: { - body, - }, - }); + public async createThread(options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }) { + const thread = await this.store.createThread(options); this.editor._tiptapEditor.commands.setMark(this.markType, { threadId: thread.id, }); } } -type CommentBody = any; - -type CommentReaction = { - emoji: string; - createdAt: Date; - users: { - id: string; - }[]; -}; - -type Comment = { - type: "comment"; - id: string; - userId: string; - createdAt: Date; - updatedAt: Date; - reactions: CommentReaction[]; - // attachments: CommentAttachment[]; - metadata: any; - body: CommentBody; -}; - -type Thread = { - type: "thread"; - id: string; - createdAt: Date; - updatedAt: Date; - comments: Comment[]; - resolved: boolean; - resolvedUpdatedAt?: Date; - metadata: any; -}; - -export abstract class CommentProvider { +export abstract class ThreadStore { abstract createThread(options: { initialComment: { body: CommentBody; metadata?: any; }; metadata?: any; - }): Promise; + }): Promise; + + abstract getThread(threadId: string): ThreadData; } -export class YjsCommentProvider extends CommentProvider { +export class YjsThreadStore extends ThreadStore { constructor( private readonly editor: BlockNoteEditor, private readonly userId: string, @@ -249,7 +216,7 @@ export class YjsCommentProvider extends CommentProvider { super(); } - private commentToYMap(comment: Comment) { + private commentToYMap(comment: CommentData) { const yMap = new Y.Map(); yMap.set("id", comment.id); yMap.set("userId", comment.userId); @@ -264,7 +231,7 @@ export class YjsCommentProvider extends CommentProvider { return yMap; } - private threadToYMap(thread: Thread) { + private threadToYMap(thread: ThreadData) { const yMap = new Y.Map(); yMap.set("id", thread.id); yMap.set("createdAt", thread.createdAt.toISOString()); @@ -282,6 +249,40 @@ export class YjsCommentProvider extends CommentProvider { return yMap; } + private yMapToComment(yMap: Y.Map): CommentData { + return { + type: "comment", + id: yMap.get("id"), + userId: yMap.get("userId"), + createdAt: new Date(yMap.get("createdAt")), + updatedAt: new Date(yMap.get("updatedAt")), + reactions: [], + metadata: yMap.get("metadata"), + body: yMap.get("body"), + }; + } + + private yMapToThread(yMap: Y.Map): ThreadData { + return { + type: "thread", + id: yMap.get("id"), + createdAt: new Date(yMap.get("createdAt")), + updatedAt: new Date(yMap.get("updatedAt")), + comments: ((yMap.get("comments") as Y.Array>) || []).map( + (comment) => this.yMapToComment(comment) + ), + resolved: yMap.get("resolved"), + resolvedUpdatedAt: yMap.get("resolvedUpdatedAt"), + metadata: yMap.get("metadata"), + }; + } + + // TODO: async / reactive interface? + public getThread(threadId: string) { + const thread = this.yMapToThread(this.threadsYMap.get(threadId)); + return thread; + } + public async createThread(options: { initialComment: { body: CommentBody; @@ -291,7 +292,7 @@ export class YjsCommentProvider extends CommentProvider { }) { const date = new Date(); - const comment: Comment = { + const comment: CommentData = { type: "comment", id: v4(), userId: this.userId, @@ -302,7 +303,7 @@ export class YjsCommentProvider extends CommentProvider { body: options.initialComment.body, }; - const thread: Thread = { + const thread: ThreadData = { type: "thread", id: v4(), createdAt: date, @@ -318,7 +319,7 @@ export class YjsCommentProvider extends CommentProvider { } } -export class LiveblocksCommentProvider { +export class LiveblocksThreadStore { constructor(private readonly editor: BlockNoteEditor) {} public async createThread() { @@ -327,7 +328,7 @@ export class LiveblocksCommentProvider { } } -export class TiptapCommentProvider { +export class TiptapThreadStore { constructor(private readonly editor: BlockNoteEditor) {} public async createThread() { diff --git a/packages/core/src/extensions/Comments/types.ts b/packages/core/src/extensions/Comments/types.ts new file mode 100644 index 0000000000..db7fc2bede --- /dev/null +++ b/packages/core/src/extensions/Comments/types.ts @@ -0,0 +1,32 @@ +export type CommentBody = any; + +export type CommentReactionData = { + emoji: string; + createdAt: Date; + users: { + id: string; + }[]; +}; + +export type CommentData = { + type: "comment"; + id: string; + userId: string; + createdAt: Date; + updatedAt: Date; + reactions: CommentReactionData[]; + // attachments: CommentAttachment[]; + metadata: any; + body: CommentBody; +}; + +export type ThreadData = { + type: "thread"; + id: string; + createdAt: Date; + updatedAt: Date; + comments: CommentData[]; + resolved: boolean; + resolvedUpdatedAt?: Date; + metadata: any; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7b0115ae47..aa12a962d8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ import * as locales from "./i18n/locales/index.js"; +export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; export * from "./api/getBlockInfoFromPos.js"; @@ -53,7 +54,6 @@ export * from "./util/string.js"; export * from "./util/typescript.js"; export { UnreachableCaseError, assertEmpty } from "./util/typescript.js"; export { locales }; -export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; // for testing from react (TODO: move): export * from "./api/nodeConversions/blockToNode.js"; @@ -65,3 +65,5 @@ export * from "./extensions/UniqueID/UniqueID.js"; export * from "./api/exporters/markdown/markdownExporter.js"; export * from "./api/parsers/html/parseHTML.js"; export * from "./api/parsers/markdown/parseMarkdown.js"; + +export * from "./extensions/Comments/types.js"; diff --git a/packages/core/src/util/browser.ts b/packages/core/src/util/browser.ts index 9ecdf3250d..2a0a2905bd 100644 --- a/packages/core/src/util/browser.ts +++ b/packages/core/src/util/browser.ts @@ -12,7 +12,7 @@ export function formatKeyboardShortcut(shortcut: string, ctrlText = "Ctrl") { } } -export function mergeCSSClasses(...classes: string[]) { +export function mergeCSSClasses(...classes: (string | false | undefined)[]) { return classes.filter((c) => c).join(" "); } diff --git a/packages/mantine/src/BlockNoteView.tsx b/packages/mantine/src/BlockNoteView.tsx new file mode 100644 index 0000000000..f9403d6a10 --- /dev/null +++ b/packages/mantine/src/BlockNoteView.tsx @@ -0,0 +1,100 @@ +import { + BlockSchema, + InlineContentSchema, + mergeCSSClasses, + StyleSchema, +} from "@blocknote/core"; +import { + BlockNoteViewProps, + BlockNoteViewRaw, + ComponentsContext, + useBlockNoteContext, + usePrefersColorScheme, +} from "@blocknote/react"; +import { MantineProvider } from "@mantine/core"; +import { useCallback } from "react"; + +import { + applyBlockNoteCSSVariablesFromTheme, + removeBlockNoteCSSVariables, + Theme, +} from "./BlockNoteTheme.js"; +import { components } from "./components.js"; +import "./style.css"; +export * from "./BlockNoteTheme.js"; +export * from "./defaultThemes.js"; + +const mantineTheme = { + // Removes button press effect + activeClassName: "", +}; + +export const BlockNoteView = < + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>( + props: Omit, "theme"> & { + theme?: + | "light" + | "dark" + | Theme + | { + light: Theme; + dark: Theme; + }; + } +) => { + const { className, theme, ...rest } = props; + + const existingContext = useBlockNoteContext(); + const systemColorScheme = usePrefersColorScheme(); + const defaultColorScheme = + existingContext?.colorSchemePreference || systemColorScheme; + + const ref = useCallback( + (node: HTMLDivElement | null) => { + if (!node) { + // todo: clean variables? + return; + } + + removeBlockNoteCSSVariables(node); + + if (typeof theme === "object") { + if ("light" in theme && "dark" in theme) { + applyBlockNoteCSSVariablesFromTheme( + theme[defaultColorScheme === "dark" ? "dark" : "light"], + node + ); + return; + } + + applyBlockNoteCSSVariablesFromTheme(theme, node); + return; + } + }, + [defaultColorScheme, theme] + ); + + return ( + + {/* `cssVariablesSelector` scopes Mantine CSS variables to only the editor, */} + {/* as proposed here: https://github.com/orgs/mantinedev/discussions/5685 */} + undefined}> + + + + ); +}; diff --git a/packages/mantine/src/comments/Card.tsx b/packages/mantine/src/comments/Card.tsx new file mode 100644 index 0000000000..2e50ea092d --- /dev/null +++ b/packages/mantine/src/comments/Card.tsx @@ -0,0 +1,46 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { Card as MantineCard } from "@mantine/core"; +import { forwardRef } from "react"; + +export const Card = forwardRef< + HTMLDivElement, + ComponentProps["Comments"]["Card"] +>((props, ref) => { + const { className, children, ...rest } = props; + + assertEmpty(rest, false); + + return ( + + {children} + + ); +}); + +export const CardSection = forwardRef< + HTMLDivElement, + ComponentProps["Comments"]["CardSection"] +>((props, ref) => { + const { className, children, ...rest } = props; + + assertEmpty(rest, false); + + return ( + + {children} + + ); +}); diff --git a/packages/mantine/src/comments/Comment.tsx b/packages/mantine/src/comments/Comment.tsx new file mode 100644 index 0000000000..f00947a035 --- /dev/null +++ b/packages/mantine/src/comments/Comment.tsx @@ -0,0 +1,25 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; +import { BlockNoteView } from "../BlockNoteView.js"; + +export const Comment = forwardRef< + HTMLDivElement, + ComponentProps["Comments"]["Comment"] +>((props, ref) => { + const { className, editor, editable, ...rest } = props; + + assertEmpty(rest, false); + + return ( + + ); +}); diff --git a/packages/mantine/src/comments/Composer.tsx b/packages/mantine/src/comments/Composer.tsx index 5b61ff4d5e..648016ca33 100644 --- a/packages/mantine/src/comments/Composer.tsx +++ b/packages/mantine/src/comments/Composer.tsx @@ -2,7 +2,7 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; import { Button, Flex, Paper } from "@mantine/core"; import { forwardRef } from "react"; -import { BlockNoteView } from "../index.js"; +import { BlockNoteView } from "../BlockNoteView.js"; export const Composer = forwardRef< HTMLDivElement, diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx new file mode 100644 index 0000000000..f8645f19bc --- /dev/null +++ b/packages/mantine/src/components.tsx @@ -0,0 +1,104 @@ +import { Components } from "@blocknote/react"; + +import { Card, CardSection } from "./comments/Card.js"; +import { Comment } from "./comments/Comment.js"; +import { Composer } from "./comments/Composer.js"; +import { TextInput } from "./form/TextInput.js"; +import { + Menu, + MenuDivider, + MenuDropdown, + MenuItem, + MenuLabel, + MenuTrigger, +} from "./menu/Menu.js"; +import { Panel } from "./panel/Panel.js"; +import { PanelButton } from "./panel/PanelButton.js"; +import { PanelFileInput } from "./panel/PanelFileInput.js"; +import { PanelTab } from "./panel/PanelTab.js"; +import { PanelTextInput } from "./panel/PanelTextInput.js"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover/Popover.js"; +import { SideMenu } from "./sideMenu/SideMenu.js"; +import { SideMenuButton } from "./sideMenu/SideMenuButton.js"; +import "./style.css"; +import { SuggestionMenu } from "./suggestionMenu/SuggestionMenu.js"; +import { SuggestionMenuEmptyItem } from "./suggestionMenu/SuggestionMenuEmptyItem.js"; +import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem.js"; +import { SuggestionMenuLabel } from "./suggestionMenu/SuggestionMenuLabel.js"; +import { SuggestionMenuLoader } from "./suggestionMenu/SuggestionMenuLoader.js"; +import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.js"; +import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.js"; +import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.js"; +import { GridSuggestionMenuLoader } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.js"; +import { ExtendButton } from "./tableHandle/ExtendButton.js"; +import { TableHandle } from "./tableHandle/TableHandle.js"; +import { Toolbar } from "./toolbar/Toolbar.js"; +import { ToolbarButton } from "./toolbar/ToolbarButton.js"; +import { ToolbarSelect } from "./toolbar/ToolbarSelect.js"; +export * from "./BlockNoteTheme.js"; +export * from "./defaultThemes.js"; + +export const components: Components = { + FormattingToolbar: { + Root: Toolbar, + Button: ToolbarButton, + Select: ToolbarSelect, + }, + FilePanel: { + Root: Panel, + Button: PanelButton, + FileInput: PanelFileInput, + TabPanel: PanelTab, + TextInput: PanelTextInput, + }, + GridSuggestionMenu: { + Root: GridSuggestionMenu, + Item: GridSuggestionMenuItem, + EmptyItem: GridSuggestionMenuEmptyItem, + Loader: GridSuggestionMenuLoader, + }, + LinkToolbar: { + Root: Toolbar, + Button: ToolbarButton, + }, + SideMenu: { + Root: SideMenu, + Button: SideMenuButton, + }, + SuggestionMenu: { + Root: SuggestionMenu, + Item: SuggestionMenuItem, + EmptyItem: SuggestionMenuEmptyItem, + Label: SuggestionMenuLabel, + Loader: SuggestionMenuLoader, + }, + TableHandle: { + Root: TableHandle, + ExtendButton: ExtendButton, + }, + Generic: { + Form: { + Root: (props) =>
{props.children}
, + TextInput: TextInput, + }, + Menu: { + Root: Menu, + Trigger: MenuTrigger, + Dropdown: MenuDropdown, + Divider: MenuDivider, + Label: MenuLabel, + Item: MenuItem, + }, + Popover: { + Root: Popover, + Trigger: PopoverTrigger, + Content: PopoverContent, + }, + }, + Comments: { + Comment, + Composer, + Card, + CardSection, + }, +}; diff --git a/packages/mantine/src/index.tsx b/packages/mantine/src/index.tsx index 7c93c5b8d7..f3f6a4bfc8 100644 --- a/packages/mantine/src/index.tsx +++ b/packages/mantine/src/index.tsx @@ -1,194 +1,2 @@ -import { - BlockSchema, - InlineContentSchema, - mergeCSSClasses, - StyleSchema, -} from "@blocknote/core"; -import { - BlockNoteViewProps, - BlockNoteViewRaw, - Components, - ComponentsContext, - useBlockNoteContext, - usePrefersColorScheme, -} from "@blocknote/react"; -import { MantineProvider } from "@mantine/core"; -import { useCallback } from "react"; - -import { - applyBlockNoteCSSVariablesFromTheme, - removeBlockNoteCSSVariables, - Theme, -} from "./BlockNoteTheme.js"; -import { Composer } from "./comments/Composer.js"; -import { TextInput } from "./form/TextInput.js"; -import { - Menu, - MenuDivider, - MenuDropdown, - MenuItem, - MenuLabel, - MenuTrigger, -} from "./menu/Menu.js"; -import { Panel } from "./panel/Panel.js"; -import { PanelButton } from "./panel/PanelButton.js"; -import { PanelFileInput } from "./panel/PanelFileInput.js"; -import { PanelTab } from "./panel/PanelTab.js"; -import { PanelTextInput } from "./panel/PanelTextInput.js"; -import { Popover, PopoverContent, PopoverTrigger } from "./popover/Popover.js"; -import { SideMenu } from "./sideMenu/SideMenu.js"; -import { SideMenuButton } from "./sideMenu/SideMenuButton.js"; -import "./style.css"; -import { GridSuggestionMenu } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenu.js"; -import { GridSuggestionMenuEmptyItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuEmptyItem.js"; -import { GridSuggestionMenuItem } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuItem.js"; -import { GridSuggestionMenuLoader } from "./suggestionMenu/gridSuggestionMenu/GridSuggestionMenuLoader.js"; -import { SuggestionMenu } from "./suggestionMenu/SuggestionMenu.js"; -import { SuggestionMenuEmptyItem } from "./suggestionMenu/SuggestionMenuEmptyItem.js"; -import { SuggestionMenuItem } from "./suggestionMenu/SuggestionMenuItem.js"; -import { SuggestionMenuLabel } from "./suggestionMenu/SuggestionMenuLabel.js"; -import { SuggestionMenuLoader } from "./suggestionMenu/SuggestionMenuLoader.js"; -import { ExtendButton } from "./tableHandle/ExtendButton.js"; -import { TableHandle } from "./tableHandle/TableHandle.js"; -import { Toolbar } from "./toolbar/Toolbar.js"; -import { ToolbarButton } from "./toolbar/ToolbarButton.js"; -import { ToolbarSelect } from "./toolbar/ToolbarSelect.js"; -export * from "./BlockNoteTheme.js"; -export * from "./defaultThemes.js"; - -export const components: Components = { - FormattingToolbar: { - Root: Toolbar, - Button: ToolbarButton, - Select: ToolbarSelect, - }, - FilePanel: { - Root: Panel, - Button: PanelButton, - FileInput: PanelFileInput, - TabPanel: PanelTab, - TextInput: PanelTextInput, - }, - GridSuggestionMenu: { - Root: GridSuggestionMenu, - Item: GridSuggestionMenuItem, - EmptyItem: GridSuggestionMenuEmptyItem, - Loader: GridSuggestionMenuLoader, - }, - LinkToolbar: { - Root: Toolbar, - Button: ToolbarButton, - }, - SideMenu: { - Root: SideMenu, - Button: SideMenuButton, - }, - SuggestionMenu: { - Root: SuggestionMenu, - Item: SuggestionMenuItem, - EmptyItem: SuggestionMenuEmptyItem, - Label: SuggestionMenuLabel, - Loader: SuggestionMenuLoader, - }, - TableHandle: { - Root: TableHandle, - ExtendButton: ExtendButton, - }, - Generic: { - Form: { - Root: (props) =>
{props.children}
, - TextInput: TextInput, - }, - Menu: { - Root: Menu, - Trigger: MenuTrigger, - Dropdown: MenuDropdown, - Divider: MenuDivider, - Label: MenuLabel, - Item: MenuItem, - }, - Popover: { - Root: Popover, - Trigger: PopoverTrigger, - Content: PopoverContent, - }, - }, - Comments: { - Composer, - }, -}; - -const mantineTheme = { - // Removes button press effect - activeClassName: "", -}; - -export const BlockNoteView = < - BSchema extends BlockSchema, - ISchema extends InlineContentSchema, - SSchema extends StyleSchema ->( - props: Omit, "theme"> & { - theme?: - | "light" - | "dark" - | Theme - | { - light: Theme; - dark: Theme; - }; - } -) => { - const { className, theme, ...rest } = props; - - const existingContext = useBlockNoteContext(); - const systemColorScheme = usePrefersColorScheme(); - const defaultColorScheme = - existingContext?.colorSchemePreference || systemColorScheme; - - const ref = useCallback( - (node: HTMLDivElement | null) => { - if (!node) { - // todo: clean variables? - return; - } - - removeBlockNoteCSSVariables(node); - - if (typeof theme === "object") { - if ("light" in theme && "dark" in theme) { - applyBlockNoteCSSVariablesFromTheme( - theme[defaultColorScheme === "dark" ? "dark" : "light"], - node - ); - return; - } - - applyBlockNoteCSSVariablesFromTheme(theme, node); - return; - } - }, - [defaultColorScheme, theme] - ); - - return ( - - {/* `cssVariablesSelector` scopes Mantine CSS variables to only the editor, */} - {/* as proposed here: https://github.com/orgs/mantinedev/discussions/5685 */} - undefined}> - - - - ); -}; +export * from "./BlockNoteView.js"; +export * from "./components.js"; diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx new file mode 100644 index 0000000000..6fedaec6a6 --- /dev/null +++ b/packages/react/src/components/Comments/Comment.tsx @@ -0,0 +1,599 @@ +"use client"; + +import { CommentData, mergeCSSClasses } from "@blocknote/core"; +import type { + ComponentPropsWithoutRef, + FormEvent, + MouseEvent, + ReactNode, + SyntheticEvent, +} from "react"; +import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; +import { useDictionary } from "../../i18n/dictionary.js"; +import { mergeRefs } from "../../util/mergeRefs.js"; +import { schema } from "./schema.js"; + +/** + * Liveblocks, but changed: + * - removed attachments + * - removed read status + */ +const REACTIONS_TRUNCATE = 5; + +export interface CommentProps extends ComponentPropsWithoutRef<"div"> { + /** + * The comment to display. + */ + comment: CommentData; + + /** + * How to show or hide the actions. + */ + showActions?: boolean | "hover"; + + /** + * Whether to show the comment if it was deleted. If set to `false`, it will render deleted comments as `null`. + */ + showDeleted?: boolean; + + /** + * Whether to show reactions. + */ + showReactions?: boolean; + + /** + * Whether to show the composer's formatting controls when editing the comment. + */ + // showComposerFormattingControls?: ComposerProps["showFormattingControls"]; + + /** + * Whether to indent the comment's content. + */ + indentContent?: boolean; + + /** + * The event handler called when the comment is edited. + */ + onCommentEdit?: (comment: CommentData) => void; + + /** + * The event handler called when the comment is deleted. + */ + onCommentDelete?: (comment: CommentData) => void; + + /** + * The event handler called when clicking on the author. + */ + onAuthorClick?: (userId: string, event: MouseEvent) => void; + + /** + * The event handler called when clicking on a mention. + */ + onMentionClick?: (userId: string, event: MouseEvent) => void; + + /** + * Override the component's strings. + */ + // overrides?: Partial; + + /** + * @internal + */ + autoMarkReadThreadId?: string; + + /** + * @internal + */ + additionalActions?: ReactNode; + + /** + * @internal + */ + additionalActionsClassName?: string; +} + +// interface CommentReactionButtonProps +// extends ComponentPropsWithoutRef { +// reaction: CommentReactionData; +// // overrides?: Partial; +// } + +// interface CommentReactionProps extends ComponentPropsWithoutRef<"button"> { +// comment: CommentData; +// reaction: CommentReactionData; +// // overrides?: Partial; +// } + +// type CommentNonInteractiveReactionProps = Omit; + +// const CommentReactionButton = forwardRef< +// HTMLButtonElement, +// CommentReactionButtonProps +// >(({ reaction, overrides, className, ...props }, forwardedRef) => { +// const $ = useOverrides(overrides); +// return ( +// +// ); +// }); + +// export const CommentReaction = forwardRef< +// HTMLButtonElement, +// CommentReactionProps +// >(({ comment, reaction, overrides, disabled, ...props }, forwardedRef) => { +// const addReaction = useAddRoomCommentReaction(comment.roomId); +// const removeReaction = useRemoveRoomCommentReaction(comment.roomId); +// const currentId = useCurrentUserId(); +// const isActive = useMemo(() => { +// return reaction.users.some((users) => users.id === currentId); +// }, [currentId, reaction]); +// const $ = useOverrides(overrides); +// const tooltipContent = useMemo( +// () => ( +// +// {$.COMMENT_REACTION_LIST( +// ( +// +// ))} +// formatRemaining={$.LIST_REMAINING_USERS} +// truncate={REACTIONS_TRUNCATE} +// locale={$.locale} +// />, +// reaction.emoji, +// reaction.users.length +// )} +// +// ), +// [$, reaction] +// ); + +// const stopPropagation = useCallback((event: SyntheticEvent) => { +// event.stopPropagation(); +// }, []); + +// const handlePressedChange = useCallback( +// (isPressed: boolean) => { +// if (isPressed) { +// addReaction({ +// threadId: comment.threadId, +// commentId: comment.id, +// emoji: reaction.emoji, +// }); +// } else { +// removeReaction({ +// threadId: comment.threadId, +// commentId: comment.id, +// emoji: reaction.emoji, +// }); +// } +// }, +// [addReaction, comment.threadId, comment.id, reaction.emoji, removeReaction] +// ); + +// return ( +// +// +// +// +// +// ); +// }); + +// export const CommentNonInteractiveReaction = forwardRef< +// HTMLButtonElement, +// CommentNonInteractiveReactionProps +// >(({ reaction, overrides, ...props }, forwardedRef) => { +// const currentId = useCurrentUserId(); +// const isActive = useMemo(() => { +// return reaction.users.some((users) => users.id === currentId); +// }, [currentId, reaction]); + +// return ( +// +// ); +// }); + +/** + * Displays a single comment. + * + * @example + * <> + * {thread.comments.map((comment) => ( + * + * ))} + * + */ +export const Comment = forwardRef( + ( + { + comment, + indentContent = true, + showDeleted, + showActions = "hover", + showReactions = true, + // showComposerFormattingControls = true, + onAuthorClick, + onMentionClick, + onCommentEdit, + onCommentDelete, + // overrides, + className, + additionalActions, + additionalActionsClassName, + autoMarkReadThreadId, + ...props + }, + forwardedRef + ) => { + const dict = useDictionary(); + + const commentEditor = useCreateBlockNote({ + initialContent: comment.body, + trailingBlock: false, + dictionary: { + ...dict, + placeholders: { + ...dict.placeholders, + default: "Edit comment...", // TODO: only for empty doc + }, + }, + schema, + }); + + const ctx = useComponentsContext()!; + + const ref = useRef(null); + const mergedRefs = mergeRefs([forwardedRef, ref]); + // const currentUserId = useCurrentUserId(); + // const deleteComment = useDeleteRoomComment(comment.roomId); + // const editComment = useEditRoomComment(comment.roomId); + // const addReaction = useAddRoomCommentReaction(comment.roomId); + // const removeReaction = useRemoveRoomCommentReaction(comment.roomId); + // const $ = useOverrides(overrides); + const [isEditing, setEditing] = useState(false); + const [isTarget, setTarget] = useState(false); + const [isMoreActionOpen, setMoreActionOpen] = useState(false); + const [isReactionActionOpen, setReactionActionOpen] = useState(false); + + const stopPropagation = useCallback((event: SyntheticEvent) => { + event.stopPropagation(); + }, []); + + const handleEdit = useCallback(() => { + setEditing(true); + }, []); + + const handleEditCancel = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + setEditing(false); + }, + [] + ); + + const handleEditSubmit = useCallback( + ( + { body, attachments }: ComposerSubmitComment, + event: FormEvent + ) => { + // TODO: Add a way to preventDefault from within this callback, to override the default behavior (e.g. showing a confirmation dialog) + onCommentEdit?.(comment); + + // event.preventDefault(); + // setEditing(false); + // editComment({ + // commentId: comment.id, + // threadId: comment.threadId, + // body, + // attachments, + // }); + }, + [comment, onCommentEdit] + ); + + const handleDelete = useCallback(() => { + // TODO: Add a way to preventDefault from within this callback, to override the default behavior (e.g. showing a confirmation dialog) + onCommentDelete?.(comment); + + // deleteComment({ + // commentId: comment.id, + // threadId: comment.threadId, + // }); + }, [comment, onCommentDelete]); + + // const handleAuthorClick = useCallback( + // (event: MouseEvent) => { + // onAuthorClick?.(comment.userId, event); + // }, + // [comment.userId, onAuthorClick] + // ); + + // const handleReactionSelect = useCallback( + // (emoji: string) => { + // const reactionIndex = comment.reactions.findIndex( + // (reaction) => reaction.emoji === emoji + // ); + + // if ( + // reactionIndex >= 0 && + // currentUserId && + // comment.reactions[reactionIndex]?.users.some( + // (user) => user.id === currentUserId + // ) + // ) { + // removeReaction({ + // threadId: comment.threadId, + // commentId: comment.id, + // emoji, + // }); + // } else { + // addReaction({ + // threadId: comment.threadId, + // commentId: comment.id, + // emoji, + // }); + // } + // }, + // [ + // addReaction, + // comment.id, + // comment.reactions, + // comment.threadId, + // removeReaction, + // currentUserId, + // ] + // ); + + useEffect(() => { + const isWindowDefined = typeof window !== "undefined"; + if (!isWindowDefined) { + return; + } + + const hash = window.location.hash; + const commentId = hash.slice(1); + + if (commentId === comment.id) { + setTarget(true); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (!showDeleted && !comment.body) { + return null; + } + + return ( +
+
+ {/*
+ + + + + + {comment.editedAt && comment.body && ( + <> + {" "} + + {$.COMMENT_EDITED} + + + )} + + +
*/} + {showActions && !isEditing && ( +
+ {additionalActions ?? null} + {/* {showReactions && ( + + + + + + + + )} */} + {/* {comment.userId === currentUserId && ( + + + + {$.COMMENT_EDIT} + + + + {$.COMMENT_DELETE} + + + }> + + + + + + + )} */} +
+ )} +
+
+ {isEditing ? ( + + ) : // + // + // + // + // }> + // + // + // + // + // + // } + // overrides={{ + // COMPOSER_PLACEHOLDER: $.COMMENT_EDIT_COMPOSER_PLACEHOLDER, + // }} + // roomId={comment.roomId} + // /> + comment.body ? ( + <> + + {/* ( + onMentionClick?.(userId, event)} + /> + ), + Link: CommentLink, + }} + /> */} + {showReactions && comment.reactions.length > 0 && ( +
+ {/* {comment.reactions.map((reaction) => ( + + ))} */} + {/* + + + + + + */} +
+ )} + + ) : ( +
+ {/*

{$.COMMENT_DELETED}

*/} +
+ )} +
+
+ ); + } +); diff --git a/packages/react/src/components/Comments/Composer.tsx b/packages/react/src/components/Comments/Composer.tsx index 8c60fae364..1457adf793 100644 --- a/packages/react/src/components/Comments/Composer.tsx +++ b/packages/react/src/components/Comments/Composer.tsx @@ -1,28 +1,8 @@ -import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; - -import { useCreateBlockNote } from "@blocknote/react"; -import { - ComponentProps, - useComponentsContext, -} from "../../editor/ComponentsContext.js"; +import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; - -type PanelProps = ComponentProps["FilePanel"]["Root"]; - -// TODO: disable props on paragraph -const schema = BlockNoteSchema.create({ - blockSpecs: { - paragraph: defaultBlockSpecs.paragraph, - }, -}); - -/** - * By default, the FilePanel component will render with default tabs. However, - * you can override the tabs to render by passing the `tabs` prop. You can use - * the default tab panels in the `DefaultTabPanels` directory or make your own - * using the `FilePanelPanel` component. - */ +import { schema } from "./schema.js"; export const Composer = () => { const dict = useDictionary(); const editor = useBlockNoteEditor(); @@ -47,7 +27,9 @@ export const Composer = () => { editor={commentEditor} onSubmit={() => { editor.comments!.createThread({ - body: editor.document, + initialComment: { + body: commentEditor.document, + }, }); }} /> diff --git a/packages/react/src/components/Comments/FloatingThreadsController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx similarity index 52% rename from packages/react/src/components/Comments/FloatingThreadsController.tsx rename to packages/react/src/components/Comments/FloatingThreadController.tsx index aa6c72db1c..17e7fcc5bb 100644 --- a/packages/react/src/components/Comments/FloatingThreadsController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -7,13 +7,21 @@ import { StyleSchema, } from "@blocknote/core"; import { UseFloatingOptions, flip, offset } from "@floating-ui/react"; -import { FC, useMemo } from "react"; +import { FC, useCallback, useEffect, useLayoutEffect } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useUIElementPositioning } from "../../hooks/useUIElementPositioning.js"; import { useUIPluginState } from "../../hooks/useUIPluginState.js"; -import { Composer } from "./Composer.js"; +import { Thread } from "./Thread.js"; +/** + * This component is pretty close to the LiveBlocks FloatingThreads one. + * We have a bit of a different approach to communicating data to / from the plugin + */ + +/** + * TODO: docs + */ export const FloatingThreadController = < B extends BlockSchema = DefaultBlockSchema, I extends InlineContentSchema = DefaultInlineContentSchema, @@ -34,21 +42,9 @@ export const FloatingThreadController = < editor.comments.onUpdate.bind(editor.comments) ); - const referencePos = useMemo(() => { - if (!state?.pendingComment) { - return null; - } - - // TODO: update referencepos when doc changes (remote updates) - return editor.getSelectionBoundingBox(); - }, [editor, state?.pendingComment]); - // TODO: review - const { isMounted, ref, style, getFloatingProps } = useUIElementPositioning( - state?.pendingComment || false, - referencePos || null, - 5000, - { + const { isMounted, ref, style, getFloatingProps, setReference } = + useUIElementPositioning(!!state?.selectedThreadId, null, 5000, { placement: "bottom", middleware: [offset(10), flip()], onOpenChange: (open) => { @@ -58,19 +54,51 @@ export const FloatingThreadController = < } }, ...props.floatingOptions, + }); + + // TODO: could also use thread position from the state. prefer this? + const updateRef = useCallback(() => { + if (!state?.selectedThreadId) { + return; } - ); + + const el = editor.domElement?.querySelector( + `[data-bn-thread-id="${state?.selectedThreadId}"]` + ); + if (el) { + setReference(el); + } + }, [setReference, editor, state?.selectedThreadId]); + + // Remote cursor updates and other edits can cause the ref to break + useEffect(() => { + if (!state?.selectedThreadId) { + return; + } + + return editor.onChange(() => { + updateRef(); + }); + }, [editor, updateRef, state?.selectedThreadId]); + + useLayoutEffect(updateRef, [updateRef]); if (!isMounted || !state) { return null; } - const Component = props.filePanel || Composer; + if (!state.selectedThreadId) { + return null; // TODO + } + + const Component = props.filePanel || Thread; + + const thread = editor.comments.store.getThread(state.selectedThreadId); return (
{/*
hello
*/} - +
); }; diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx new file mode 100644 index 0000000000..b17ffba6a1 --- /dev/null +++ b/packages/react/src/components/Comments/Thread.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { ThreadData, mergeCSSClasses } from "@blocknote/core"; +import type { + ComponentPropsWithoutRef, + ForwardedRef, + SyntheticEvent, +} from "react"; +import { forwardRef, useCallback, useMemo } from "react"; +import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { Comment, CommentProps } from "./Comment.js"; + +export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { + /** + * The thread to display. + */ + thread: ThreadData; + + /** + * How to show or hide the composer to reply to the thread. + */ + showComposer?: boolean | "collapsed"; + + /** + * Whether to show the action to resolve the thread. + */ + showResolveAction?: boolean; + + /** + * How to show or hide the actions. + */ + showActions?: CommentProps["showActions"]; + + /** + * Whether to show reactions. + */ + showReactions?: CommentProps["showReactions"]; + + /** + * Whether to show the composer's formatting controls. + */ + // showComposerFormattingControls?: ComposerProps["showFormattingControls"]; + + /** + * Whether to indent the comments' content. + */ + indentCommentContent?: CommentProps["indentContent"]; + + /** + * Whether to show deleted comments. + */ + showDeletedComments?: CommentProps["showDeleted"]; + + /** + * Whether to show attachments. + */ + showAttachments?: boolean; + + /** + * The event handler called when changing the resolved status. + */ + onResolvedChange?: (resolved: boolean) => void; + + /** + * The event handler called when a comment is edited. + */ + onCommentEdit?: CommentProps["onCommentEdit"]; + + /** + * The event handler called when a comment is deleted. + */ + onCommentDelete?: CommentProps["onCommentDelete"]; + + /** + * The event handler called when the thread is deleted. + * A thread is deleted when all its comments are deleted. + */ + onThreadDelete?: (thread: ThreadData) => void; + + /** + * The event handler called when clicking on a comment's author. + */ + onAuthorClick?: CommentProps["onAuthorClick"]; + + /** + * The event handler called when clicking on a mention. + */ + onMentionClick?: CommentProps["onMentionClick"]; + + /** + * The event handler called when clicking on a comment's attachment. + */ + // onAttachmentClick?: CommentProps["onAttachmentClick"]; + + /** + * The event handler called when the composer is submitted. + */ + // onComposerSubmit?: ComposerProps["onComposerSubmit"]; + + /** + * Override the component's strings. + */ + // overrides?: Partial< + // GlobalOverrides & ThreadOverrides & CommentOverrides & ComposerOverrides + // >; +} + +/** + * Displays a thread of comments, with a composer to reply + * to it. + * + * @example + * <> + * {threads.map((thread) => ( + * + * ))} + * + */ +export const Thread = forwardRef( + ( + { + thread, + indentCommentContent = true, + showActions = "hover", + showDeletedComments, + showResolveAction = true, + showReactions = true, + showComposer = "collapsed", + showAttachments = true, + // showComposerFormattingControls = true, + onResolvedChange, + onCommentEdit, + onCommentDelete, + onThreadDelete, + onAuthorClick, + onMentionClick, + // onAttachmentClick, + // onComposerSubmit, + // overrides, + className, + ...props + }: ThreadProps, + forwardedRef: ForwardedRef + ) => { + // const markThreadAsResolved = useMarkRoomThreadAsResolved(thread.roomId); + // const markThreadAsUnresolved = useMarkRoomThreadAsUnresolved(thread.roomId); + + const ctx = useComponentsContext()!; + const firstCommentIndex = useMemo(() => { + return showDeletedComments + ? 0 + : thread.comments.findIndex((comment) => comment.body); + }, [showDeletedComments, thread.comments]); + + const stopPropagation = useCallback((event: SyntheticEvent) => { + event.stopPropagation(); + }, []); + + // const handleResolvedChange = useCallback( + // (resolved: boolean) => { + // onResolvedChange?.(resolved); + + // if (resolved) { + // markThreadAsResolved(thread.id); + // } else { + // markThreadAsUnresolved(thread.id); + // } + // }, + // [ + // markThreadAsResolved, + // markThreadAsUnresolved, + // onResolvedChange, + // thread.id, + // ] + // ); + + // TODO: thread deletion + + // const handleCommentDelete = useCallback( + // (comment: Comment) => { + // onCommentDelete?.(comment); + + // const filteredComments = thread.comments.filter( + // (comment) => comment.body + // ); + + // if (filteredComments.length <= 1) { + // onThreadDelete?.(thread); + // } + // }, + // [onCommentDelete, onThreadDelete, thread] + // ); + + // TODO: extract component + return ( + + + {thread.comments.map((comment, index) => { + const isFirstComment = index === firstCommentIndex; + + return ( + + // + // + // + // + // ) : null + // } + /> + ); + })} + + + {/* {showComposer && ( + + )} */} + + + ); + } +) as (props: ThreadProps & RefAttributes) => JSX.Element; diff --git a/packages/react/src/components/Comments/schema.ts b/packages/react/src/components/Comments/schema.ts new file mode 100644 index 0000000000..c9078380d7 --- /dev/null +++ b/packages/react/src/components/Comments/schema.ts @@ -0,0 +1,8 @@ +import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core"; + +// TODO: disable props on paragraph +export const schema = BlockNoteSchema.create({ + blockSpecs: { + paragraph: defaultBlockSpecs.paragraph, + }, +}); diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx index 88cf55c01f..606389acab 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -1,4 +1,5 @@ import { FloatingComposerController } from "../components/Comments/FloatingComposerController.js"; +import { FloatingThreadController } from "../components/Comments/FloatingThreadController.js"; import { FilePanelController } from "../components/FilePanel/FilePanelController.js"; import { FormattingToolbarController } from "../components/FormattingToolbar/FormattingToolbarController.js"; import { LinkToolbarController } from "../components/LinkToolbar/LinkToolbarController.js"; @@ -48,7 +49,10 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { )} {editor.comments && props.comments !== false && ( - + <> + + + )} ); diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index af1e758bb5..3218c58073 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -260,11 +260,24 @@ export type ComponentProps = { }; }; Comments: { + Card: { + className?: string; + children?: ReactNode; + }; + CardSection: { + className?: string; + children?: ReactNode; + }; Composer: { className?: string; editor: BlockNoteEditor; onSubmit: () => void; }; + Comment: { + className?: string; + editable: boolean; + editor: BlockNoteEditor; + }; }; }; diff --git a/packages/react/src/hooks/useUIElementPositioning.ts b/packages/react/src/hooks/useUIElementPositioning.ts index 328e481eb9..1e9de1b10d 100644 --- a/packages/react/src/hooks/useUIElementPositioning.ts +++ b/packages/react/src/hooks/useUIElementPositioning.ts @@ -15,6 +15,7 @@ export function useUIElementPositioning( ) { const { refs, update, context, floatingStyles } = useFloating({ open: show, + strategy: "fixed", ...options, }); const { isMounted, styles } = useTransitionStyles(context); @@ -43,6 +44,7 @@ export function useUIElementPositioning( return { isMounted, ref: refs.setFloating, + setReference: refs.setReference, style: { display: "flex", ...styles, @@ -56,6 +58,7 @@ export function useUIElementPositioning( floatingStyles, isMounted, refs.setFloating, + refs.setReference, styles, zIndex, getFloatingProps, From 99a6905ffa6eea8bc89e3311ea97386334d7fd28 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 13 Jan 2025 17:58:35 +0100 Subject: [PATCH 03/30] fix: Code block language parsing (#1362) * fix: Code block language parsing * Small change to code clarity --------- Co-authored-by: matthewlipski --- .../html/__snapshots__/parse-codeblocks.json | 62 +++++++++++++++++++ .../src/api/parsers/html/parseHTML.test.ts | 9 +++ .../CodeBlockContent/CodeBlockContent.ts | 5 +- 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/api/parsers/html/__snapshots__/parse-codeblocks.json diff --git a/packages/core/src/api/parsers/html/__snapshots__/parse-codeblocks.json b/packages/core/src/api/parsers/html/__snapshots__/parse-codeblocks.json new file mode 100644 index 0000000000..1481967f47 --- /dev/null +++ b/packages/core/src/api/parsers/html/__snapshots__/parse-codeblocks.json @@ -0,0 +1,62 @@ +[ + { + "id": "1", + "type": "codeBlock", + "props": { + "language": "javascript" + }, + "content": [ + { + "type": "text", + "text": "console.log(\"Should default to JS\")", + "styles": {} + } + ], + "children": [] + }, + { + "id": "2", + "type": "codeBlock", + "props": { + "language": "typescript" + }, + "content": [ + { + "type": "text", + "text": "console.log(\"Should parse TS from data-language\")", + "styles": {} + } + ], + "children": [] + }, + { + "id": "3", + "type": "codeBlock", + "props": { + "language": "python" + }, + "content": [ + { + "type": "text", + "text": "print(\"Should parse Python from language- class\")", + "styles": {} + } + ], + "children": [] + }, + { + "id": "4", + "type": "codeBlock", + "props": { + "language": "typescript" + }, + "content": [ + { + "type": "text", + "text": "console.log(\"Should prioritize TS from data-language over language- class\")", + "styles": {} + } + ], + "children": [] + } +] \ No newline at end of file diff --git a/packages/core/src/api/parsers/html/parseHTML.test.ts b/packages/core/src/api/parsers/html/parseHTML.test.ts index fe41fe41c9..2a246da242 100644 --- a/packages/core/src/api/parsers/html/parseHTML.test.ts +++ b/packages/core/src/api/parsers/html/parseHTML.test.ts @@ -516,4 +516,13 @@ With Hard Break

await parseHTMLAndCompareSnapshots(html, "parse-google-docs-html"); }); + + it("Parse codeblocks", async () => { + const html = `
console.log("Should default to JS")
+
console.log("Should parse TS from data-language")
+
print("Should parse Python from language- class")
+
console.log("Should prioritize TS from data-language over language- class")
`; + + await parseHTMLAndCompareSnapshots(html, "parse-codeblocks"); + }); }); diff --git a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts index ce71bba1bc..6b2a502fc1 100644 --- a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts +++ b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts @@ -77,9 +77,10 @@ const CodeBlockContent = createStronglyTypedTiptapNode({ const languages = classNames .filter((className) => className.startsWith("language-")) .map((className) => className.replace("language-", "")); - const [classLanguage] = languages; - language = classLanguage.toLowerCase(); + if (languages.length > 0) { + language = languages[0].toLowerCase(); + } } if (!language) { From bf15c047cdc9d65ab56cd809daf6f09e70400049 Mon Sep 17 00:00:00 2001 From: virgile-dev Date: Mon, 13 Jan 2025 18:03:39 +0100 Subject: [PATCH 04/30] Enhance translation in french in fr.ts (#1359) * Enhance translation in french in fr.ts * Small fixes --------- Co-authored-by: matthewlipski --- packages/core/src/i18n/locales/fr.ts | 58 ++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 152d2805dd..2dc28972a9 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -10,13 +10,14 @@ export const fr: Dictionary = { }, heading_2: { title: "Titre 2", - subtext: "Utilisé pour les sections clés", + subtext: "Titre de deuxième niveau Utilisé pour les sections clés", aliases: ["h2", "titre2", "sous-titre"], group: "Titres", }, heading_3: { title: "Titre 3", - subtext: "Utilisé pour les sous-sections et les titres de groupe", + subtext: + "Titre de troisième niveau utilisé pour les sous-sections et les titres de groupe", aliases: ["h3", "titre3", "sous-titre"], group: "Titres", }, @@ -27,13 +28,21 @@ export const fr: Dictionary = { group: "Blocs de base", }, bullet_list: { - title: "Liste à Puces", - subtext: "Utilisé pour afficher une liste non ordonnée", - aliases: ["ul", "li", "liste", "listeàpuces", "liste à puces"], + title: "Liste à puces", + subtext: "Utilisé pour afficher une liste à puce non numérotée", + aliases: [ + "ul", + "li", + "liste", + "listeàpuces", + "liste à puces", + "bullet points", + "bulletpoints", + ], group: "Blocs de base", }, check_list: { - title: "Liste de vérification", + title: "Liste de tâches", subtext: "Utilisé pour afficher une liste avec des cases à cocher", aliases: [ "ul", @@ -42,13 +51,18 @@ export const fr: Dictionary = { "liste de vérification", "liste cochée", "case à cocher", + "checklist", + "checkbox", + "check box", + "to do", + "todo", ], group: "Blocs de base", }, paragraph: { title: "Paragraphe", subtext: "Utilisé pour le corps de votre document", - aliases: ["p", "paragraphe"], + aliases: ["p", "paragraphe", "texte"], group: "Blocs de base", }, code_block: { @@ -60,7 +74,7 @@ export const fr: Dictionary = { table: { title: "Tableau", subtext: "Utilisé pour les tableaux", - aliases: ["tableau"], + aliases: ["tableau", "grille"], group: "Avancé", }, image: { @@ -69,7 +83,9 @@ export const fr: Dictionary = { aliases: [ "image", "uploadImage", - "télécharger", + "télécharger image", + "téléverser image", + "uploader image", "img", "photo", "média", @@ -82,8 +98,8 @@ export const fr: Dictionary = { subtext: "Insérer une vidéo", aliases: [ "vidéo", - "téléchargerVidéo", - "téléverser", + "télécharger vidéo", + "téléverser vidéo", "mp4", "film", "média", @@ -96,8 +112,8 @@ export const fr: Dictionary = { subtext: "Insérer un audio", aliases: [ "audio", - "téléchargerAudio", - "téléverser", + "télécharger audio", + "téléverser audio", "mp3", "son", "média", @@ -108,18 +124,26 @@ export const fr: Dictionary = { file: { title: "Fichier", subtext: "Insérer un fichier", - aliases: ["fichier", "téléverser", "intégrer", "média", "url"], + aliases: [ + "fichier", + "téléverser fichier", + "intégrer fichier", + "insérer fichier", + "média", + "url", + ], group: "Média", }, emoji: { title: "Emoji", subtext: "Utilisé pour insérer un emoji", - aliases: ["emoji", "émoticône", "émotion", "visage"], + aliases: ["emoji", "émoticône", "émotion", "visage", "smiley"], group: "Autres", }, }, placeholders: { - default: "Entrez du texte ou tapez '/' pour les commandes", + default: + "Entrez du texte ou tapez '/' pour faire apparaître les options de mise en page", heading: "Titre", bulletListItem: "Liste", numberedListItem: "Liste", @@ -280,7 +304,7 @@ export const fr: Dictionary = { audio: "Télécharger un fichier audio", file: "Télécharger un fichier", }, - upload_error: "Erreur : Échec du téléchargement", + upload_error: "Erreur : échec du téléchargement", }, embed: { title: "Intégrer", From 82e068c4ee2ed3fcbae6580adbf16955a896a5ff Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 14 Jan 2025 12:19:45 +0100 Subject: [PATCH 05/30] fix: Multi-editor drag & drop (#1341) * WIP * Fixed multi-editor drag and drop * WIP: Added side menu detection area flag * Small changes * Source blocks are now deleted correctly & fixed error sometimes being thrown after drag & drop * Removed logs * Implemented PR feedback * Updated test snapshot * Moved/refactored tests --- examples/01-basic/04-all-blocks/App.tsx | 342 +++++++++--------- .../__snapshots__/internal/basicBlocks.html | 1 + .../internal/basicBlocksWithProps.html | 1 + .../api/clipboard/clipboardInternal.test.ts | 126 +++++++ .../CodeBlockContent/CodeBlockContent.ts | 3 + .../HeadingBlockContent.ts | 9 - .../BulletListItemBlockContent.ts | 2 +- .../CheckListItemBlockContent.ts | 2 +- .../NumberedListItemBlockContent.ts | 2 +- packages/core/src/editor/BlockNoteEditor.ts | 10 + .../core/src/editor/BlockNoteExtensions.ts | 6 +- .../src/extensions/SideMenu/SideMenuPlugin.ts | 138 +++++-- .../core/src/extensions/SideMenu/dragging.ts | 1 - .../headings-json-chromium-linux.json | 2 +- 14 files changed, 434 insertions(+), 211 deletions(-) create mode 100644 packages/core/src/api/clipboard/__snapshots__/internal/basicBlocks.html create mode 100644 packages/core/src/api/clipboard/__snapshots__/internal/basicBlocksWithProps.html diff --git a/examples/01-basic/04-all-blocks/App.tsx b/examples/01-basic/04-all-blocks/App.tsx index 73681ebcf0..5ae6d024f6 100644 --- a/examples/01-basic/04-all-blocks/App.tsx +++ b/examples/01-basic/04-all-blocks/App.tsx @@ -1,207 +1,203 @@ import { + BlockNoteEditorOptions, BlockNoteSchema, - combineByGroup, - filterSuggestionItems, locales, } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; import { - 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", - content: "Welcome to this demo!", - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Blocks:", - styles: { bold: true }, - }, - ], - }, - { - 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!", - }, - ], + +const schema = withMultiColumn(BlockNoteSchema.create()); +const options = { + schema: withMultiColumn(BlockNoteSchema.create()), + dropCursor: multiColumnDropCursor, + dictionary: { + ...locales.en, + multi_column: multiColumnLocales.en, + }, + initialContent: [ + { + type: "paragraph", + content: "Welcome to this demo!", + }, + { + type: "paragraph", + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Blocks:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "columnList", + children: [ + { + type: "column", + props: { + width: 0.8, }, - ], - }, - { - type: "heading", - content: "Heading", - }, - { - type: "bulletListItem", - content: "Bullet List Item", - }, - { - type: "numberedListItem", - content: "Numbered List Item", - }, - { - type: "checkListItem", - content: "Check List Item", - }, - { - type: "codeBlock", - props: { language: "javascript" }, - content: "console.log('Hello, world!');", - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ - { - cells: ["Table Cell", "Table Cell", "Table Cell"], - }, + children: [ { - cells: ["Table Cell", "Table Cell", "Table Cell"], + type: "paragraph", + content: "Hello to the left!", }, + ], + }, + { + type: "column", + props: { + width: 1.2, + }, + children: [ { - cells: ["Table Cell", "Table Cell", "Table Cell"], + type: "paragraph", + content: "Hello to the right!", }, ], }, - }, - { - type: "file", - }, - { - type: "image", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", - }, - }, - { - type: "video", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - }, - }, - { - type: "audio", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - }, - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Inline Content:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: [ + ], + }, + { + type: "heading", + content: "Heading", + }, + { + type: "bulletListItem", + content: "Bullet List Item", + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "codeBlock", + props: { language: "javascript" }, + content: "console.log('Hello, world!');", + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ { - type: "text", - text: "Styled Text", - styles: { - bold: true, - italic: true, - textColor: "red", - backgroundColor: "blue", - }, + cells: ["Table Cell", "Table Cell", "Table Cell"], }, { - type: "text", - text: " ", - styles: {}, + cells: ["Table Cell", "Table Cell", "Table Cell"], }, { - type: "link", - content: "Link", - href: "https://www.blocknotejs.org", + cells: ["Table Cell", "Table Cell", "Table Cell"], }, ], }, - { - type: "paragraph", + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", }, - ], - }); + }, + { + type: "paragraph", + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Inline Content:", + styles: { bold: true }, + }, + ], + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, + }, + { + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", + }, + ], + }, + { + type: "paragraph", + }, + ], + // sideMenuDetection: "editor", +} satisfies Partial< + BlockNoteEditorOptions< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + > +>; - const slashMenuItems = useMemo(() => { - return combineByGroup( - getDefaultReactSlashMenuItems(editor), - getMultiColumnSlashMenuItems(editor) - ); - }, [editor]); +export default function App() { + // Creates a new editor instance. + const editor1 = useCreateBlockNote(options); + const editor2 = useCreateBlockNote(options); // Renders the editor instance using a React component. return ( - - filterSuggestionItems(slashMenuItems, query)} - /> - +
+ + {/**/} +
); } diff --git a/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocks.html b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocks.html new file mode 100644 index 0000000000..8c7757e46a --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocks.html @@ -0,0 +1 @@ +

Paragraph

Heading

  1. Numbered List Item

  • Bullet List Item

  • Check List Item

console.log("Hello World");

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Add image

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocksWithProps.html b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocksWithProps.html new file mode 100644 index 0000000000..4397413824 --- /dev/null +++ b/packages/core/src/api/clipboard/__snapshots__/internal/basicBlocksWithProps.html @@ -0,0 +1 @@ +

Paragraph

Heading

  1. Numbered List Item

  • Bullet List Item

  • Check List Item

console.log("Hello World");

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

Table Cell

1280px-Placeholder_view_vector.svg.png
Placeholder

\ No newline at end of file diff --git a/packages/core/src/api/clipboard/clipboardInternal.test.ts b/packages/core/src/api/clipboard/clipboardInternal.test.ts index 29b5393f07..0fb136575e 100644 --- a/packages/core/src/api/clipboard/clipboardInternal.test.ts +++ b/packages/core/src/api/clipboard/clipboardInternal.test.ts @@ -148,6 +148,122 @@ describe("Test ProseMirror selection clipboard HTML", () => { type: "customParagraph", content: "Paragraph", }, + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "heading", + content: "Heading", + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "bulletListItem", + content: "Bullet List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "codeBlock", + content: 'console.log("Hello World");', + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + ], + }, + }, + { + type: "image", + }, + { + type: "paragraph", + props: { + textColor: "red", + }, + content: "Paragraph", + }, + { + type: "heading", + props: { + level: 2, + }, + content: "Heading", + }, + { + type: "numberedListItem", + props: { + start: 2, + }, + content: "Numbered List Item", + }, + { + type: "bulletListItem", + props: { + backgroundColor: "red", + }, + content: "Bullet List Item", + }, + { + type: "checkListItem", + props: { + checked: true, + }, + content: "Check List Item", + }, + { + type: "codeBlock", + props: { + language: "typescript", + }, + content: 'console.log("Hello World");', + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + { + cells: [["Table Cell"], ["Table Cell"], ["Table Cell"]], + }, + ], + }, + }, + { + type: "image", + props: { + name: "1280px-Placeholder_view_vector.svg.png", + url: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Placeholder_view_vector.svg/1280px-Placeholder_view_vector.svg.png", + caption: "Placeholder", + showPreview: true, + previewWidth: 256, + }, + }, + { + type: "paragraph", + }, ]; let editor: BlockNoteEditor; @@ -299,6 +415,16 @@ describe("Test ProseMirror selection clipboard HTML", () => { createCopySelection: (doc) => TextSelection.create(doc, 277, 286), createPasteSelection: (doc) => TextSelection.create(doc, 290, 299), }, + // Copy/paste basic blocks. + { + testName: "basicBlocks", + createCopySelection: (doc) => TextSelection.create(doc, 303, 558), + }, + // Copy/paste basic blocks with props. + { + testName: "basicBlocksWithProps", + createCopySelection: (doc) => TextSelection.create(doc, 558, 813), + }, ]; for (const testCase of testCases) { diff --git a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts index 6b2a502fc1..0ed9485951 100644 --- a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts +++ b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts @@ -94,6 +94,7 @@ const CodeBlockContent = createStronglyTypedTiptapNode({ ); }, renderHTML: (attributes) => { + // TODO: Use `data-language="..."` instead for easier parsing return attributes.language && attributes.language !== "text" ? { class: `language-${attributes.language}`, @@ -107,9 +108,11 @@ const CodeBlockContent = createStronglyTypedTiptapNode({ return [ { tag: "div[data-content-type=" + this.name + "]", + contentElement: "code", }, { tag: "pre", + contentElement: "code", preserveWhitespace: "full", }, ]; diff --git a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts index 2bf825dd6f..f20e0b4197 100644 --- a/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts +++ b/packages/core/src/blocks/HeadingBlockContent/HeadingBlockContent.ts @@ -124,15 +124,6 @@ const HeadingBlockContent = createStronglyTypedTiptapNode({ return [ { tag: "div[data-content-type=" + this.name + "]", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - return { - level: element.getAttribute("data-level"), - }; - }, }, { tag: "h1", diff --git a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts index 06259794fe..e373c95242 100644 --- a/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/BulletListItemBlockContent/BulletListItemBlockContent.ts @@ -79,7 +79,7 @@ const BulletListItemBlockContent = createStronglyTypedTiptapNode({ return [ // Case for regular HTML list structure. { - tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + tag: "div[data-content-type=" + this.name + "]", }, { tag: "li", diff --git a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts index 628df671a4..3c2b2a209d 100644 --- a/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/CheckListItemBlockContent/CheckListItemBlockContent.ts @@ -118,7 +118,7 @@ const checkListItemBlockContent = createStronglyTypedTiptapNode({ parseHTML() { return [ { - tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + tag: "div[data-content-type=" + this.name + "]", }, // Checkbox only. { diff --git a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts index f4de5a8ac7..d637122727 100644 --- a/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts +++ b/packages/core/src/blocks/ListItemBlockContent/NumberedListItemBlockContent/NumberedListItemBlockContent.ts @@ -106,7 +106,7 @@ const NumberedListItemBlockContent = createStronglyTypedTiptapNode({ parseHTML() { return [ { - tag: "div[data-content-type=" + this.name + "]", // TODO: remove if we can't come up with test case that needs this + tag: "div[data-content-type=" + this.name + "]", }, // Case for regular HTML list structure. // (e.g.: when pasting from other apps) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index f974af072f..bbfc6388a1 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -245,6 +245,15 @@ export type BlockNoteEditorOptions< @default "prefer-navigate-ui" */ tabBehavior: "prefer-navigate-ui" | "prefer-indent"; + + /** + * The detection mode for showing the side menu - "viewport" always shows the + * side menu for the block next to the mouse cursor, while "editor" only shows + * it when hovering the editor or the side menu itself. + * + * @default "viewport" + */ + sideMenuDetection: "viewport" | "editor"; }; const blockNoteTipTapOptions = { @@ -423,6 +432,7 @@ export class BlockNoteEditor< dropCursor: this.options.dropCursor ?? dropCursor, placeholders: newOptions.placeholders, tabBehavior: newOptions.tabBehavior, + sideMenuDetection: newOptions.sideMenuDetection || "viewport", }); // add extensions from _tiptapOptions diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index 354ec85bda..ac826dce26 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -72,6 +72,7 @@ type ExtensionOptions< dropCursor: (opts: any) => Plugin; placeholders: Record; tabBehavior?: "prefer-navigate-ui" | "prefer-indent"; + sideMenuDetection: "viewport" | "editor"; }; /** @@ -97,7 +98,10 @@ export const getBlockNoteExtensions = < opts.editor ); ret["linkToolbar"] = new LinkToolbarProsemirrorPlugin(opts.editor); - ret["sideMenu"] = new SideMenuProsemirrorPlugin(opts.editor); + ret["sideMenu"] = new SideMenuProsemirrorPlugin( + opts.editor, + opts.sideMenuDetection + ); ret["suggestionMenus"] = new SuggestionMenuProseMirrorPlugin(opts.editor); ret["filePanel"] = new FilePanelProsemirrorPlugin(opts.editor as any); ret["placeholder"] = new PlaceholderPlugin(opts.editor, opts.placeholders); diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index eb50f42487..62a1541c8e 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -1,6 +1,6 @@ -import { PluginView } from "@tiptap/pm/state"; -import { EditorState, Plugin, PluginKey } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; +import { DOMParser, Slice } from "@tiptap/pm/model"; +import { EditorState, Plugin, PluginKey, PluginView } from "@tiptap/pm/state"; +import { EditorView } from "@tiptap/pm/view"; import { Block } from "../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; @@ -14,6 +14,7 @@ import { EventEmitter } from "../../util/EventEmitter.js"; import { initializeESMDependencies } from "../../util/esmDependencies.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; import { dragStart, unsetDragImage } from "./dragging.js"; + export type SideMenuState< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -28,9 +29,14 @@ const PERCENTAGE_OF_BLOCK_WIDTH_CONSIDERED_SIDE_DROP = 0.1; function getBlockFromCoords( view: EditorView, coords: { left: number; top: number }, + sideMenuDetection: "viewport" | "editor", adjustForColumns = true ) { - const elements = view.root.elementsFromPoint(coords.left, coords.top); + const elements = view.root.elementsFromPoint( + // bit hacky - offset x position to right to account for the width of sidemenu itself + coords.left + (sideMenuDetection === "editor" ? 50 : 0), + coords.top + ); for (const element of elements) { if (!view.dom.contains(element)) { @@ -46,6 +52,7 @@ function getBlockFromCoords( 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, }, + sideMenuDetection, false ); } @@ -60,7 +67,8 @@ function getBlockFromMousePos( x: number; y: number; }, - view: EditorView + view: EditorView, + sideMenuDetection: "viewport" | "editor" ): { 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 @@ -76,7 +84,7 @@ function getBlockFromMousePos( // this.horizontalPosAnchor = editorBoundingBox.x; - // Gets block at mouse cursor's vertical position. + // Gets block at mouse cursor's position. const coords = { left: mousePos.x, top: mousePos.y, @@ -85,15 +93,18 @@ function getBlockFromMousePos( const mouseLeftOfEditor = coords.left < editorBoundingBox.left; const mouseRightOfEditor = coords.left > editorBoundingBox.right; - if (mouseLeftOfEditor) { - coords.left = editorBoundingBox.left + 10; - } + // Clamps the x position to the editor's bounding box. + if (sideMenuDetection === "viewport") { + if (mouseLeftOfEditor) { + coords.left = editorBoundingBox.left + 10; + } - if (mouseRightOfEditor) { - coords.left = editorBoundingBox.right - 10; + if (mouseRightOfEditor) { + coords.left = editorBoundingBox.right - 10; + } } - let block = getBlockFromCoords(view, coords); + let block = getBlockFromCoords(view, coords, sideMenuDetection); if (!mouseRightOfEditor && block) { // note: this case is not necessary when we're on the right side of the editor @@ -101,14 +112,14 @@ function getBlockFromMousePos( /* 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); + block = getBlockFromCoords(view, coords, "viewport", false); } return block; @@ -132,8 +143,11 @@ export class SideMenuView< public menuFrozen = false; + public isDragOrigin = false; + constructor( private readonly editor: BlockNoteEditor, + private readonly sideMenuDetection: "viewport" | "editor", private readonly pmView: EditorView, emitUpdate: (state: SideMenuState) => void ) { @@ -146,14 +160,18 @@ export class SideMenuView< }; this.pmView.root.addEventListener( - "drop", - this.onDrop as EventListener, - true + "dragstart", + this.onDragStart as EventListener ); this.pmView.root.addEventListener( "dragover", this.onDragOver as EventListener ); + this.pmView.root.addEventListener( + "drop", + this.onDrop as EventListener, + true + ); initializeESMDependencies(); // Shows or updates menu position whenever the cursor moves, if the menu isn't frozen. @@ -181,7 +199,11 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const block = getBlockFromMousePos( + this.mousePos, + this.pmView, + this.sideMenuDetection + ); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -249,7 +271,16 @@ export class SideMenuView< onDrop = (event: DragEvent) => { this.editor._tiptapEditor.commands.blur(); + // ProseMirror doesn't remove the dragged content if it's dropped outside + // the editor (e.g. to other editors), so we need to do it manually. Since + // the dragged content is the same as the selected content, we can just + // delete the selection. + if (this.isDragOrigin && !this.pmView.dom.contains(event.target as Node)) { + this.pmView.dispatch(this.pmView.state.tr.deleteSelection()); + } + if ( + this.sideMenuDetection === "editor" || (event as any).synthetic || !event.dataTransfer?.types.includes("blocknote/html") ) { @@ -268,6 +299,46 @@ export class SideMenuView< } }; + /** + * If a block is being dragged, ProseMirror usually gets the context of what's + * being dragged from `view.dragging`, which is automatically set when a + * `dragstart` event fires in the editor. However, if the user tries to drag + * and drop blocks between multiple editors, only the one in which the drag + * began has that context, so we need to set it on the others manually. This + * ensures that PM always drops the blocks in between other blocks, and not + * inside them. + * + * After the `dragstart` event fires on the drag handle, it sets + * `blocknote/html` data on the clipboard. This handler fires right after, + * parsing the `blocknote/html` data into nodes and setting them on + * `view.dragging`. + * + * Note: Setting `view.dragging` on `dragover` would be better as the user + * could then drag between editors in different windows, but you can only + * access `dataTransfer` contents on `dragstart` and `drop` events. + */ + onDragStart = (event: DragEvent) => { + if (!this.pmView.dragging) { + const html = event.dataTransfer?.getData("blocknote/html"); + if (!html) { + return; + } + + const element = document.createElement("div"); + element.innerHTML = html; + + const parser = DOMParser.fromSchema(this.pmView.state.schema); + const node = parser.parse(element, { + topNode: this.pmView.state.schema.nodes["blockGroup"].create(), + }); + + this.pmView.dragging = { + slice: new Slice(node.content, 0, 0), + move: true, + }; + } + }; + /** * If the event is outside the editor contents, * we dispatch a fake event, so that we can still drop the content @@ -275,11 +346,13 @@ export class SideMenuView< */ onDragOver = (event: DragEvent) => { if ( + this.sideMenuDetection === "editor" || (event as any).synthetic || !event.dataTransfer?.types.includes("blocknote/html") ) { return; } + const pos = this.pmView.posAtCoords({ left: event.clientX, top: event.clientY, @@ -424,11 +497,14 @@ export class SideMenuView< this.onMouseMove as EventListener, true ); + this.pmView.root.removeEventListener( + "dragstart", + this.onDragStart as EventListener + ); this.pmView.root.removeEventListener( "dragover", this.onDragOver as EventListener ); - this.pmView.root.removeEventListener( "drop", this.onDrop as EventListener, @@ -452,14 +528,22 @@ export class SideMenuProsemirrorPlugin< public view: SideMenuView | undefined; public readonly plugin: Plugin; - constructor(private readonly editor: BlockNoteEditor) { + constructor( + private readonly editor: BlockNoteEditor, + sideMenuDetection: "viewport" | "editor" + ) { super(); this.plugin = new Plugin({ key: sideMenuPluginKey, view: (editorView) => { - this.view = new SideMenuView(editor, editorView, (state) => { - this.emit("update", state); - }); + this.view = new SideMenuView( + editor, + sideMenuDetection, + editorView, + (state) => { + this.emit("update", state); + } + ); return this.view; }, }); @@ -479,6 +563,10 @@ export class SideMenuProsemirrorPlugin< }, block: Block ) => { + if (this.view) { + this.view.isDragOrigin = true; + } + dragStart(event, block, this.editor); }; @@ -489,6 +577,10 @@ export class SideMenuProsemirrorPlugin< if (this.editor.prosemirrorView) { unsetDragImage(this.editor.prosemirrorView.root); } + + if (this.view) { + this.view.isDragOrigin = false; + } }; /** * Freezes the side menu. When frozen, the side menu will stay diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index 2c8a4bb5d1..1dba4f462e 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -202,6 +202,5 @@ export function dragStart< e.dataTransfer.setData("text/plain", plainText); e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setDragImage(dragImageElement!, 0, 0); - view.dragging = { slice: selectedSlice, move: true }; } } diff --git a/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json index a777ca1166..efc45cfa6f 100644 --- a/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json +++ b/tests/src/end-to-end/copypaste/copypaste.test.ts-snapshots/headings-json-chromium-linux.json @@ -101,7 +101,7 @@ "type": "heading", "attrs": { "textAlignment": "left", - "level": null + "level": 1 }, "content": [ { From 1c34ed6ca68acd7360ebede0fe712fdb1b03a27c Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Tue, 14 Jan 2025 12:47:35 +0100 Subject: [PATCH 06/30] fix: Cache & reuse Shiki highlighter & parser for improved performance (#1363) * fix: Cache & reuse Shiki highlighter & parser for improved performance * Updated multi editor example and reverted all blocks example due to unwanted changes in merged PR --------- Co-authored-by: matthewlipski --- examples/01-basic/04-all-blocks/App.tsx | 341 +++++++++--------- .../01-basic/12-multi-editor/.bnexample.json | 6 + examples/01-basic/12-multi-editor/App.tsx | 55 +++ examples/01-basic/12-multi-editor/README.md | 7 + examples/01-basic/12-multi-editor/index.html | 14 + examples/01-basic/12-multi-editor/main.tsx | 11 + .../01-basic/12-multi-editor/package.json | 37 ++ .../01-basic/12-multi-editor/tsconfig.json | 36 ++ .../01-basic/12-multi-editor/vite.config.ts | 32 ++ .../CodeBlockContent/CodeBlockContent.ts | 35 +- playground/src/examples.gen.tsx | 18 + 11 files changed, 415 insertions(+), 177 deletions(-) create mode 100644 examples/01-basic/12-multi-editor/.bnexample.json create mode 100644 examples/01-basic/12-multi-editor/App.tsx create mode 100644 examples/01-basic/12-multi-editor/README.md create mode 100644 examples/01-basic/12-multi-editor/index.html create mode 100644 examples/01-basic/12-multi-editor/main.tsx create mode 100644 examples/01-basic/12-multi-editor/package.json create mode 100644 examples/01-basic/12-multi-editor/tsconfig.json create mode 100644 examples/01-basic/12-multi-editor/vite.config.ts diff --git a/examples/01-basic/04-all-blocks/App.tsx b/examples/01-basic/04-all-blocks/App.tsx index 5ae6d024f6..6a34751b75 100644 --- a/examples/01-basic/04-all-blocks/App.tsx +++ b/examples/01-basic/04-all-blocks/App.tsx @@ -1,203 +1,208 @@ import { - BlockNoteEditorOptions, BlockNoteSchema, + combineByGroup, + filterSuggestionItems, locales, } from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; -import { useCreateBlockNote } from "@blocknote/react"; import { + SuggestionMenuController, + getDefaultReactSlashMenuItems, + useCreateBlockNote, +} from "@blocknote/react"; +import { + getMultiColumnSlashMenuItems, multiColumnDropCursor, locales as multiColumnLocales, withMultiColumn, } from "@blocknote/xl-multi-column"; +import { useMemo } from "react"; -const schema = withMultiColumn(BlockNoteSchema.create()); -const options = { - schema: withMultiColumn(BlockNoteSchema.create()), - dropCursor: multiColumnDropCursor, - dictionary: { - ...locales.en, - multi_column: multiColumnLocales.en, - }, - initialContent: [ - { - type: "paragraph", - content: "Welcome to this demo!", - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Blocks:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: "Paragraph", - }, - { - type: "columnList", - children: [ - { - type: "column", - props: { - width: 0.8, +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", + content: "Welcome to this demo!", + }, + { + type: "paragraph", + }, + { + type: "paragraph", + content: [ + { + type: "text", + text: "Blocks:", + styles: { bold: true }, }, - children: [ - { - type: "paragraph", - content: "Hello to the left!", + ], + }, + { + type: "paragraph", + content: "Paragraph", + }, + { + type: "columnList", + children: [ + { + type: "column", + props: { + width: 0.8, }, - ], - }, - { - type: "column", - props: { - width: 1.2, + children: [ + { + type: "paragraph", + content: "Hello to the left!", + }, + ], }, - children: [ + { + type: "column", + props: { + width: 1.2, + }, + children: [ + { + type: "paragraph", + content: "Hello to the right!", + }, + ], + }, + ], + }, + { + type: "heading", + content: "Heading", + }, + { + type: "bulletListItem", + content: "Bullet List Item", + }, + { + type: "numberedListItem", + content: "Numbered List Item", + }, + { + type: "checkListItem", + content: "Check List Item", + }, + { + type: "codeBlock", + props: { language: "javascript" }, + content: "console.log('Hello, world!');", + }, + { + type: "table", + content: { + type: "tableContent", + rows: [ + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, + { + cells: ["Table Cell", "Table Cell", "Table Cell"], + }, { - type: "paragraph", - content: "Hello to the right!", + cells: ["Table Cell", "Table Cell", "Table Cell"], }, ], }, - ], - }, - { - type: "heading", - content: "Heading", - }, - { - type: "bulletListItem", - content: "Bullet List Item", - }, - { - type: "numberedListItem", - content: "Numbered List Item", - }, - { - type: "checkListItem", - content: "Check List Item", - }, - { - type: "codeBlock", - props: { language: "javascript" }, - content: "console.log('Hello, world!');", - }, - { - type: "table", - content: { - type: "tableContent", - rows: [ + }, + { + type: "file", + }, + { + type: "image", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", + }, + }, + { + type: "video", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", + }, + }, + { + type: "audio", + props: { + url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + caption: + "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + }, + }, + { + type: "paragraph", + }, + { + type: "paragraph", + content: [ { - cells: ["Table Cell", "Table Cell", "Table Cell"], + type: "text", + text: "Inline Content:", + styles: { bold: true }, }, + ], + }, + { + type: "paragraph", + content: [ { - cells: ["Table Cell", "Table Cell", "Table Cell"], + type: "text", + text: "Styled Text", + styles: { + bold: true, + italic: true, + textColor: "red", + backgroundColor: "blue", + }, + }, + { + type: "text", + text: " ", + styles: {}, }, { - cells: ["Table Cell", "Table Cell", "Table Cell"], + type: "link", + content: "Link", + href: "https://www.blocknotejs.org", }, ], }, - }, - { - type: "file", - }, - { - type: "image", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg", - }, - }, - { - type: "video", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm", - }, - }, - { - type: "audio", - props: { - url: "https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", - caption: - "From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3", + { + type: "paragraph", }, - }, - { - type: "paragraph", - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Inline Content:", - styles: { bold: true }, - }, - ], - }, - { - type: "paragraph", - content: [ - { - type: "text", - text: "Styled Text", - styles: { - bold: true, - italic: true, - textColor: "red", - backgroundColor: "blue", - }, - }, - { - type: "text", - text: " ", - styles: {}, - }, - { - type: "link", - content: "Link", - href: "https://www.blocknotejs.org", - }, - ], - }, - { - type: "paragraph", - }, - ], - // sideMenuDetection: "editor", -} satisfies Partial< - BlockNoteEditorOptions< - typeof schema.blockSchema, - typeof schema.inlineContentSchema, - typeof schema.styleSchema - > ->; + ], + }); -export default function App() { - // Creates a new editor instance. - const editor1 = useCreateBlockNote(options); - const editor2 = useCreateBlockNote(options); + const slashMenuItems = useMemo(() => { + return combineByGroup( + getDefaultReactSlashMenuItems(editor), + getMultiColumnSlashMenuItems(editor) + ); + }, [editor]); // Renders the editor instance using a React component. return ( -
- - {/**/} -
+ + filterSuggestionItems(slashMenuItems, query)} + /> + ); } diff --git a/examples/01-basic/12-multi-editor/.bnexample.json b/examples/01-basic/12-multi-editor/.bnexample.json new file mode 100644 index 0000000000..54cfd20571 --- /dev/null +++ b/examples/01-basic/12-multi-editor/.bnexample.json @@ -0,0 +1,6 @@ +{ + "playground": true, + "docs": true, + "author": "areknawo", + "tags": ["Basic"] +} diff --git a/examples/01-basic/12-multi-editor/App.tsx b/examples/01-basic/12-multi-editor/App.tsx new file mode 100644 index 0000000000..edc2a84c0e --- /dev/null +++ b/examples/01-basic/12-multi-editor/App.tsx @@ -0,0 +1,55 @@ +import { PartialBlock } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useCreateBlockNote } from "@blocknote/react"; + +// Component that creates & renders a BlockNote editor. +function Editor(props: { initialContent?: PartialBlock[] }) { + // Creates a new editor instance. + const editor = useCreateBlockNote({ + sideMenuDetection: "editor", + initialContent: props.initialContent, + }); + + // Renders the editor instance using a React component. + return ; +} + +export default function App() { + // Creates & renders two editors side by side. + return ( +
+ + +
+ ); +} diff --git a/examples/01-basic/12-multi-editor/README.md b/examples/01-basic/12-multi-editor/README.md new file mode 100644 index 0000000000..3cee21f329 --- /dev/null +++ b/examples/01-basic/12-multi-editor/README.md @@ -0,0 +1,7 @@ +# Multi-Editor Setup + +This example showcases use of multiple editors in a single page - you can even drag blocks between them. + +**Relevant Docs:** + +- [Editor Setup](/docs/editor-basics/setup) diff --git a/examples/01-basic/12-multi-editor/index.html b/examples/01-basic/12-multi-editor/index.html new file mode 100644 index 0000000000..f7f7370836 --- /dev/null +++ b/examples/01-basic/12-multi-editor/index.html @@ -0,0 +1,14 @@ + + + + + + Multi-Editor Setup + + +
+ + + diff --git a/examples/01-basic/12-multi-editor/main.tsx b/examples/01-basic/12-multi-editor/main.tsx new file mode 100644 index 0000000000..f88b490fbd --- /dev/null +++ b/examples/01-basic/12-multi-editor/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/12-multi-editor/package.json b/examples/01-basic/12-multi-editor/package.json new file mode 100644 index 0000000000..51045d2b16 --- /dev/null +++ b/examples/01-basic/12-multi-editor/package.json @@ -0,0 +1,37 @@ +{ + "name": "@blocknote/example-multi-editor", + "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" + }, + "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/12-multi-editor/tsconfig.json b/examples/01-basic/12-multi-editor/tsconfig.json new file mode 100644 index 0000000000..1bd8ab3c57 --- /dev/null +++ b/examples/01-basic/12-multi-editor/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/12-multi-editor/vite.config.ts b/examples/01-basic/12-multi-editor/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/01-basic/12-multi-editor/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/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts index 0ed9485951..2143d9f686 100644 --- a/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts +++ b/packages/core/src/blocks/CodeBlockContent/CodeBlockContent.ts @@ -25,6 +25,10 @@ interface CodeBlockOptions { supportedLanguages: SupportedLanguageConfig[]; } +export const shikiParserSymbol = Symbol.for("blocknote.shikiParser"); +export const shikiHighlighterPromiseSymbol = Symbol.for( + "blocknote.shikiHighlighterPromise" +); export const defaultCodeBlockPropSchema = { language: { default: "javascript", @@ -199,19 +203,30 @@ const CodeBlockContent = createStronglyTypedTiptapNode({ }; }, addProseMirrorPlugins() { + const supportedLanguages = this.options + .supportedLanguages as SupportedLanguageConfig[]; + const globalThisForShiki = globalThis as { + [shikiHighlighterPromiseSymbol]?: Promise; + [shikiParserSymbol]?: Parser; + }; + let highlighter: Highlighter | undefined; let parser: Parser | undefined; - const supportedLanguages = this.options - .supportedLanguages as SupportedLanguageConfig[]; const lazyParser: Parser = (options) => { if (!highlighter) { - return createHighlighter({ - themes: ["github-dark"], - langs: [], - }).then((createdHighlighter) => { - highlighter = createdHighlighter; - }); + globalThisForShiki[shikiHighlighterPromiseSymbol] = + globalThisForShiki[shikiHighlighterPromiseSymbol] || + createHighlighter({ + themes: ["github-dark"], + langs: [], + }); + + return globalThisForShiki[shikiHighlighterPromiseSymbol].then( + (createdHighlighter) => { + highlighter = createdHighlighter; + } + ); } const language = options.language; @@ -227,7 +242,9 @@ const CodeBlockContent = createStronglyTypedTiptapNode({ } if (!parser) { - parser = createParser(highlighter); + parser = + globalThisForShiki[shikiParserSymbol] || createParser(highlighter); + globalThisForShiki[shikiParserSymbol] = parser; } return parser(options); diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 03af43c998..35a8320c98 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -217,6 +217,24 @@ "slug": "basic" } }, + { + "projectSlug": "multi-editor", + "fullSlug": "basic/multi-editor", + "pathFromRoot": "examples/01-basic/12-multi-editor", + "config": { + "playground": true, + "docs": true, + "author": "areknawo", + "tags": [ + "Basic" + ] + }, + "title": "Multi-Editor Setup", + "group": { + "pathFromRoot": "examples/01-basic", + "slug": "basic" + } + }, { "projectSlug": "testing", "fullSlug": "basic/testing", From 034124259742dc1c5113522bcdf4ff0008de13f2 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 14 Jan 2025 12:50:38 +0100 Subject: [PATCH 07/30] fix: Rollup config for `xl` and `server-util` packages (#1365) * Fixed rollup config for xl and server-util packages * Small fix --- packages/server-util/vite.config.ts | 17 +++++++++++++++-- packages/xl-docx-exporter/package.json | 4 ++++ packages/xl-docx-exporter/vite.config.ts | 17 +++++++++++++++-- packages/xl-multi-column/package.json | 4 ++++ packages/xl-multi-column/vite.config.ts | 17 +++++++++++++++-- packages/xl-pdf-exporter/vite.config.ts | 17 +++++++++++++++-- 6 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/server-util/vite.config.ts b/packages/server-util/vite.config.ts index 940749ef1c..c134c61e54 100644 --- a/packages/server-util/vite.config.ts +++ b/packages/server-util/vite.config.ts @@ -4,7 +4,11 @@ import { defineConfig } from "vite"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; -const deps = Object.keys(pkg.dependencies); +const deps = Object.keys({ + ...pkg.dependencies, + ...pkg.peerDependencies, + ...pkg.devDependencies, +}); // https://vitejs.dev/config/ export default defineConfig((conf) => ({ @@ -37,7 +41,16 @@ export default defineConfig((conf) => ({ if (deps.includes(source)) { return true; } - return source.startsWith("prosemirror-"); + + if (source === "react/jsx-runtime") { + return true; + } + + if (source.startsWith("prosemirror-")) { + return true; + } + + return false; }, output: { // Provide global variables to use in the UMD build diff --git a/packages/xl-docx-exporter/package.json b/packages/xl-docx-exporter/package.json index 6a088a1dbd..ce73995654 100644 --- a/packages/xl-docx-exporter/package.json +++ b/packages/xl-docx-exporter/package.json @@ -67,6 +67,10 @@ "vitest": "^2.0.3", "xml-formatter": "^3.6.3" }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + }, "eslintConfig": { "extends": [ "../../.eslintrc.js" diff --git a/packages/xl-docx-exporter/vite.config.ts b/packages/xl-docx-exporter/vite.config.ts index b17d4637b1..bf56284285 100644 --- a/packages/xl-docx-exporter/vite.config.ts +++ b/packages/xl-docx-exporter/vite.config.ts @@ -4,7 +4,11 @@ import { defineConfig } from "vite"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; -const deps = Object.keys(pkg.dependencies); +const deps = Object.keys({ + ...pkg.dependencies, + ...pkg.peerDependencies, + ...pkg.devDependencies, +}); // https://vitejs.dev/config/ export default defineConfig((conf) => ({ @@ -46,7 +50,16 @@ export default defineConfig((conf) => ({ if (deps.includes(source)) { return true; } - return source.startsWith("prosemirror-"); + + if (source === "react/jsx-runtime") { + return true; + } + + if (source.startsWith("prosemirror-")) { + return true; + } + + return false; }, output: { // Provide global variables to use in the UMD build diff --git a/packages/xl-multi-column/package.json b/packages/xl-multi-column/package.json index a0906d5f62..f0a7d607c3 100644 --- a/packages/xl-multi-column/package.json +++ b/packages/xl-multi-column/package.json @@ -67,6 +67,10 @@ "vite-plugin-eslint": "^1.8.1", "vitest": "^2.0.3" }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" + }, "eslintConfig": { "extends": [ "../../.eslintrc.js" diff --git a/packages/xl-multi-column/vite.config.ts b/packages/xl-multi-column/vite.config.ts index 4aa18f6711..d82b5b09a3 100644 --- a/packages/xl-multi-column/vite.config.ts +++ b/packages/xl-multi-column/vite.config.ts @@ -4,7 +4,11 @@ import { defineConfig } from "vite"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; -const deps = Object.keys(pkg.dependencies); +const deps = Object.keys({ + ...pkg.dependencies, + ...pkg.peerDependencies, + ...pkg.devDependencies, +}); // https://vitejs.dev/config/ export default defineConfig((conf) => ({ @@ -36,7 +40,16 @@ export default defineConfig((conf) => ({ if (deps.includes(source)) { return true; } - return source.startsWith("prosemirror-"); + + if (source === "react/jsx-runtime") { + return true; + } + + if (source.startsWith("prosemirror-")) { + return true; + } + + return false; }, output: { // Provide global variables to use in the UMD build diff --git a/packages/xl-pdf-exporter/vite.config.ts b/packages/xl-pdf-exporter/vite.config.ts index 66eb3bb58a..721e92d225 100644 --- a/packages/xl-pdf-exporter/vite.config.ts +++ b/packages/xl-pdf-exporter/vite.config.ts @@ -4,7 +4,11 @@ import { defineConfig } from "vite"; import pkg from "./package.json"; // import eslintPlugin from "vite-plugin-eslint"; -const deps = Object.keys(pkg.dependencies); +const deps = Object.keys({ + ...pkg.dependencies, + ...pkg.peerDependencies, + ...pkg.devDependencies, +}); // https://vitejs.dev/config/ export default defineConfig((conf) => ({ @@ -53,7 +57,16 @@ export default defineConfig((conf) => ({ if (deps.includes(source)) { return true; } - return source.startsWith("prosemirror-"); + + if (source === "react/jsx-runtime") { + return true; + } + + if (source.startsWith("prosemirror-")) { + return true; + } + + return false; }, output: { // Provide global variables to use in the UMD build From 721a4e94b1834a1ef24cc4aae45077c14e136a27 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 14 Jan 2025 17:42:38 +0100 Subject: [PATCH 08/30] wip --- packages/mantine/src/comments/Comment.tsx | 68 +++- packages/mantine/src/comments/Editor.tsx | 25 ++ packages/mantine/src/components.tsx | 6 + .../react/src/components/Comments/Comment.tsx | 337 +++++++++--------- .../react/src/components/Comments/Thread.tsx | 23 +- .../react/src/editor/ComponentsContext.tsx | 76 ++-- 6 files changed, 306 insertions(+), 229 deletions(-) create mode 100644 packages/mantine/src/comments/Editor.tsx diff --git a/packages/mantine/src/comments/Comment.tsx b/packages/mantine/src/comments/Comment.tsx index f00947a035..0f382408b0 100644 --- a/packages/mantine/src/comments/Comment.tsx +++ b/packages/mantine/src/comments/Comment.tsx @@ -1,25 +1,71 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; +import { Avatar, Group, Skeleton, Text } from "@mantine/core"; import { forwardRef } from "react"; -import { BlockNoteView } from "../BlockNoteView.js"; + +const AuthorInfo = forwardRef< + HTMLDivElement, + ComponentProps["Comments"]["Comment"] +>((props, ref) => { + const { className, authorInfo, timeString, actions, children, ...rest } = + props; + + assertEmpty(rest, false); + + if (authorInfo === "loading") { + return ( + + +
+ +
+
+ ); + } + + return ( + + + + + {authorInfo.username} + + {timeString} + + + + ); +}); export const Comment = forwardRef< HTMLDivElement, ComponentProps["Comments"]["Comment"] >((props, ref) => { - const { className, editor, editable, ...rest } = props; + const { className, authorInfo, timeString, actions, children, ...rest } = + props; assertEmpty(rest, false); return ( - + <> +
+ {actions} +
+ + {children} + ); }); diff --git a/packages/mantine/src/comments/Editor.tsx b/packages/mantine/src/comments/Editor.tsx new file mode 100644 index 0000000000..9975c608e0 --- /dev/null +++ b/packages/mantine/src/comments/Editor.tsx @@ -0,0 +1,25 @@ +import { assertEmpty } from "@blocknote/core"; +import { ComponentProps } from "@blocknote/react"; +import { forwardRef } from "react"; +import { BlockNoteView } from "../BlockNoteView.js"; + +export const Editor = forwardRef< + HTMLDivElement, + ComponentProps["Comments"]["Editor"] +>((props, ref) => { + const { className, editor, editable, ...rest } = props; + + assertEmpty(rest, false); + + return ( + + ); +}); diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx index f8645f19bc..7e736d5a3f 100644 --- a/packages/mantine/src/components.tsx +++ b/packages/mantine/src/components.tsx @@ -3,6 +3,7 @@ import { Components } from "@blocknote/react"; import { Card, CardSection } from "./comments/Card.js"; import { Comment } from "./comments/Comment.js"; import { Composer } from "./comments/Composer.js"; +import { Editor } from "./comments/Editor.js"; import { TextInput } from "./form/TextInput.js"; import { Menu, @@ -94,9 +95,14 @@ export const components: Components = { Trigger: PopoverTrigger, Content: PopoverContent, }, + Toolbar: { + Root: Toolbar, + Button: ToolbarButton, + }, }, Comments: { Comment, + Editor, Composer, Card, CardSection, diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 6fedaec6a6..b99973440a 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -274,7 +274,7 @@ export const Comment = forwardRef( schema, }); - const ctx = useComponentsContext()!; + const Components = useComponentsContext()!; const ref = useRef(null); const mergedRefs = mergeRefs([forwardedRef, ref]); @@ -396,174 +396,156 @@ export const Comment = forwardRef( return null; } + let actions: ReactNode | undefined = undefined; + + if (showActions && !isEditing) { + actions = ( + + {additionalActions ?? null} + {/* {showReactions && ( + + + + + + + + )} */} + + R1 + + + R2 + + + + + ... + + + + + Edit comment + + + Delete comment + + + + {/* {comment.userId === currentUserId && ( + + + + {$.COMMENT_EDIT} + + + + {$.COMMENT_DELETE} + + + }> + + + + + + + )} */} + + ); + } + + const timeString = + comment.createdAt.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) + (comment.updatedAt !== comment.createdAt ? " (edited)" : ""); // TODO: needs editedAt? + return ( -
-
- {/*
- + {isEditing ? ( + + ) : // + // + // + // + // }> + // + // + // + // + // + // } + // overrides={{ + // COMPOSER_PLACEHOLDER: $.COMMENT_EDIT_COMPOSER_PLACEHOLDER, + // }} + // roomId={comment.roomId} + // /> + comment.body ? ( + <> + - - - - - {comment.editedAt && comment.body && ( - <> - {" "} - - {$.COMMENT_EDITED} - - - )} - - -
*/} - {showActions && !isEditing && ( -
- {additionalActions ?? null} - {/* {showReactions && ( - - - - - - - - )} */} - {/* {comment.userId === currentUserId && ( - - - - {$.COMMENT_EDIT} - - - - {$.COMMENT_DELETE} - - - }> - - - - - - - )} */} -
- )} -
-
- {isEditing ? ( - - ) : // - // - // - // - // }> - // - // - // - // - // - // } - // overrides={{ - // COMPOSER_PLACEHOLDER: $.COMMENT_EDIT_COMPOSER_PLACEHOLDER, - // }} - // roomId={comment.roomId} - // /> - comment.body ? ( - <> - - {/* ( - onMentionClick?.(userId, event)} - /> - ), - Link: CommentLink, - }} - /> */} - {showReactions && comment.reactions.length > 0 && ( -
- {/* {comment.reactions.map((reaction) => ( + {showReactions && comment.reactions.length > 0 && ( +
+ {/* {comment.reactions.map((reaction) => ( ( overrides={overrides} /> ))} */} - {/* + {/*
- )} - - ) : ( -
- {/*

{$.COMMENT_DELETED}

*/} -
- )} -
-
+
+ )} + + ) : ( +
+ {/*

{$.COMMENT_DELETED}

*/} +
+ )} + ); } ); diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index b17ffba6a1..23a293882d 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -1,14 +1,13 @@ "use client"; import { ThreadData, mergeCSSClasses } from "@blocknote/core"; -import type { - ComponentPropsWithoutRef, - ForwardedRef, - SyntheticEvent, -} from "react"; +import type { ComponentPropsWithoutRef, ForwardedRef } from "react"; import { forwardRef, useCallback, useMemo } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; +import { useDictionary } from "../../i18n/dictionary.js"; import { Comment, CommentProps } from "./Comment.js"; +import { schema } from "./schema.js"; export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { /** @@ -146,6 +145,19 @@ export const Thread = forwardRef( // const markThreadAsUnresolved = useMarkRoomThreadAsUnresolved(thread.roomId); const ctx = useComponentsContext()!; + const dict = useDictionary(); + const newCommentEditor = useCreateBlockNote({ + trailingBlock: false, + dictionary: { + ...dict, + placeholders: { + ...dict.placeholders, + default: "Add comment...", // TODO: only for empty doc + }, + }, + schema, + }); + const firstCommentIndex = useMemo(() => { return showDeletedComments ? 0 @@ -273,6 +285,7 @@ export const Thread = forwardRef( roomId={thread.roomId} /> )} */} + ); diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 3218c58073..d127e0c939 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -13,24 +13,29 @@ import { BlockNoteEditor } from "@blocknote/core"; import { DefaultReactGridSuggestionItem } from "../components/SuggestionMenu/GridSuggestionMenu/types.js"; import { DefaultReactSuggestionItem } from "../components/SuggestionMenu/types.js"; +type ToolbarRootType = { + className?: string; + children?: ReactNode; + onMouseEnter?: () => void; + onMouseLeave?: () => void; +}; + +type ToolbarButtonType = { + className?: string; + mainTooltip: string; + secondaryTooltip?: string; + icon?: ReactNode; + onClick?: (e: MouseEvent) => void; + isSelected?: boolean; + isDisabled?: boolean; +} & ( + | { children: ReactNode; label?: string } + | { children?: undefined; label: string } +); export type ComponentProps = { FormattingToolbar: { - Root: { - className?: string; - children?: ReactNode; - }; - Button: { - className?: string; - mainTooltip: string; - secondaryTooltip?: string; - icon?: ReactNode; - onClick?: (e: MouseEvent) => void; - isSelected?: boolean; - isDisabled?: boolean; - } & ( - | { children: ReactNode; label?: string } - | { children?: undefined; label: string } - ); + Root: ToolbarRootType; + Button: ToolbarButtonType; Select: { className?: string; items: { @@ -82,24 +87,8 @@ export type ComponentProps = { }; }; LinkToolbar: { - Root: { - className?: string; - children?: ReactNode; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - }; - Button: { - className?: string; - mainTooltip: string; - secondaryTooltip?: string; - icon?: ReactNode; - onClick?: (e: MouseEvent) => void; - isSelected?: boolean; - isDisabled?: boolean; - } & ( - | { children: ReactNode; label?: string } - | { children?: undefined; label: string } - ); + Root: ToolbarRootType; + Button: ToolbarButtonType; }; SideMenu: { Root: { @@ -258,6 +247,10 @@ export type ComponentProps = { children?: ReactNode; }; }; + Toolbar: { + Root: ToolbarRootType; + Button: ToolbarButtonType; + }; }; Comments: { Card: { @@ -268,16 +261,29 @@ export type ComponentProps = { className?: string; children?: ReactNode; }; + // TODO: same as editor? Composer: { className?: string; editor: BlockNoteEditor; onSubmit: () => void; }; - Comment: { + Editor: { className?: string; editable: boolean; editor: BlockNoteEditor; }; + Comment: { + className?: string; + children?: ReactNode; + authorInfo: + | "loading" + | { + username: string; + avatarUrl?: string; + }; + timeString: string; + actions?: ReactNode; + }; }; }; From e824c428f18f55edd833e46e842ef1d34e3cd3b9 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 15 Jan 2025 14:16:38 +0100 Subject: [PATCH 09/30] wip --- packages/core/src/editor/editor.css | 7 +- .../Placeholder/PlaceholderPlugin.ts | 7 +- packages/mantine/src/comments/Editor.tsx | 5 +- packages/mantine/src/toolbar/Toolbar.tsx | 19 ++- .../mantine/src/toolbar/ToolbarButton.tsx | 5 +- .../react/src/components/Comments/Comment.tsx | 124 ++++++------------ .../src/components/Comments/CommentEditor.tsx | 66 ++++++++++ .../src/components/Comments/Composer.tsx | 47 ++++--- .../Comments/FloatingComposerController.tsx | 2 +- .../Comments/FloatingThreadController.tsx | 4 +- .../react/src/components/Comments/Thread.tsx | 54 ++++---- .../react/src/editor/ComponentsContext.tsx | 4 + 12 files changed, 206 insertions(+), 138 deletions(-) create mode 100644 packages/react/src/components/Comments/CommentEditor.tsx diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 5f3d97a6df..248c5313e7 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -10,7 +10,12 @@ --N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */ } -.bn-comment-composer .bn-editor { +.bn-comment-editor { + width: 100%; + padding: 0; +} + +.bn-comment-editor .bn-editor { padding: 0; } diff --git a/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts b/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts index 440457eb29..db1681e89d 100644 --- a/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts +++ b/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts @@ -1,5 +1,6 @@ import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; +import { v4 } from "uuid"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; const PLUGIN_KEY = new PluginKey(`blocknote-placeholder`); @@ -12,7 +13,9 @@ export class PlaceholderPlugin { ) { this.plugin = new Plugin({ key: PLUGIN_KEY, - view: () => { + view: (view) => { + const uniqueEditorSelector = `placeholder-selector-${v4()}`; + view.dom.classList.add(uniqueEditorSelector); const styleEl = document.createElement("style"); const nonce = editor._tiptapEditor.options.injectNonce; if (nonce) { @@ -27,7 +30,7 @@ export class PlaceholderPlugin { const styleSheet = styleEl.sheet!; const getBaseSelector = (additionalSelectors = "") => - `.bn-block-content${additionalSelectors} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`; + `.${uniqueEditorSelector} .bn-block-content${additionalSelectors} .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child):before`; const getSelector = ( blockType: string | "default", diff --git a/packages/mantine/src/comments/Editor.tsx b/packages/mantine/src/comments/Editor.tsx index 9975c608e0..af5a88f4c2 100644 --- a/packages/mantine/src/comments/Editor.tsx +++ b/packages/mantine/src/comments/Editor.tsx @@ -7,12 +7,13 @@ export const Editor = forwardRef< HTMLDivElement, ComponentProps["Comments"]["Editor"] >((props, ref) => { - const { className, editor, editable, ...rest } = props; + const { className, onFocus, onBlur, editor, editable, ...rest } = props; assertEmpty(rest, false); return ( ); }); diff --git a/packages/mantine/src/toolbar/Toolbar.tsx b/packages/mantine/src/toolbar/Toolbar.tsx index 9ad042e850..47dd1cb6e4 100644 --- a/packages/mantine/src/toolbar/Toolbar.tsx +++ b/packages/mantine/src/toolbar/Toolbar.tsx @@ -1,4 +1,4 @@ -import { Group as MantineGroup } from "@mantine/core"; +import { Flex } from "@mantine/core"; import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; @@ -10,7 +10,14 @@ type ToolbarProps = ComponentProps["FormattingToolbar"]["Root"] & export const Toolbar = forwardRef( (props, ref) => { - const { className, children, onMouseEnter, onMouseLeave, ...rest } = props; + const { + className, + children, + onMouseEnter, + onMouseLeave, + variant, + ...rest + } = props; assertEmpty(rest); @@ -22,15 +29,17 @@ export const Toolbar = forwardRef( const combinedRef = mergeRefs(ref, focusRef, trapRef); return ( - + onMouseLeave={onMouseLeave} + justify={variant === "action-toolbar" ? "flex-end" : undefined} + gap={variant === "action-toolbar" ? "xs" : undefined}> {children} - + ); } ); diff --git a/packages/mantine/src/toolbar/ToolbarButton.tsx b/packages/mantine/src/toolbar/ToolbarButton.tsx index bd22f04110..4f2fd9e5fc 100644 --- a/packages/mantine/src/toolbar/ToolbarButton.tsx +++ b/packages/mantine/src/toolbar/ToolbarButton.tsx @@ -40,6 +40,7 @@ export const ToolbarButton = forwardRef( isDisabled, onClick, label, + variant, ...rest } = props; @@ -75,7 +76,7 @@ export const ToolbarButton = forwardRef( mainTooltip.slice(0, 1).toLowerCase() + mainTooltip.replace(/\s+/g, "").slice(1) } - size={"xs"} + size={variant === "compact" ? "compact-xs" : "xs"} disabled={isDisabled || false} ref={ref} {...rest}> @@ -99,7 +100,7 @@ export const ToolbarButton = forwardRef( mainTooltip.slice(0, 1).toLowerCase() + mainTooltip.replace(/\s+/g, "").slice(1) } - size={30} + size={variant === "compact" ? 20 : 30} disabled={isDisabled || false} ref={ref} {...rest}> diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index b99973440a..967b21033e 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -3,7 +3,6 @@ import { CommentData, mergeCSSClasses } from "@blocknote/core"; import type { ComponentPropsWithoutRef, - FormEvent, MouseEvent, ReactNode, SyntheticEvent, @@ -13,6 +12,7 @@ import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { mergeRefs } from "../../util/mergeRefs.js"; +import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; /** @@ -298,18 +298,15 @@ export const Comment = forwardRef( }, []); const handleEditCancel = useCallback( - (event: MouseEvent) => { + (event: MouseEvent) => { event.stopPropagation(); setEditing(false); }, - [] + [setEditing] ); const handleEditSubmit = useCallback( - ( - { body, attachments }: ComposerSubmitComment, - event: FormEvent - ) => { + (_event: MouseEvent) => { // TODO: Add a way to preventDefault from within this callback, to override the default behavior (e.g. showing a confirmation dialog) onCommentEdit?.(comment); @@ -423,15 +420,21 @@ export const Comment = forwardRef( )} */} - + R1 - + R2 - + ... @@ -444,40 +447,6 @@ export const Comment = forwardRef( - {/* {comment.userId === currentUserId && ( - - - - {$.COMMENT_EDIT} - - - - {$.COMMENT_DELETE} - - - }> - - - - - - - )} */} ); } @@ -498,51 +467,38 @@ export const Comment = forwardRef( timeString={timeString} actions={actions}> {isEditing ? ( - - ) : // - // - // - // - // }> - // - // - // - // - // - // } - // overrides={{ - // COMPOSER_PLACEHOLDER: $.COMMENT_EDIT_COMPOSER_PLACEHOLDER, - // }} - // roomId={comment.roomId} - // /> - comment.body ? ( + <> + ( + + + X + + + Save + + + )} + /> + + ) : comment.body ? ( <> + {showReactions && comment.reactions.length > 0 && (
{/* {comment.reactions.map((reaction) => ( diff --git a/packages/react/src/components/Comments/CommentEditor.tsx b/packages/react/src/components/Comments/CommentEditor.tsx new file mode 100644 index 0000000000..2fa3501379 --- /dev/null +++ b/packages/react/src/components/Comments/CommentEditor.tsx @@ -0,0 +1,66 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { FC, useCallback, useState } from "react"; +import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useEditorChange } from "../../hooks/useEditorChange.js"; +import { schema } from "./schema.js"; + +function isDocumentEmpty( + editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + > +) { + return ( + editor.document.length === 0 || + (editor.document.length === 1 && + editor.document[0].type === "paragraph" && + editor.document[0].content.length === 0) + ); +} + +export const CommentEditor = (props: { + editable: boolean; + placeholder?: string; + actions?: FC<{ + isFocused: boolean; + isEmpty: boolean; + }>; + editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema + >; +}) => { + const [isFocused, setIsFocused] = useState(false); + const [isEmpty, setIsEmpty] = useState(isDocumentEmpty(props.editor)); + + const components = useComponentsContext()!; + + useEditorChange(() => { + setIsEmpty(isDocumentEmpty(props.editor)); + }, props.editor); + + const onFocus = useCallback(() => { + setIsFocused(true); + }, []); + + const onBlur = useCallback(() => { + setIsFocused(false); + }, []); + + return ( + <> + + {props.actions && ( + + )} + + ); +}; diff --git a/packages/react/src/components/Comments/Composer.tsx b/packages/react/src/components/Comments/Composer.tsx index 1457adf793..d7d55bc3b7 100644 --- a/packages/react/src/components/Comments/Composer.tsx +++ b/packages/react/src/components/Comments/Composer.tsx @@ -2,12 +2,15 @@ import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; +import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; -export const Composer = () => { - const dict = useDictionary(); + +export function Composer() { const editor = useBlockNoteEditor(); + const Components = useComponentsContext()!; + const dict = useDictionary(); - const commentEditor = useCreateBlockNote({ + const newCommentEditor = useCreateBlockNote({ trailingBlock: false, dictionary: { ...dict, @@ -19,19 +22,29 @@ export const Composer = () => { schema, }); - const components = useComponentsContext()!; - return ( - { - editor.comments!.createThread({ - initialComment: { - body: commentEditor.document, - }, - }); - }} - /> + + ( + + { + editor.comments!.createThread({ + initialComment: { + body: newCommentEditor.document, + }, + }); + }}> + Save + + + )} + /> + ); -}; +} diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 40807ee537..72a6b4c905 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -53,6 +53,7 @@ export const FloatingComposerController = < middleware: [offset(10), flip()], onOpenChange: (open) => { if (!open) { + // TODO editor.filePanel!.closeMenu(); editor.focus(); } @@ -69,7 +70,6 @@ export const FloatingComposerController = < return (
- {/*
hello
*/}
); diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index 17e7fcc5bb..65cfa5a180 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -49,8 +49,8 @@ export const FloatingThreadController = < middleware: [offset(10), flip()], onOpenChange: (open) => { if (!open) { - editor.filePanel!.closeMenu(); - editor.focus(); + // editor.filePanel!.closeMenu(); + // editor.focus(); } }, ...props.floatingOptions, diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index 23a293882d..09d61c2aad 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -7,6 +7,7 @@ import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { Comment, CommentProps } from "./Comment.js"; +import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { @@ -144,8 +145,9 @@ export const Thread = forwardRef( // const markThreadAsResolved = useMarkRoomThreadAsResolved(thread.roomId); // const markThreadAsUnresolved = useMarkRoomThreadAsUnresolved(thread.roomId); - const ctx = useComponentsContext()!; + const Components = useComponentsContext()!; const dict = useDictionary(); + const newCommentEditor = useCreateBlockNote({ trailingBlock: false, dictionary: { @@ -205,16 +207,16 @@ export const Thread = forwardRef( // TODO: extract component return ( - - + {thread.comments.map((comment, index) => { const isFirstComment = index === firstCommentIndex; @@ -268,26 +270,32 @@ export const Thread = forwardRef( /> ); })} - - - {/* {showComposer && ( - + + { + if (!isFocused && isEmpty) { + return null; + } + + return ( + + + Save + + + ); + }} /> - )} */} - - - + + ); } ) as (props: ThreadProps & RefAttributes) => JSX.Element; diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index d127e0c939..8fd6a00e3d 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -18,6 +18,7 @@ type ToolbarRootType = { children?: ReactNode; onMouseEnter?: () => void; onMouseLeave?: () => void; + variant?: "default" | "action-toolbar"; }; type ToolbarButtonType = { @@ -28,6 +29,7 @@ type ToolbarButtonType = { onClick?: (e: MouseEvent) => void; isSelected?: boolean; isDisabled?: boolean; + variant?: "default" | "compact"; } & ( | { children: ReactNode; label?: string } | { children?: undefined; label: string } @@ -271,6 +273,8 @@ export type ComponentProps = { className?: string; editable: boolean; editor: BlockNoteEditor; + onFocus?: () => void; + onBlur?: () => void; }; Comment: { className?: string; From f2d4bb802e8a85b67e11a6b4604dadee79cdbfef Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Jan 2025 09:50:18 +0100 Subject: [PATCH 10/30] misc --- .../src/extensions/Comments/CommentsPlugin.ts | 153 +----- .../Comments/store/LiveBlocksThreadStore.ts | 8 + .../extensions/Comments/store/ThreadStore.ts | 59 +++ .../Comments/store/TipTapThreadStore.ts | 7 + .../Comments/store/YjsThreadStore.ts | 339 ++++++++++++ .../core/src/extensions/Comments/types.ts | 13 +- packages/mantine/src/comments/Comment.tsx | 51 +- packages/mantine/src/comments/Composer.tsx | 39 -- packages/mantine/src/components.tsx | 3 +- .../react/src/components/Comments/Comment.tsx | 498 +++++++----------- .../src/components/Comments/CommentEditor.tsx | 4 +- .../{Composer.tsx => FloatingComposer.tsx} | 7 +- .../Comments/FloatingComposerController.tsx | 10 +- .../Comments/FloatingThreadController.tsx | 6 +- .../react/src/components/Comments/Thread.tsx | 364 ++++++------- .../src/components/Comments/useThreadStore.ts | 28 + .../DefaultButtons/AddCommentButton.tsx | 2 +- .../react/src/editor/ComponentsContext.tsx | 7 +- 18 files changed, 900 insertions(+), 698 deletions(-) create mode 100644 packages/core/src/extensions/Comments/store/LiveBlocksThreadStore.ts create mode 100644 packages/core/src/extensions/Comments/store/ThreadStore.ts create mode 100644 packages/core/src/extensions/Comments/store/TipTapThreadStore.ts create mode 100644 packages/core/src/extensions/Comments/store/YjsThreadStore.ts delete mode 100644 packages/mantine/src/comments/Composer.tsx rename packages/react/src/components/Comments/{Composer.tsx => FloatingComposer.tsx} (88%) create mode 100644 packages/react/src/components/Comments/useThreadStore.ts diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index 0b0bb40c5c..ffec2d8700 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -1,11 +1,12 @@ import { Node } from "prosemirror-model"; import { Plugin, PluginKey } from "prosemirror-state"; import { Decoration, DecorationSet } from "prosemirror-view"; -import { v4 } from "uuid"; import * as Y from "yjs"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { EventEmitter } from "../../util/EventEmitter.js"; -import { CommentBody, CommentData, ThreadData } from "./types.js"; +import { ThreadStore } from "./store/ThreadStore.js"; +import { YjsThreadStore } from "./store/YjsThreadStore.js"; +import { CommentBody } from "./types.js"; const PLUGIN_KEY = new PluginKey(`blocknote-comments`); enum CommentsPluginActions { @@ -176,11 +177,16 @@ export class CommentsPlugin extends EventEmitter { return this.on("update", callback); } - public addPendingComment() { + public startPendingComment() { this.pendingComment = true; this.emitStateUpdate(); } + public stopPendingComment() { + this.pendingComment = false; + this.emitStateUpdate(); + } + public async createThread(options: { initialComment: { body: CommentBody; @@ -194,144 +200,3 @@ export class CommentsPlugin extends EventEmitter { }); } } - -export abstract class ThreadStore { - abstract createThread(options: { - initialComment: { - body: CommentBody; - metadata?: any; - }; - metadata?: any; - }): Promise; - - abstract getThread(threadId: string): ThreadData; -} - -export class YjsThreadStore extends ThreadStore { - constructor( - private readonly editor: BlockNoteEditor, - private readonly userId: string, - private readonly threadsYMap: Y.Map - ) { - super(); - } - - private commentToYMap(comment: CommentData) { - const yMap = new Y.Map(); - yMap.set("id", comment.id); - yMap.set("userId", comment.userId); - yMap.set("createdAt", comment.createdAt.toISOString()); - yMap.set("updatedAt", comment.updatedAt.toISOString()); - if (comment.reactions.length > 0) { - throw new Error("Reactions should be empty in commentToYMap"); - } - yMap.set("reactions", new Y.Array()); - yMap.set("metadata", comment.metadata); - yMap.set("body", comment.body); - return yMap; - } - - private threadToYMap(thread: ThreadData) { - const yMap = new Y.Map(); - yMap.set("id", thread.id); - yMap.set("createdAt", thread.createdAt.toISOString()); - yMap.set("updatedAt", thread.updatedAt.toISOString()); - const commentsArray = new Y.Array>(); - - commentsArray.push( - thread.comments.map((comment) => this.commentToYMap(comment)) - ); - - yMap.set("comments", commentsArray); - yMap.set("resolved", thread.resolved); - yMap.set("resolvedUpdatedAt", thread.resolvedUpdatedAt?.toISOString()); - yMap.set("metadata", thread.metadata); - return yMap; - } - - private yMapToComment(yMap: Y.Map): CommentData { - return { - type: "comment", - id: yMap.get("id"), - userId: yMap.get("userId"), - createdAt: new Date(yMap.get("createdAt")), - updatedAt: new Date(yMap.get("updatedAt")), - reactions: [], - metadata: yMap.get("metadata"), - body: yMap.get("body"), - }; - } - - private yMapToThread(yMap: Y.Map): ThreadData { - return { - type: "thread", - id: yMap.get("id"), - createdAt: new Date(yMap.get("createdAt")), - updatedAt: new Date(yMap.get("updatedAt")), - comments: ((yMap.get("comments") as Y.Array>) || []).map( - (comment) => this.yMapToComment(comment) - ), - resolved: yMap.get("resolved"), - resolvedUpdatedAt: yMap.get("resolvedUpdatedAt"), - metadata: yMap.get("metadata"), - }; - } - - // TODO: async / reactive interface? - public getThread(threadId: string) { - const thread = this.yMapToThread(this.threadsYMap.get(threadId)); - return thread; - } - - public async createThread(options: { - initialComment: { - body: CommentBody; - metadata?: any; - }; - metadata?: any; - }) { - const date = new Date(); - - const comment: CommentData = { - type: "comment", - id: v4(), - userId: this.userId, - createdAt: date, - updatedAt: date, - reactions: [], - metadata: options.metadata, - body: options.initialComment.body, - }; - - const thread: ThreadData = { - type: "thread", - id: v4(), - createdAt: date, - updatedAt: date, - comments: [comment], - resolved: false, - metadata: options.metadata, - }; - - this.threadsYMap.set(thread.id, this.threadToYMap(thread)); - - return thread; - } -} - -export class LiveblocksThreadStore { - constructor(private readonly editor: BlockNoteEditor) {} - - public async createThread() { - const x = useCreateThread(); - return x; - } -} - -export class TiptapThreadStore { - constructor(private readonly editor: BlockNoteEditor) {} - - public async createThread() { - this.editor._tiptapEditor.commands.setMark(this.markType, { threadId: id }); - } -} diff --git a/packages/core/src/extensions/Comments/store/LiveBlocksThreadStore.ts b/packages/core/src/extensions/Comments/store/LiveBlocksThreadStore.ts new file mode 100644 index 0000000000..d7b28a527a --- /dev/null +++ b/packages/core/src/extensions/Comments/store/LiveBlocksThreadStore.ts @@ -0,0 +1,8 @@ +export class LiveblocksThreadStore { + constructor(private readonly editor: BlockNoteEditor) {} + + public async createThread() { + const x = useCreateThread(); + return x; + } +} diff --git a/packages/core/src/extensions/Comments/store/ThreadStore.ts b/packages/core/src/extensions/Comments/store/ThreadStore.ts new file mode 100644 index 0000000000..c3454d624a --- /dev/null +++ b/packages/core/src/extensions/Comments/store/ThreadStore.ts @@ -0,0 +1,59 @@ +import { CommentBody, CommentData, ThreadData } from "../types.js"; + +export abstract class ThreadStore { + abstract createThread(options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }): Promise; + + abstract addComment(options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + }): Promise; + + abstract updateComment(options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + commentId: string; + }): Promise; + + abstract deleteComment(options: { + threadId: string; + commentId: string; + }): Promise; + + abstract deleteThread(options: { threadId: string }): Promise; + + abstract resolveThread(options: { threadId: string }): Promise; + + abstract unresolveThread(options: { threadId: string }): Promise; + + abstract addReaction(options: { + threadId: string; + commentId: string; + // reaction: string; TODO + }): Promise; + + abstract deleteReaction(options: { + threadId: string; + commentId: string; + reactionId: string; + }): Promise; + + abstract getThread(threadId: string): ThreadData; + + abstract getThreads(): Map; + + abstract subscribe( + cb: (threads: Map) => void + ): () => void; +} diff --git a/packages/core/src/extensions/Comments/store/TipTapThreadStore.ts b/packages/core/src/extensions/Comments/store/TipTapThreadStore.ts new file mode 100644 index 0000000000..8d068a37c5 --- /dev/null +++ b/packages/core/src/extensions/Comments/store/TipTapThreadStore.ts @@ -0,0 +1,7 @@ +export class TiptapThreadStore { + constructor(private readonly editor: BlockNoteEditor) {} + + public async createThread() { + this.editor._tiptapEditor.commands.setMark(this.markType, { threadId: id }); + } +} diff --git a/packages/core/src/extensions/Comments/store/YjsThreadStore.ts b/packages/core/src/extensions/Comments/store/YjsThreadStore.ts new file mode 100644 index 0000000000..fb3b262f17 --- /dev/null +++ b/packages/core/src/extensions/Comments/store/YjsThreadStore.ts @@ -0,0 +1,339 @@ +import { v4 } from "uuid"; +import * as Y from "yjs"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { CommentBody, CommentData, ThreadData } from "../types.js"; +import { ThreadStore } from "./ThreadStore.js"; + +// type YjsType = { +// [K in keyof T]: T[K] extends Date ? string : T[K]; // TODO: dates as string? +// }; + +// type YjsTypeConvertArrays = { +// [K in keyof T]: T[K] extends Array +// ? Y.Array> +// : YjsType; +// }; + +export class YjsThreadStore extends ThreadStore { + constructor( + private readonly editor: BlockNoteEditor, + private readonly userId: string, + private readonly threadsYMap: Y.Map + ) { + super(); + } + + private transact = ( + fn: (options: T) => R + ): ((options: T) => Promise) => { + return async (options: T) => { + return this.threadsYMap.doc!.transact(() => { + return fn(options); + }); + }; + }; + + public createThread = this.transact( + (options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }) => { + const date = new Date(); + + const comment: CommentData = { + type: "comment", + id: v4(), + userId: this.userId, + createdAt: date, + updatedAt: date, + reactions: [], + metadata: options.metadata, + body: options.initialComment.body, + }; + + const thread: ThreadData = { + type: "thread", + id: v4(), + createdAt: date, + updatedAt: date, + comments: [comment], + resolved: false, + metadata: options.metadata, + }; + + this.threadsYMap.set(thread.id, threadToYMap(thread)); + + return thread; + } + ); + + public addComment = this.transact( + (options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + }) => { + const yThread = this.threadsYMap.get(options.threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + + const date = new Date(); + const comment: CommentData = { + type: "comment", + id: v4(), + userId: this.userId, + createdAt: date, + updatedAt: date, + deletedAt: undefined, + reactions: [], + metadata: options.comment.metadata, + body: options.comment.body, + }; + + (yThread.get("comments") as Y.Array>).push([ + commentToYMap(comment), + ]); + + yThread.set("updatedAt", new Date().getTime()); + return comment; + } + ); + + public updateComment = this.transact( + (options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + commentId: string; + }) => { + const yThread = this.threadsYMap.get(options.threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + + const yCommentIndex = yArrayFindIndex( + yThread.get("comments"), + (comment) => comment.get("id") === options.commentId + ); + + if (yCommentIndex === -1) { + throw new Error("Comment not found"); + } + + const yComment = yThread.get("comments").get(yCommentIndex); + yComment.set("body", options.comment.body); + yComment.set("updatedAt", new Date().getTime()); + yComment.set("metadata", options.comment.metadata); + } + ); + + public deleteComment = this.transact( + (options: { + threadId: string; + commentId: string; + softDelete?: boolean; + }) => { + const yThread = this.threadsYMap.get(options.threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + + const yCommentIndex = yArrayFindIndex( + yThread.get("comments"), + (comment) => comment.get("id") === options.commentId + ); + + if (yCommentIndex === -1) { + throw new Error("Comment not found"); + } + + const yComment = yThread.get("comments").get(yCommentIndex); + + if (yComment.get("deletedAt")) { + throw new Error("Comment already deleted"); + } + + if (options.softDelete) { + yComment.set("deletedAt", new Date().getTime()); + yComment.set("body", undefined); + } else { + yThread.get("comments").delete(yCommentIndex); + } + + if ( + (yThread.get("comments") as Y.Array) + .toArray() + .every((comment) => comment.get("deletedAt")) + ) { + // all comments deleted + if (options.softDelete) { + yThread.set("deletedAt", new Date().getTime()); + } else { + this.threadsYMap.delete(options.threadId); + } + } + + yThread.set("updatedAt", new Date().getTime()); + } + ); + + public deleteThread = this.transact((options: { threadId: string }) => { + this.threadsYMap.delete(options.threadId); + }); + + public resolveThread = this.transact((options: { threadId: string }) => { + const yThread = this.threadsYMap.get(options.threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + + yThread.set("resolved", true); + yThread.set("resolvedUpdatedAt", new Date().getTime()); + }); + + public unresolveThread = this.transact((options: { threadId: string }) => { + const yThread = this.threadsYMap.get(options.threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + + yThread.set("resolved", false); + yThread.set("resolvedUpdatedAt", new Date().getTime()); + }); + + public addReaction = this.transact( + (options: { + threadId: string; + commentId: string; + // reaction: string; TODO + }) => { + throw new Error("Not implemented"); + } + ); + + public deleteReaction = this.transact( + (options: { threadId: string; commentId: string; reactionId: string }) => { + throw new Error("Not implemented"); + } + ); + + // TODO: async / reactive interface? + public getThread(threadId: string) { + const yThread = this.threadsYMap.get(threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + const thread = yMapToThread(yThread); + return thread; + } + + public getThreads(): Map { + const threadMap = new Map(); + this.threadsYMap.forEach((yThread, id) => { + threadMap.set(id, yMapToThread(yThread)); + }); + return threadMap; + } + + public subscribe(cb: (threads: Map) => void) { + const observer = () => { + cb(this.getThreads()); + }; + + this.threadsYMap.observeDeep(observer); + + return () => { + this.threadsYMap.unobserveDeep(observer); + }; + } +} + +// HELPERS + +function commentToYMap(comment: CommentData) { + const yMap = new Y.Map(); + yMap.set("id", comment.id); + yMap.set("userId", comment.userId); + yMap.set("createdAt", comment.createdAt.getTime()); + yMap.set("updatedAt", comment.updatedAt.getTime()); + if (comment.deletedAt) { + yMap.set("deletedAt", comment.deletedAt.getTime()); + yMap.set("body", undefined); + } else { + yMap.set("body", comment.body); + } + if (comment.reactions.length > 0) { + throw new Error("Reactions should be empty in commentToYMap"); + } + yMap.set("reactions", new Y.Array()); + yMap.set("metadata", comment.metadata); + + return yMap; +} + +function threadToYMap(thread: ThreadData) { + const yMap = new Y.Map(); + yMap.set("id", thread.id); + yMap.set("createdAt", thread.createdAt.getTime()); + yMap.set("updatedAt", thread.updatedAt.getTime()); + const commentsArray = new Y.Array>(); + + commentsArray.push(thread.comments.map((comment) => commentToYMap(comment))); + + yMap.set("comments", commentsArray); + yMap.set("resolved", thread.resolved); + yMap.set("resolvedUpdatedAt", thread.resolvedUpdatedAt?.getTime()); + yMap.set("metadata", thread.metadata); + return yMap; +} + +function yMapToComment(yMap: Y.Map): CommentData { + return { + type: "comment", + id: yMap.get("id"), + userId: yMap.get("userId"), + createdAt: new Date(yMap.get("createdAt")), + updatedAt: new Date(yMap.get("updatedAt")), + deletedAt: yMap.get("deletedAt") + ? new Date(yMap.get("deletedAt")) + : undefined, + reactions: [], + metadata: yMap.get("metadata"), + body: yMap.get("body"), + }; +} + +function yMapToThread(yMap: Y.Map): ThreadData { + return { + type: "thread", + id: yMap.get("id"), + createdAt: new Date(yMap.get("createdAt")), + updatedAt: new Date(yMap.get("updatedAt")), + comments: ((yMap.get("comments") as Y.Array>) || []).map( + (comment) => yMapToComment(comment) + ), + resolved: yMap.get("resolved"), + resolvedUpdatedAt: yMap.get("resolvedUpdatedAt"), + metadata: yMap.get("metadata"), + }; +} + +function yArrayFindIndex( + yArray: Y.Array, + predicate: (item: any) => boolean +) { + for (let i = 0; i < yArray.length; i++) { + if (predicate(yArray.get(i))) { + return i; + } + } + return -1; +} diff --git a/packages/core/src/extensions/Comments/types.ts b/packages/core/src/extensions/Comments/types.ts index db7fc2bede..a928f9aec2 100644 --- a/packages/core/src/extensions/Comments/types.ts +++ b/packages/core/src/extensions/Comments/types.ts @@ -17,8 +17,16 @@ export type CommentData = { reactions: CommentReactionData[]; // attachments: CommentAttachment[]; metadata: any; - body: CommentBody; -}; +} & ( + | { + deletedAt: Date; + body: undefined; + } + | { + deletedAt?: never; + body: CommentBody; + } +); export type ThreadData = { type: "thread"; @@ -29,4 +37,5 @@ export type ThreadData = { resolved: boolean; resolvedUpdatedAt?: Date; metadata: any; + deletedAt?: Date; }; diff --git a/packages/mantine/src/comments/Comment.tsx b/packages/mantine/src/comments/Comment.tsx index 0f382408b0..9167c09360 100644 --- a/packages/mantine/src/comments/Comment.tsx +++ b/packages/mantine/src/comments/Comment.tsx @@ -1,14 +1,14 @@ import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; +import { ComponentProps, mergeRefs } from "@blocknote/react"; import { Avatar, Group, Skeleton, Text } from "@mantine/core"; +import { useHover } from "@mantine/hooks"; import { forwardRef } from "react"; const AuthorInfo = forwardRef< HTMLDivElement, - ComponentProps["Comments"]["Comment"] + Pick >((props, ref) => { - const { className, authorInfo, timeString, actions, children, ...rest } = - props; + const { authorInfo, timeString, ...rest } = props; assertEmpty(rest, false); @@ -48,24 +48,41 @@ export const Comment = forwardRef< HTMLDivElement, ComponentProps["Comments"]["Comment"] >((props, ref) => { - const { className, authorInfo, timeString, actions, children, ...rest } = - props; + const { + className, + showActions, + authorInfo, + timeString, + actions, + children, + ...rest + } = props; + const { hovered, ref: hoverRef } = useHover(); + const mergedRef = mergeRefs([ref, hoverRef]); assertEmpty(rest, false); + const doShowActions = + actions && + (showActions === true || + showActions === undefined || + (showActions === "hover" && hovered)); + return ( - <> -
- {actions} -
+ + {doShowActions ? ( + + {actions} + + ) : null} {children} - + ); }); diff --git a/packages/mantine/src/comments/Composer.tsx b/packages/mantine/src/comments/Composer.tsx deleted file mode 100644 index 648016ca33..0000000000 --- a/packages/mantine/src/comments/Composer.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { assertEmpty } from "@blocknote/core"; -import { ComponentProps } from "@blocknote/react"; -import { Button, Flex, Paper } from "@mantine/core"; -import { forwardRef } from "react"; -import { BlockNoteView } from "../BlockNoteView.js"; - -export const Composer = forwardRef< - HTMLDivElement, - ComponentProps["Comments"]["Composer"] ->((props, ref) => { - const { className, editor, onSubmit, ...rest } = props; - - assertEmpty(rest, false); - - return ( - - - {/* TODO: extract / change to icon? */} - - - - - ); -}); diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx index 7e736d5a3f..b892d2b33b 100644 --- a/packages/mantine/src/components.tsx +++ b/packages/mantine/src/components.tsx @@ -2,7 +2,7 @@ import { Components } from "@blocknote/react"; import { Card, CardSection } from "./comments/Card.js"; import { Comment } from "./comments/Comment.js"; -import { Composer } from "./comments/Composer.js"; + import { Editor } from "./comments/Editor.js"; import { TextInput } from "./form/TextInput.js"; import { @@ -103,7 +103,6 @@ export const components: Components = { Comments: { Comment, Editor, - Composer, Card, CardSection, }, diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 967b21033e..07398ce82a 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -1,17 +1,12 @@ "use client"; import { CommentData, mergeCSSClasses } from "@blocknote/core"; -import type { - ComponentPropsWithoutRef, - MouseEvent, - ReactNode, - SyntheticEvent, -} from "react"; -import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; -import { mergeRefs } from "../../util/mergeRefs.js"; import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; @@ -19,6 +14,7 @@ import { schema } from "./schema.js"; * Liveblocks, but changed: * - removed attachments * - removed read status + * ... */ const REACTIONS_TRUNCATE = 5; @@ -28,6 +24,11 @@ export interface CommentProps extends ComponentPropsWithoutRef<"div"> { */ comment: CommentData; + /** + * The thread id. + */ + threadId: string; + /** * How to show or hide the actions. */ @@ -227,41 +228,29 @@ export interface CommentProps extends ComponentPropsWithoutRef<"div"> { // ); // }); -/** - * Displays a single comment. - * - * @example - * <> - * {thread.comments.map((comment) => ( - * - * ))} - * - */ -export const Comment = forwardRef( - ( +export const Comment = ({ + comment, + threadId, + indentContent = true, + showDeleted, + showActions = "hover", + showReactions = true, + // showComposerFormattingControls = true, + onAuthorClick, + onMentionClick, + onCommentEdit, + onCommentDelete, + // overrides, + className, + additionalActions, + additionalActionsClassName, + autoMarkReadThreadId, + ...props +}: CommentProps) => { + const dict = useDictionary(); + + const commentEditor = useCreateBlockNote( { - comment, - indentContent = true, - showDeleted, - showActions = "hover", - showReactions = true, - // showComposerFormattingControls = true, - onAuthorClick, - onMentionClick, - onCommentEdit, - onCommentDelete, - // overrides, - className, - additionalActions, - additionalActionsClassName, - autoMarkReadThreadId, - ...props - }, - forwardedRef - ) => { - const dict = useDictionary(); - - const commentEditor = useCreateBlockNote({ initialContent: comment.body, trailingBlock: false, dictionary: { @@ -272,265 +261,186 @@ export const Comment = forwardRef( }, }, schema, + }, + [comment.body] + ); + + const Components = useComponentsContext()!; + + // const currentUserId = useCurrentUserId(); + // const deleteComment = useDeleteRoomComment(comment.roomId); + // const editComment = useEditRoomComment(com ment.roomId); + // const addReaction = useAddRoomCommentReaction(comment.roomId); + // const removeReaction = useRemoveRoomCommentReaction(comment.roomId); + // const $ = useOverrides(overrides); + const [isEditing, setEditing] = useState(false); + const [isTarget, setTarget] = useState(false); + const [isMoreActionOpen, setMoreActionOpen] = useState(false); + const [isReactionActionOpen, setReactionActionOpen] = useState(false); + + const editor = useBlockNoteEditor(); + + const handleEdit = useCallback(() => { + setEditing(true); + }, []); + + const onEditCancel = useCallback(() => { + commentEditor.replaceBlocks(commentEditor.document, comment.body); + setEditing(false); + }, [commentEditor, comment.body]); + + const onEditSubmit = useCallback( + async (_event: MouseEvent) => { + await editor.comments!.store.updateComment({ + commentId: comment.id, + comment: { + body: commentEditor.document, + }, + threadId: threadId, + }); + + setEditing(false); + }, + [comment, threadId, commentEditor, editor.comments] + ); + + const onDelete = useCallback(() => { + editor.comments!.store.deleteComment({ + commentId: comment.id, + threadId: threadId, }); + }, [comment, threadId, editor.comments]); - const Components = useComponentsContext()!; - - const ref = useRef(null); - const mergedRefs = mergeRefs([forwardedRef, ref]); - // const currentUserId = useCurrentUserId(); - // const deleteComment = useDeleteRoomComment(comment.roomId); - // const editComment = useEditRoomComment(comment.roomId); - // const addReaction = useAddRoomCommentReaction(comment.roomId); - // const removeReaction = useRemoveRoomCommentReaction(comment.roomId); - // const $ = useOverrides(overrides); - const [isEditing, setEditing] = useState(false); - const [isTarget, setTarget] = useState(false); - const [isMoreActionOpen, setMoreActionOpen] = useState(false); - const [isReactionActionOpen, setReactionActionOpen] = useState(false); - - const stopPropagation = useCallback((event: SyntheticEvent) => { - event.stopPropagation(); - }, []); - - const handleEdit = useCallback(() => { - setEditing(true); - }, []); - - const handleEditCancel = useCallback( - (event: MouseEvent) => { - event.stopPropagation(); - setEditing(false); - }, - [setEditing] - ); + const onReactionSelect = useCallback(() => { + console.log("reaction select"); + }, []); - const handleEditSubmit = useCallback( - (_event: MouseEvent) => { - // TODO: Add a way to preventDefault from within this callback, to override the default behavior (e.g. showing a confirmation dialog) - onCommentEdit?.(comment); - - // event.preventDefault(); - // setEditing(false); - // editComment({ - // commentId: comment.id, - // threadId: comment.threadId, - // body, - // attachments, - // }); - }, - [comment, onCommentEdit] - ); + const onResolve = useCallback(() => { + console.log("resolve"); + }, []); - const handleDelete = useCallback(() => { - // TODO: Add a way to preventDefault from within this callback, to override the default behavior (e.g. showing a confirmation dialog) - onCommentDelete?.(comment); - - // deleteComment({ - // commentId: comment.id, - // threadId: comment.threadId, - // }); - }, [comment, onCommentDelete]); - - // const handleAuthorClick = useCallback( - // (event: MouseEvent) => { - // onAuthorClick?.(comment.userId, event); - // }, - // [comment.userId, onAuthorClick] - // ); - - // const handleReactionSelect = useCallback( - // (emoji: string) => { - // const reactionIndex = comment.reactions.findIndex( - // (reaction) => reaction.emoji === emoji - // ); - - // if ( - // reactionIndex >= 0 && - // currentUserId && - // comment.reactions[reactionIndex]?.users.some( - // (user) => user.id === currentUserId - // ) - // ) { - // removeReaction({ - // threadId: comment.threadId, - // commentId: comment.id, - // emoji, - // }); - // } else { - // addReaction({ - // threadId: comment.threadId, - // commentId: comment.id, - // emoji, - // }); - // } - // }, - // [ - // addReaction, - // comment.id, - // comment.reactions, - // comment.threadId, - // removeReaction, - // currentUserId, - // ] - // ); - - useEffect(() => { - const isWindowDefined = typeof window !== "undefined"; - if (!isWindowDefined) { - return; - } - - const hash = window.location.hash; - const commentId = hash.slice(1); - - if (commentId === comment.id) { - setTarget(true); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - if (!showDeleted && !comment.body) { - return null; + useEffect(() => { + const isWindowDefined = typeof window !== "undefined"; + if (!isWindowDefined) { + return; } - let actions: ReactNode | undefined = undefined; - - if (showActions && !isEditing) { - actions = ( - - {additionalActions ?? null} - {/* {showReactions && ( - - - - - - - - )} */} - - R1 - - - R2 - - - - - ... - - - - - Edit comment - - - Delete comment - - - - - ); + const hash = window.location.hash; + const commentId = hash.slice(1); + + if (commentId === comment.id) { + setTarget(true); } + }, []); // eslint-disable-line react-hooks/exhaustive-deps - const timeString = - comment.createdAt.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }) + (comment.updatedAt !== comment.createdAt ? " (edited)" : ""); // TODO: needs editedAt? - - return ( - - {isEditing ? ( - <> - ( - - - X - - - Save - - - )} - /> - - ) : comment.body ? ( - <> - - - {showReactions && comment.reactions.length > 0 && ( -
- {/* {comment.reactions.map((reaction) => ( - - ))} */} - {/* - - - - - - */} -
- )} - - ) : ( -
- {/*

{$.COMMENT_DELETED}

*/} -
- )} -
+ if (!showDeleted && !comment.body) { + return null; + } + + let actions: ReactNode | undefined = undefined; + + if (showActions && !isEditing) { + actions = ( + + {additionalActions ?? null} + + R1 + + + R2 + + + + + ... + + + + + Edit comment + + + Delete comment + + + + ); } -); + + const timeString = + comment.createdAt.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) + + (comment.updatedAt.getTime() !== comment.createdAt.getTime() + ? " (edited)" + : ""); // TODO: needs editedAt? + + return ( + + {isEditing ? ( + <> + ( + + + X + + + Save + + + )} + /> + + ) : comment.body ? ( + <> + + + {showReactions && comment.reactions.length > 0 && ( +
+ )} + + ) : ( + // Soft deletes + // TODO, test +
+

Deleted

+
+ )} +
+ ); +}; diff --git a/packages/react/src/components/Comments/CommentEditor.tsx b/packages/react/src/components/Comments/CommentEditor.tsx index 2fa3501379..43841c4a16 100644 --- a/packages/react/src/components/Comments/CommentEditor.tsx +++ b/packages/react/src/components/Comments/CommentEditor.tsx @@ -59,7 +59,9 @@ export const CommentEditor = (props: { editable={props.editable} /> {props.actions && ( - +
+ +
)} ); diff --git a/packages/react/src/components/Comments/Composer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx similarity index 88% rename from packages/react/src/components/Comments/Composer.tsx rename to packages/react/src/components/Comments/FloatingComposer.tsx index d7d55bc3b7..214b4d740c 100644 --- a/packages/react/src/components/Comments/Composer.tsx +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -5,7 +5,7 @@ import { useDictionary } from "../../i18n/dictionary.js"; import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; -export function Composer() { +export function FloatingComposer() { const editor = useBlockNoteEditor(); const Components = useComponentsContext()!; const dict = useDictionary(); @@ -33,12 +33,13 @@ export function Composer() { mainTooltip="Save" variant="compact" isDisabled={isEmpty} - onClick={() => { - editor.comments!.createThread({ + onClick={async () => { + await editor.comments!.createThread({ initialComment: { body: newCommentEditor.document, }, }); + editor.comments!.stopPendingComment(); }}> Save diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx index 72a6b4c905..75722dfe9e 100644 --- a/packages/react/src/components/Comments/FloatingComposerController.tsx +++ b/packages/react/src/components/Comments/FloatingComposerController.tsx @@ -7,19 +7,19 @@ import { StyleSchema, } from "@blocknote/core"; import { UseFloatingOptions, flip, offset } from "@floating-ui/react"; -import { FC, useMemo } from "react"; +import { ComponentProps, FC, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useUIElementPositioning } from "../../hooks/useUIElementPositioning.js"; import { useUIPluginState } from "../../hooks/useUIPluginState.js"; -import { Composer } from "./Composer.js"; +import { FloatingComposer } from "./FloatingComposer.js"; export const FloatingComposerController = < B extends BlockSchema = DefaultBlockSchema, I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema >(props: { - filePanel?: FC; + floatingComposer?: FC>; floatingOptions?: Partial; }) => { const editor = useBlockNoteEditor(); @@ -54,7 +54,7 @@ export const FloatingComposerController = < onOpenChange: (open) => { if (!open) { // TODO - editor.filePanel!.closeMenu(); + editor.comments!.stopPendingComment(); editor.focus(); } }, @@ -66,7 +66,7 @@ export const FloatingComposerController = < return null; } - const Component = props.filePanel || Composer; + const Component = props.floatingComposer || FloatingComposer; return (
diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx index 65cfa5a180..93bdf1ee3e 100644 --- a/packages/react/src/components/Comments/FloatingThreadController.tsx +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -27,7 +27,7 @@ export const FloatingThreadController = < I extends InlineContentSchema = DefaultInlineContentSchema, S extends StyleSchema = DefaultStyleSchema >(props: { - filePanel?: FC; + filePanel?: FC; // TODO floatingOptions?: Partial; }) => { const editor = useBlockNoteEditor(); @@ -93,12 +93,10 @@ export const FloatingThreadController = < const Component = props.filePanel || Thread; - const thread = editor.comments.store.getThread(state.selectedThreadId); - return (
{/*
hello
*/} - +
); }; diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index 09d61c2aad..69ea985a57 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -1,20 +1,22 @@ "use client"; import { ThreadData, mergeCSSClasses } from "@blocknote/core"; -import type { ComponentPropsWithoutRef, ForwardedRef } from "react"; -import { forwardRef, useCallback, useMemo } from "react"; +import type { ComponentPropsWithoutRef } from "react"; +import { useCallback, useMemo } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { Comment, CommentProps } from "./Comment.js"; import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; +import { useThreadStore } from "./useThreadStore.js"; export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { /** * The thread to display. */ - thread: ThreadData; + threadId: string; /** * How to show or hide the composer to reply to the thread. @@ -116,186 +118,188 @@ export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { * ))} * */ -export const Thread = forwardRef( - ( - { - thread, - indentCommentContent = true, - showActions = "hover", - showDeletedComments, - showResolveAction = true, - showReactions = true, - showComposer = "collapsed", - showAttachments = true, - // showComposerFormattingControls = true, - onResolvedChange, - onCommentEdit, - onCommentDelete, - onThreadDelete, - onAuthorClick, - onMentionClick, - // onAttachmentClick, - // onComposerSubmit, - // overrides, - className, - ...props - }: ThreadProps, - forwardedRef: ForwardedRef - ) => { - // const markThreadAsResolved = useMarkRoomThreadAsResolved(thread.roomId); - // const markThreadAsUnresolved = useMarkRoomThreadAsUnresolved(thread.roomId); - - const Components = useComponentsContext()!; - const dict = useDictionary(); - - const newCommentEditor = useCreateBlockNote({ - trailingBlock: false, - dictionary: { - ...dict, - placeholders: { - ...dict.placeholders, - default: "Add comment...", // TODO: only for empty doc - }, +export const Thread = ({ + threadId, + indentCommentContent = true, + showActions = "hover", + showDeletedComments, + showResolveAction = true, + showReactions = true, + showComposer = "collapsed", + showAttachments = true, + // showComposerFormattingControls = true, + onResolvedChange, + onCommentEdit, + onCommentDelete, + onThreadDelete, + onAuthorClick, + onMentionClick, + // onAttachmentClick, + // onComposerSubmit, + // overrides, + className, + ...props +}: ThreadProps) => { + // const markThreadAsResolved = useMarkRoomThreadAsResolved(thread.roomId); + // const markThreadAsUnresolved = useMarkRoomThreadAsUnresolved(thread.roomId); + const editor = useBlockNoteEditor(); + const Components = useComponentsContext()!; + const dict = useDictionary(); + + const threadMap = useThreadStore(editor); + const thread = threadMap.get(threadId); + + if (!thread) { + throw new Error("Thread not found"); + } + + const newCommentEditor = useCreateBlockNote({ + trailingBlock: false, + dictionary: { + ...dict, + placeholders: { + ...dict.placeholders, + default: "Add comment...", // TODO: only for empty doc }, - schema, + }, + schema, + }); + + const firstCommentIndex = useMemo(() => { + return showDeletedComments + ? 0 + : thread.comments.findIndex((comment) => comment.body); + }, [showDeletedComments, thread.comments]); + + // const handleResolvedChange = useCallback( + // (resolved: boolean) => { + // onResolvedChange?.(resolved); + + // if (resolved) { + // markThreadAsResolved(thread.id); + // } else { + // markThreadAsUnresolved(thread.id); + // } + // }, + // [ + // markThreadAsResolved, + // markThreadAsUnresolved, + // onResolvedChange, + // thread.id, + // ] + // ); + + // TODO: thread deletion + + // const handleCommentDelete = useCallback( + // (comment: Comment) => { + // onCommentDelete?.(comment); + + // const filteredComments = thread.comments.filter( + // (comment) => comment.body + // ); + + // if (filteredComments.length <= 1) { + // onThreadDelete?.(thread); + // } + // }, + // [onCommentDelete, onThreadDelete, thread] + // ); + + const onNewCommentSave = useCallback(async () => { + await editor.comments!.store.addComment({ + comment: { + body: newCommentEditor.document, + }, + threadId: thread.id, }); - const firstCommentIndex = useMemo(() => { - return showDeletedComments - ? 0 - : thread.comments.findIndex((comment) => comment.body); - }, [showDeletedComments, thread.comments]); - - const stopPropagation = useCallback((event: SyntheticEvent) => { - event.stopPropagation(); - }, []); - - // const handleResolvedChange = useCallback( - // (resolved: boolean) => { - // onResolvedChange?.(resolved); - - // if (resolved) { - // markThreadAsResolved(thread.id); - // } else { - // markThreadAsUnresolved(thread.id); - // } - // }, - // [ - // markThreadAsResolved, - // markThreadAsUnresolved, - // onResolvedChange, - // thread.id, - // ] - // ); - - // TODO: thread deletion - - // const handleCommentDelete = useCallback( - // (comment: Comment) => { - // onCommentDelete?.(comment); - - // const filteredComments = thread.comments.filter( - // (comment) => comment.body - // ); - - // if (filteredComments.length <= 1) { - // onThreadDelete?.(thread); - // } - // }, - // [onCommentDelete, onThreadDelete, thread] - // ); - - // TODO: extract component - return ( - - - {thread.comments.map((comment, index) => { - const isFirstComment = index === firstCommentIndex; + // reset editor + newCommentEditor.removeBlocks(newCommentEditor.document); + }, [editor.comments, newCommentEditor, thread.id]); + + // TODO: extract component + return ( + + + {thread.comments.map((comment, index) => { + const isFirstComment = index === firstCommentIndex; + + return ( + + // + // + // + // + // ) : null + // } + /> + ); + })} + + + { + if (!isFocused && isEmpty) { + return null; + } return ( - - // - // - // - // - // ) : null - // } - /> + + + Save + + ); - })} - - - { - if (!isFocused && isEmpty) { - return null; - } - - return ( - - - Save - - - ); - }} - /> - - - ); - } -) as (props: ThreadProps & RefAttributes) => JSX.Element; + }} + /> + + + ); +}; diff --git a/packages/react/src/components/Comments/useThreadStore.ts b/packages/react/src/components/Comments/useThreadStore.ts new file mode 100644 index 0000000000..1c37a25345 --- /dev/null +++ b/packages/react/src/components/Comments/useThreadStore.ts @@ -0,0 +1,28 @@ +import { BlockNoteEditor, ThreadData } from "@blocknote/core"; +import { useCallback, useRef, useSyncExternalStore } from "react"; + +export function useThreadStore(editor: BlockNoteEditor) { + const store = editor.comments!.store; + + // this ref works around this error: + // https://react.dev/reference/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached + // however, might not be a good practice to work around it this way + const threadsRef = useRef>(); + + if (!threadsRef.current) { + threadsRef.current = store.getThreads(); + } + + const subscribe = useCallback( + (cb: () => void) => { + return store.subscribe((threads) => { + // update ref when changed + threadsRef.current = threads; + cb(); + }); + }, + [store] + ); + + return useSyncExternalStore(subscribe, () => threadsRef.current!); +} diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx index c37cfcf0d4..bd00a988f7 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx @@ -17,7 +17,7 @@ export const AddCommentButton = () => { >(); const onClick = useCallback(() => { - editor.comments?.addPendingComment(); + editor.comments?.startPendingComment(); editor.formattingToolbar.closeMenu(); }, [editor]); diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 8fd6a00e3d..ffe5b56152 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -263,12 +263,6 @@ export type ComponentProps = { className?: string; children?: ReactNode; }; - // TODO: same as editor? - Composer: { - className?: string; - editor: BlockNoteEditor; - onSubmit: () => void; - }; Editor: { className?: string; editable: boolean; @@ -287,6 +281,7 @@ export type ComponentProps = { }; timeString: string; actions?: ReactNode; + showActions?: boolean | "hover"; }; }; }; From 434eafa228042a07334f086465c1759596aa503a Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Jan 2025 14:50:38 +0100 Subject: [PATCH 11/30] add threadstore tests --- .../Comments/store/YjsThreadStore.test.ts | 282 ++++++++++++++++++ .../Comments/store/YjsThreadStore.ts | 2 +- 2 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/extensions/Comments/store/YjsThreadStore.test.ts diff --git a/packages/core/src/extensions/Comments/store/YjsThreadStore.test.ts b/packages/core/src/extensions/Comments/store/YjsThreadStore.test.ts new file mode 100644 index 0000000000..d3f939e57d --- /dev/null +++ b/packages/core/src/extensions/Comments/store/YjsThreadStore.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { CommentBody } from "../types.js"; +import { YjsThreadStore } from "./YjsThreadStore.js"; + +// Mock UUID to generate sequential IDs +let mockUuidCounter = 0; +vi.mock("uuid", () => ({ + v4: () => `mocked-uuid-${++mockUuidCounter}`, +})); + +describe("YjsThreadStore", () => { + let store: YjsThreadStore; + let doc: Y.Doc; + let threadsYMap: Y.Map; + let editor: BlockNoteEditor; + + beforeEach(() => { + // Reset mocks and create fresh instances + vi.clearAllMocks(); + mockUuidCounter = 0; + doc = new Y.Doc(); + threadsYMap = doc.getMap("threads"); + editor = {} as BlockNoteEditor; + store = new YjsThreadStore(editor, "test-user", threadsYMap); + }); + + describe("createThread", () => { + it("creates a thread with initial comment", async () => { + const initialComment = { + body: "Test comment" as CommentBody, + metadata: { extra: "metadatacomment" }, + }; + + const thread = await store.createThread({ + initialComment, + metadata: { extra: "metadatathread" }, + }); + + expect(thread).toMatchObject({ + type: "thread", + id: "mocked-uuid-2", + resolved: false, + metadata: { extra: "metadatathread" }, + comments: [ + { + type: "comment", + id: "mocked-uuid-1", + userId: "test-user", + body: "Test comment", + metadata: { extra: "metadatacomment" }, + reactions: [], + }, + ], + }); + }); + }); + + describe("addComment", () => { + it("adds a comment to existing thread", async () => { + // First create a thread + const thread = await store.createThread({ + initialComment: { + body: "Initial comment" as CommentBody, + }, + }); + + // Add new comment + const comment = await store.addComment({ + threadId: thread.id, + comment: { + body: "New comment" as CommentBody, + metadata: { test: "metadata" }, + }, + }); + + expect(comment).toMatchObject({ + type: "comment", + id: "mocked-uuid-3", + userId: "test-user", + body: "New comment", + metadata: { test: "metadata" }, + reactions: [], + }); + + // Verify thread has both comments + const updatedThread = store.getThread(thread.id); + expect(updatedThread.comments).toHaveLength(2); + }); + + it("throws error for non-existent thread", async () => { + await expect( + store.addComment({ + threadId: "non-existent", + comment: { + body: "Test comment" as CommentBody, + }, + }) + ).rejects.toThrow("Thread not found"); + }); + }); + + describe("updateComment", () => { + it("updates existing comment", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Initial comment" as CommentBody, + }, + }); + + await store.updateComment({ + threadId: thread.id, + commentId: thread.comments[0].id, + comment: { + body: "Updated comment" as CommentBody, + metadata: { updatedMetadata: true }, + }, + }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.comments[0]).toMatchObject({ + body: "Updated comment", + metadata: { updatedMetadata: true }, + }); + }); + }); + + describe("deleteComment", () => { + it("soft deletes a comment", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.deleteComment({ + threadId: thread.id, + commentId: thread.comments[0].id, + softDelete: true, + }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.comments[0].deletedAt).toBeDefined(); + expect(updatedThread.comments[0].body).toBeUndefined(); + }); + + it("hard deletes a comment (deletes thread)", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.deleteComment({ + threadId: thread.id, + commentId: thread.comments[0].id, + softDelete: false, + }); + + // Thread should be deleted since it was the only comment + expect(() => store.getThread(thread.id)).toThrow("Thread not found"); + }); + }); + + describe("resolveThread", () => { + it("resolves a thread", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.resolveThread({ threadId: thread.id }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.resolved).toBe(true); + expect(updatedThread.resolvedUpdatedAt).toBeDefined(); + }); + }); + + describe("unresolveThread", () => { + it("unresolves a thread", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.resolveThread({ threadId: thread.id }); + await store.unresolveThread({ threadId: thread.id }); + + const updatedThread = store.getThread(thread.id); + expect(updatedThread.resolved).toBe(false); + expect(updatedThread.resolvedUpdatedAt).toBeDefined(); + }); + }); + + describe("getThreads", () => { + it("returns all threads", async () => { + await store.createThread({ + initialComment: { + body: "Thread 1" as CommentBody, + }, + }); + + await store.createThread({ + initialComment: { + body: "Thread 2" as CommentBody, + }, + }); + + const threads = store.getThreads(); + expect(threads.size).toBe(2); + }); + }); + + describe("deleteThread", () => { + it("deletes an entire thread", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await store.deleteThread({ threadId: thread.id }); + + // Verify thread is deleted + expect(() => store.getThread(thread.id)).toThrow("Thread not found"); + }); + }); + + describe("reactions", () => { + it("throws not implemented error when adding reaction", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await expect( + store.addReaction({ + threadId: thread.id, + commentId: thread.comments[0].id, + }) + ).rejects.toThrow("Not implemented"); + }); + + it("throws not implemented error when deleting reaction", async () => { + const thread = await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + await expect( + store.deleteReaction({ + threadId: thread.id, + commentId: thread.comments[0].id, + reactionId: "some-reaction", + }) + ).rejects.toThrow("Not implemented"); + }); + }); + + describe("subscribe", () => { + it("calls callback when threads change", async () => { + const callback = vi.fn(); + const unsubscribe = store.subscribe(callback); + + await store.createThread({ + initialComment: { + body: "Test comment" as CommentBody, + }, + }); + + expect(callback).toHaveBeenCalled(); + + unsubscribe(); + }); + }); +}); diff --git a/packages/core/src/extensions/Comments/store/YjsThreadStore.ts b/packages/core/src/extensions/Comments/store/YjsThreadStore.ts index fb3b262f17..fb9c7f9048 100644 --- a/packages/core/src/extensions/Comments/store/YjsThreadStore.ts +++ b/packages/core/src/extensions/Comments/store/YjsThreadStore.ts @@ -50,7 +50,7 @@ export class YjsThreadStore extends ThreadStore { createdAt: date, updatedAt: date, reactions: [], - metadata: options.metadata, + metadata: options.initialComment.metadata, body: options.initialComment.body, }; From 9d35f72b7d0214b781b985ecad5f2ec2f24e12e3 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Jan 2025 15:02:08 +0100 Subject: [PATCH 12/30] document recommended auth rules --- .../extensions/Comments/store/ThreadStore.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/core/src/extensions/Comments/store/ThreadStore.ts b/packages/core/src/extensions/Comments/store/ThreadStore.ts index c3454d624a..79c8d4f497 100644 --- a/packages/core/src/extensions/Comments/store/ThreadStore.ts +++ b/packages/core/src/extensions/Comments/store/ThreadStore.ts @@ -1,6 +1,24 @@ import { CommentBody, CommentData, ThreadData } from "../types.js"; +/** + * ThreadStore is an abstract class that defines the interface + * to read / add / update / delete threads and comments. + * + * The methods are annotated with the recommended auth pattern + * (but of course this could be different in your app): + * - View-only users should not be able to see any comments + * - Comment-only users and editors can: + * - - create new comments / replies / reactions + * - - edit / delete their own comments / reactions + * - - resolve / unresolve threads + * - Editors can also delete any comment or thread + */ export abstract class ThreadStore { + /** + * Creates a new thread with an initial comment. + * + * Auth: should be possible by anyone with comment access + */ abstract createThread(options: { initialComment: { body: CommentBody; @@ -9,6 +27,11 @@ export abstract class ThreadStore { metadata?: any; }): Promise; + /** + * Adds a comment to a thread. + * + * Auth: should be possible by anyone with comment access + */ abstract addComment(options: { comment: { body: CommentBody; @@ -17,6 +40,11 @@ export abstract class ThreadStore { threadId: string; }): Promise; + /** + * Updates a comment in a thread. + * + * Auth: should only be possible by the comment author + */ abstract updateComment(options: { comment: { body: CommentBody; @@ -26,23 +54,53 @@ export abstract class ThreadStore { commentId: string; }): Promise; + /** + * Deletes a comment from a thread. + * + * Auth: should be possible by the comment author OR an editor of the document + */ abstract deleteComment(options: { threadId: string; commentId: string; }): Promise; + /** + * Deletes a thread. + * + * Auth: should only be possible by an editor of the document + */ abstract deleteThread(options: { threadId: string }): Promise; + /** + * Marks a thread as resolved. + * + * Auth: should be possible by anyone with comment access + */ abstract resolveThread(options: { threadId: string }): Promise; + /** + * Marks a thread as unresolved. + * + * Auth: should be possible by anyone with comment access + */ abstract unresolveThread(options: { threadId: string }): Promise; + /** + * Adds a reaction to a comment. + * + * Auth: should be possible by anyone with comment access + */ abstract addReaction(options: { threadId: string; commentId: string; // reaction: string; TODO }): Promise; + /** + * Deletes a reaction from a comment. + * + * Auth: should be possible by the reaction author + */ abstract deleteReaction(options: { threadId: string; commentId: string; From 58ed7c00ea27dd906bd0917ea214e91a886ed80c Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Jan 2025 16:05:44 +0100 Subject: [PATCH 13/30] resolve --- packages/core/src/editor/Block.css | 4 +- .../src/extensions/Comments/CommentsPlugin.ts | 63 +++++++++- .../react/src/components/Comments/Comment.tsx | 117 ++++++------------ .../react/src/components/Comments/Thread.tsx | 114 +---------------- 4 files changed, 109 insertions(+), 189 deletions(-) diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index ace7353fce..e699fd478a 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -524,10 +524,10 @@ NESTED BLOCKS padding-right: 0; } -.bn-thread-mark { +.bn-thread-mark:not([data-orphan="true"]) { background: rgba(255, 200, 0, 0.15); } -.bn-thread-mark-selected { +.bn-thread-mark:not([data-orphan="true"]) .bn-thread-mark-selected { background: rgba(255, 200, 0, 0.25); } diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index ffec2d8700..92457e5b4d 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -6,7 +6,7 @@ import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { EventEmitter } from "../../util/EventEmitter.js"; import { ThreadStore } from "./store/ThreadStore.js"; import { YjsThreadStore } from "./store/YjsThreadStore.js"; -import { CommentBody } from "./types.js"; +import { CommentBody, ThreadData } from "./types.js"; const PLUGIN_KEY = new PluginKey(`blocknote-comments`); enum CommentsPluginActions { @@ -84,6 +84,59 @@ export class CommentsPlugin extends EventEmitter { }); } + /** + * when a thread is resolved or deleted, we need to update the marks to reflect the new state + */ + private updateMarksFromThreads = (threads: Map) => { + const doc = new Y.Doc(); + const threadMap = doc.getMap("threads"); + threads.forEach((thread) => { + threadMap.set(thread.id, thread); + }); + + const ttEditor = this.editor._tiptapEditor; + if (!ttEditor) { + // TODO: better lifecycle management + return; + } + + ttEditor.state.doc.descendants((node, pos) => { + node.marks.forEach((mark) => { + if (mark.type.name === this.markType) { + const markType = mark.type; + const markThreadId = mark.attrs.threadId; + const thread = threads.get(markThreadId); + const isOrphan = !thread || thread.resolved || thread.deletedAt; + + if (isOrphan !== mark.attrs.orphan) { + const { tr } = ttEditor.state; + const trimmedFrom = Math.max(pos, 0); + const trimmedTo = Math.min( + pos + node.nodeSize, + ttEditor.state.doc.content.size - 1 + ); + tr.removeMark(trimmedFrom, trimmedTo, markType); + tr.addMark( + trimmedFrom, + trimmedTo, + markType.create({ + ...mark.attrs, + orphan: isOrphan, + }) + ); + ttEditor.dispatch(tr); + + if (isOrphan && this.selectedThreadId === markThreadId) { + // unselect + this.selectedThreadId = undefined; + this.emitStateUpdate(); + } + } + } + }); + }); + }; + constructor( private readonly editor: BlockNoteEditor, private readonly markType: string @@ -97,7 +150,13 @@ export class CommentsPlugin extends EventEmitter { doc.getMap("threads") ); - // TODO + // TODO: unsubscribe + this.store.subscribe(this.updateMarksFromThreads); + + // initial + this.updateMarksFromThreads(this.store.getThreads()); + + // TODO: remove settimeout setTimeout(() => { editor.onSelectionChange(() => { // TODO: filter out yjs transactions diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 07398ce82a..e06c137e83 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -1,6 +1,6 @@ "use client"; -import { CommentData, mergeCSSClasses } from "@blocknote/core"; +import { CommentData, ThreadData, mergeCSSClasses } from "@blocknote/core"; import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from "react"; import { useCallback, useEffect, useState } from "react"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; @@ -16,7 +16,7 @@ import { schema } from "./schema.js"; * - removed read status * ... */ -const REACTIONS_TRUNCATE = 5; +// const REACTIONS_TRUNCATE = 5; export interface CommentProps extends ComponentPropsWithoutRef<"div"> { /** @@ -27,13 +27,18 @@ export interface CommentProps extends ComponentPropsWithoutRef<"div"> { /** * The thread id. */ - threadId: string; + thread: ThreadData; /** * How to show or hide the actions. */ showActions?: boolean | "hover"; + /** + * Whether to show the resolve action. + */ + showResolveAction?: boolean; + /** * Whether to show the comment if it was deleted. If set to `false`, it will render deleted comments as `null`. */ @@ -44,55 +49,10 @@ export interface CommentProps extends ComponentPropsWithoutRef<"div"> { */ showReactions?: boolean; - /** - * Whether to show the composer's formatting controls when editing the comment. - */ - // showComposerFormattingControls?: ComposerProps["showFormattingControls"]; - - /** - * Whether to indent the comment's content. - */ - indentContent?: boolean; - - /** - * The event handler called when the comment is edited. - */ - onCommentEdit?: (comment: CommentData) => void; - - /** - * The event handler called when the comment is deleted. - */ - onCommentDelete?: (comment: CommentData) => void; - - /** - * The event handler called when clicking on the author. - */ - onAuthorClick?: (userId: string, event: MouseEvent) => void; - - /** - * The event handler called when clicking on a mention. - */ - onMentionClick?: (userId: string, event: MouseEvent) => void; - - /** - * Override the component's strings. - */ - // overrides?: Partial; - - /** - * @internal - */ - autoMarkReadThreadId?: string; - /** * @internal */ additionalActions?: ReactNode; - - /** - * @internal - */ - additionalActionsClassName?: string; } // interface CommentReactionButtonProps @@ -230,22 +190,13 @@ export interface CommentProps extends ComponentPropsWithoutRef<"div"> { export const Comment = ({ comment, - threadId, - indentContent = true, + thread, showDeleted, showActions = "hover", showReactions = true, - // showComposerFormattingControls = true, - onAuthorClick, - onMentionClick, - onCommentEdit, - onCommentDelete, - // overrides, + showResolveAction = false, className, additionalActions, - additionalActionsClassName, - autoMarkReadThreadId, - ...props }: CommentProps) => { const dict = useDictionary(); @@ -296,28 +247,36 @@ export const Comment = ({ comment: { body: commentEditor.document, }, - threadId: threadId, + threadId: thread.id, }); setEditing(false); }, - [comment, threadId, commentEditor, editor.comments] + [comment, thread.id, commentEditor, editor.comments] ); const onDelete = useCallback(() => { editor.comments!.store.deleteComment({ commentId: comment.id, - threadId: threadId, + threadId: thread.id, }); - }, [comment, threadId, editor.comments]); + }, [comment, thread.id, editor.comments]); const onReactionSelect = useCallback(() => { console.log("reaction select"); }, []); const onResolve = useCallback(() => { - console.log("resolve"); - }, []); + editor.comments!.store.resolveThread({ + threadId: thread.id, + }); + }, [thread.id, editor.comments]); + + const onReopen = useCallback(() => { + editor.comments!.store.unresolveThread({ + threadId: thread.id, + }); + }, [thread.id, editor.comments]); useEffect(() => { const isWindowDefined = typeof window !== "undefined"; @@ -342,11 +301,7 @@ export const Comment = ({ if (showActions && !isEditing) { actions = ( + className={mergeCSSClasses("bn-comment-actions", "bn-toolbar")}> {additionalActions ?? null} R1 - - R2 - + {showResolveAction && + (thread.resolved ? ( + + R2 + + ) : ( + + R2 + + ))} { */ showReactions?: CommentProps["showReactions"]; - /** - * Whether to show the composer's formatting controls. - */ - // showComposerFormattingControls?: ComposerProps["showFormattingControls"]; - - /** - * Whether to indent the comments' content. - */ - indentCommentContent?: CommentProps["indentContent"]; - /** * Whether to show deleted comments. */ @@ -57,54 +47,6 @@ export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { * Whether to show attachments. */ showAttachments?: boolean; - - /** - * The event handler called when changing the resolved status. - */ - onResolvedChange?: (resolved: boolean) => void; - - /** - * The event handler called when a comment is edited. - */ - onCommentEdit?: CommentProps["onCommentEdit"]; - - /** - * The event handler called when a comment is deleted. - */ - onCommentDelete?: CommentProps["onCommentDelete"]; - - /** - * The event handler called when the thread is deleted. - * A thread is deleted when all its comments are deleted. - */ - onThreadDelete?: (thread: ThreadData) => void; - - /** - * The event handler called when clicking on a comment's author. - */ - onAuthorClick?: CommentProps["onAuthorClick"]; - - /** - * The event handler called when clicking on a mention. - */ - onMentionClick?: CommentProps["onMentionClick"]; - - /** - * The event handler called when clicking on a comment's attachment. - */ - // onAttachmentClick?: CommentProps["onAttachmentClick"]; - - /** - * The event handler called when the composer is submitted. - */ - // onComposerSubmit?: ComposerProps["onComposerSubmit"]; - - /** - * Override the component's strings. - */ - // overrides?: Partial< - // GlobalOverrides & ThreadOverrides & CommentOverrides & ComposerOverrides - // >; } /** @@ -120,23 +62,10 @@ export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { */ export const Thread = ({ threadId, - indentCommentContent = true, showActions = "hover", showDeletedComments, showResolveAction = true, showReactions = true, - showComposer = "collapsed", - showAttachments = true, - // showComposerFormattingControls = true, - onResolvedChange, - onCommentEdit, - onCommentDelete, - onThreadDelete, - onAuthorClick, - onMentionClick, - // onAttachmentClick, - // onComposerSubmit, - // overrides, className, ...props }: ThreadProps) => { @@ -222,55 +151,22 @@ export const Thread = ({ return ( {thread.comments.map((comment, index) => { const isFirstComment = index === firstCommentIndex; - + const hasRightToResolve = true; // TODO + const showResolveAction = isFirstComment && hasRightToResolve; return ( - // - // - // - // - // ) : null - // } + showResolveAction={showResolveAction} /> ); })} From b761e1ed3d5b9bc65820cfe119aa9295f5ce8504 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Jan 2025 20:43:31 +0100 Subject: [PATCH 14/30] basic userstore impl --- .../src/extensions/Comments/CommentsPlugin.ts | 23 +++++-- .../LiveBlocksThreadStore.ts | 0 .../{store => threadstore}/ThreadStore.ts | 0 .../TipTapThreadStore.ts | 0 .../YjsThreadStore.test.ts | 0 .../{store => threadstore}/YjsThreadStore.ts | 0 .../core/src/extensions/Comments/types.ts | 10 ++- .../Comments/userstore/UserStore.ts | 48 ++++++++++++++ .../react/src/components/Comments/Comment.tsx | 9 ++- .../react/src/components/Comments/Thread.tsx | 62 ++++--------------- .../react/src/components/Comments/useUsers.ts | 52 ++++++++++++++++ .../react/src/editor/ComponentsContext.tsx | 9 +-- 12 files changed, 143 insertions(+), 70 deletions(-) rename packages/core/src/extensions/Comments/{store => threadstore}/LiveBlocksThreadStore.ts (100%) rename packages/core/src/extensions/Comments/{store => threadstore}/ThreadStore.ts (100%) rename packages/core/src/extensions/Comments/{store => threadstore}/TipTapThreadStore.ts (100%) rename packages/core/src/extensions/Comments/{store => threadstore}/YjsThreadStore.test.ts (100%) rename packages/core/src/extensions/Comments/{store => threadstore}/YjsThreadStore.ts (100%) create mode 100644 packages/core/src/extensions/Comments/userstore/UserStore.ts create mode 100644 packages/react/src/components/Comments/useUsers.ts diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index 92457e5b4d..732dbada26 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -4,9 +4,10 @@ import { Decoration, DecorationSet } from "prosemirror-view"; import * as Y from "yjs"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { EventEmitter } from "../../util/EventEmitter.js"; -import { ThreadStore } from "./store/ThreadStore.js"; -import { YjsThreadStore } from "./store/YjsThreadStore.js"; -import { CommentBody, ThreadData } from "./types.js"; +import { ThreadStore } from "./threadstore/ThreadStore.js"; +import { YjsThreadStore } from "./threadstore/YjsThreadStore.js"; +import { CommentBody, ThreadData, User } from "./types.js"; +import { UserStore } from "./userstore/UserStore.js"; const PLUGIN_KEY = new PluginKey(`blocknote-comments`); enum CommentsPluginActions { @@ -139,7 +140,21 @@ export class CommentsPlugin extends EventEmitter { constructor( private readonly editor: BlockNoteEditor, - private readonly markType: string + private readonly markType: string, + public readonly userStore = new UserStore(async (userIds) => { + // fake slow network request + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // random username + const names = ["John Doe", "Jane Doe", "John Smith", "Jane Smith"]; + const username = names[Math.floor(Math.random() * names.length)]; + + return userIds.map((id) => ({ + id, + username, + avatarUrl: `https://placehold.co/100x100?text=${username}`, + })); + }) ) { super(); diff --git a/packages/core/src/extensions/Comments/store/LiveBlocksThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/LiveBlocksThreadStore.ts similarity index 100% rename from packages/core/src/extensions/Comments/store/LiveBlocksThreadStore.ts rename to packages/core/src/extensions/Comments/threadstore/LiveBlocksThreadStore.ts diff --git a/packages/core/src/extensions/Comments/store/ThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts similarity index 100% rename from packages/core/src/extensions/Comments/store/ThreadStore.ts rename to packages/core/src/extensions/Comments/threadstore/ThreadStore.ts diff --git a/packages/core/src/extensions/Comments/store/TipTapThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/TipTapThreadStore.ts similarity index 100% rename from packages/core/src/extensions/Comments/store/TipTapThreadStore.ts rename to packages/core/src/extensions/Comments/threadstore/TipTapThreadStore.ts diff --git a/packages/core/src/extensions/Comments/store/YjsThreadStore.test.ts b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.test.ts similarity index 100% rename from packages/core/src/extensions/Comments/store/YjsThreadStore.test.ts rename to packages/core/src/extensions/Comments/threadstore/YjsThreadStore.test.ts diff --git a/packages/core/src/extensions/Comments/store/YjsThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts similarity index 100% rename from packages/core/src/extensions/Comments/store/YjsThreadStore.ts rename to packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts diff --git a/packages/core/src/extensions/Comments/types.ts b/packages/core/src/extensions/Comments/types.ts index a928f9aec2..c50f769ec3 100644 --- a/packages/core/src/extensions/Comments/types.ts +++ b/packages/core/src/extensions/Comments/types.ts @@ -3,9 +3,7 @@ export type CommentBody = any; export type CommentReactionData = { emoji: string; createdAt: Date; - users: { - id: string; - }[]; + usersIds: string[]; }; export type CommentData = { @@ -39,3 +37,9 @@ export type ThreadData = { metadata: any; deletedAt?: Date; }; + +export type User = { + id: string; + username: string; + avatarUrl: string; +}; diff --git a/packages/core/src/extensions/Comments/userstore/UserStore.ts b/packages/core/src/extensions/Comments/userstore/UserStore.ts new file mode 100644 index 0000000000..4cad7326e0 --- /dev/null +++ b/packages/core/src/extensions/Comments/userstore/UserStore.ts @@ -0,0 +1,48 @@ +import { EventEmitter } from "../../../util/EventEmitter.js"; +import { User } from "../types.js"; +export class UserStore extends EventEmitter { + private userCache: Map = new Map(); + + // avoid duplicate loads + private loadingUsers = new Set(); + + public constructor( + private readonly resolveUsers: (userIds: string[]) => Promise + ) { + super(); + } + + public async loadUsers(userIds: string[]) { + const missingUsers = userIds.filter( + (id) => !this.userCache.has(id) && !this.loadingUsers.has(id) + ); + + if (missingUsers.length === 0) { + return; + } + + for (const id of missingUsers) { + this.loadingUsers.add(id); + } + + try { + const users = await this.resolveUsers(missingUsers); + for (const user of users) { + this.userCache.set(user.id, user); + } + this.emit("update", this.userCache); + } finally { + for (const id of missingUsers) { + this.loadingUsers.delete(id); + } + } + } + + public getUser(userId: string): U | undefined { + return this.userCache.get(userId); + } + + public subscribe(cb: (users: Map) => void): () => void { + return this.on("update", cb); + } +} diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index e06c137e83..90537faa74 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -9,6 +9,7 @@ import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; +import { useUser } from "./useUsers.js"; /** * Liveblocks, but changed: @@ -292,6 +293,8 @@ export const Comment = ({ } }, []); // eslint-disable-line react-hooks/exhaustive-deps + const user = useUser(editor, comment.userId); + if (!showDeleted && !comment.body) { return null; } @@ -357,11 +360,7 @@ export const Comment = ({ return ( diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index 48502c1504..0bd33c6619 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -11,6 +11,7 @@ import { Comment, CommentProps } from "./Comment.js"; import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; import { useThreadStore } from "./useThreadStore.js"; +import { useUsers } from "./useUsers.js"; export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { /** @@ -42,24 +43,8 @@ export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { * Whether to show deleted comments. */ showDeletedComments?: CommentProps["showDeleted"]; - - /** - * Whether to show attachments. - */ - showAttachments?: boolean; } -/** - * Displays a thread of comments, with a composer to reply - * to it. - * - * @example - * <> - * {threads.map((thread) => ( - * - * ))} - * - */ export const Thread = ({ threadId, showActions = "hover", @@ -82,6 +67,16 @@ export const Thread = ({ throw new Error("Thread not found"); } + const userIds = useMemo(() => { + return thread.comments.flatMap((c) => [ + c.userId, + ...c.reactions.flatMap((r) => r.usersIds), + ]); + }, [thread.comments]); + + // load all user data + useUsers(editor, userIds); + const newCommentEditor = useCreateBlockNote({ trailingBlock: false, dictionary: { @@ -100,41 +95,6 @@ export const Thread = ({ : thread.comments.findIndex((comment) => comment.body); }, [showDeletedComments, thread.comments]); - // const handleResolvedChange = useCallback( - // (resolved: boolean) => { - // onResolvedChange?.(resolved); - - // if (resolved) { - // markThreadAsResolved(thread.id); - // } else { - // markThreadAsUnresolved(thread.id); - // } - // }, - // [ - // markThreadAsResolved, - // markThreadAsUnresolved, - // onResolvedChange, - // thread.id, - // ] - // ); - - // TODO: thread deletion - - // const handleCommentDelete = useCallback( - // (comment: Comment) => { - // onCommentDelete?.(comment); - - // const filteredComments = thread.comments.filter( - // (comment) => comment.body - // ); - - // if (filteredComments.length <= 1) { - // onThreadDelete?.(thread); - // } - // }, - // [onCommentDelete, onThreadDelete, thread] - // ); - const onNewCommentSave = useCallback(async () => { await editor.comments!.store.addComment({ comment: { diff --git a/packages/react/src/components/Comments/useUsers.ts b/packages/react/src/components/Comments/useUsers.ts new file mode 100644 index 0000000000..62279d9810 --- /dev/null +++ b/packages/react/src/components/Comments/useUsers.ts @@ -0,0 +1,52 @@ +import { BlockNoteEditor, User } from "@blocknote/core"; +import { useCallback, useRef, useSyncExternalStore } from "react"; + +export function useUser( + editor: BlockNoteEditor, + userId: string +) { + return useUsers(editor, [userId]).get(userId); +} + +export function useUsers( + editor: BlockNoteEditor, + userIds: string[] +) { + const store = editor.comments!.userStore; + + // this ref works around this error: + // https://react.dev/reference/react/useSyncExternalStore#im-getting-an-error-the-result-of-getsnapshot-should-be-cached + // however, might not be a good practice to work around it this way + const usersRef = useRef>(); + + const getSnapshot = useCallback(() => { + const map = new Map(); + for (const id of userIds) { + const user = store.getUser(id); + if (user) { + map.set(id, user); + } + } + return map; + }, [store, userIds]); + + if (!usersRef.current) { + usersRef.current = getSnapshot(); + } + + // note: this is inefficient as it will trigger a re-render even if other users (not in userIds) are updated + const subscribe = useCallback( + (cb: () => void) => { + const ret = store.subscribe((_users) => { + // update ref when changed + usersRef.current = getSnapshot(); + cb(); + }); + store.loadUsers(userIds); + return ret; + }, + [store, getSnapshot, userIds] + ); + + return useSyncExternalStore(subscribe, () => usersRef.current!); +} diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index ffe5b56152..2ebdbce383 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -9,7 +9,7 @@ import { useContext, } from "react"; -import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteEditor, User } from "@blocknote/core"; import { DefaultReactGridSuggestionItem } from "../components/SuggestionMenu/GridSuggestionMenu/types.js"; import { DefaultReactSuggestionItem } from "../components/SuggestionMenu/types.js"; @@ -273,12 +273,7 @@ export type ComponentProps = { Comment: { className?: string; children?: ReactNode; - authorInfo: - | "loading" - | { - username: string; - avatarUrl?: string; - }; + authorInfo: "loading" | User; timeString: string; actions?: ReactNode; showActions?: boolean | "hover"; From 5f5214790944c871d8d0bd6b2a30e77b3a38b999 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 16 Jan 2025 22:42:04 +0100 Subject: [PATCH 15/30] user auth --- .../src/extensions/Comments/CommentsPlugin.ts | 4 +- .../threadstore/DefaultThreadStoreAuth.ts | 74 +++++++++++++++++++ .../Comments/threadstore/ThreadStore.ts | 30 ++------ .../Comments/threadstore/ThreadStoreAuth.ts | 14 ++++ .../Comments/threadstore/YjsThreadStore.ts | 49 +++++++++--- .../react/src/components/Comments/Comment.tsx | 67 ++++++++++------- .../react/src/components/Comments/Thread.tsx | 60 +++++++-------- 7 files changed, 208 insertions(+), 90 deletions(-) create mode 100644 packages/core/src/extensions/Comments/threadstore/DefaultThreadStoreAuth.ts create mode 100644 packages/core/src/extensions/Comments/threadstore/ThreadStoreAuth.ts diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index 732dbada26..ed71492dab 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -4,6 +4,7 @@ import { Decoration, DecorationSet } from "prosemirror-view"; import * as Y from "yjs"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { EventEmitter } from "../../util/EventEmitter.js"; +import { DefaultThreadStoreAuth } from "./threadstore/DefaultThreadStoreAuth.js"; import { ThreadStore } from "./threadstore/ThreadStore.js"; import { YjsThreadStore } from "./threadstore/YjsThreadStore.js"; import { CommentBody, ThreadData, User } from "./types.js"; @@ -162,7 +163,8 @@ export class CommentsPlugin extends EventEmitter { this.store = new YjsThreadStore( editor, "blablauserid", - doc.getMap("threads") + doc.getMap("threads"), + new DefaultThreadStoreAuth("blablauserid", "comment") ); // TODO: unsubscribe diff --git a/packages/core/src/extensions/Comments/threadstore/DefaultThreadStoreAuth.ts b/packages/core/src/extensions/Comments/threadstore/DefaultThreadStoreAuth.ts new file mode 100644 index 0000000000..4faf240116 --- /dev/null +++ b/packages/core/src/extensions/Comments/threadstore/DefaultThreadStoreAuth.ts @@ -0,0 +1,74 @@ +import { CommentData, ThreadData } from "../types.js"; +import { ThreadStoreAuth } from "./ThreadStoreAuth.js"; + +/* + * The methods are annotated with the recommended auth pattern + * (but of course this could be different in your app): + * - View-only users should not be able to see any comments + * - Comment-only users and editors can: + * - - create new comments / replies / reactions + * - - edit / delete their own comments / reactions + * - - resolve / unresolve threads + * - Editors can also delete any comment or thread + */ +export class DefaultThreadStoreAuth extends ThreadStoreAuth { + constructor( + private readonly userId: string, + private readonly role: "comment" | "editor" + ) { + super(); + } + + /** + * Auth: should be possible by anyone with comment access + */ + canCreateThread(): boolean { + return true; + } + + /** + * Auth: should be possible by anyone with comment access + */ + canAddComment(_thread: ThreadData): boolean { + return true; + } + + /** + * Auth: should only be possible by the comment author + */ + canUpdateComment(comment: CommentData): boolean { + return comment.userId === this.userId; + } + + /** + * Auth: should be possible by the comment author OR an editor of the document + */ + canDeleteComment(comment: CommentData): boolean { + return comment.userId === this.userId || this.role === "editor"; + } + + /** + * Auth: should only be possible by an editor of the document + */ + canDeleteThread(_thread: ThreadData): boolean { + return this.role === "editor"; + } + + /** + * Auth: should be possible by anyone with comment access + */ + canResolveThread(_thread: ThreadData): boolean { + return true; + } + + /** + * Auth: should be possible by anyone with comment access + */ + canUnresolveThread(_thread: ThreadData): boolean { + return true; + } + + // TODO: reactions + // abstract canAddReaction(comment: CommentData): boolean; + // abstract canDeleteReaction(comment: CommentData): boolean; +} diff --git a/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts index 79c8d4f497..936288d463 100644 --- a/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts +++ b/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts @@ -1,23 +1,19 @@ import { CommentBody, CommentData, ThreadData } from "../types.js"; +import { ThreadStoreAuth } from "./ThreadStoreAuth.js"; /** * ThreadStore is an abstract class that defines the interface * to read / add / update / delete threads and comments. - * - * The methods are annotated with the recommended auth pattern - * (but of course this could be different in your app): - * - View-only users should not be able to see any comments - * - Comment-only users and editors can: - * - - create new comments / replies / reactions - * - - edit / delete their own comments / reactions - * - - resolve / unresolve threads - * - Editors can also delete any comment or thread */ export abstract class ThreadStore { + public readonly auth: ThreadStoreAuth; + + constructor(auth: ThreadStoreAuth) { + this.auth = auth; + } + /** * Creates a new thread with an initial comment. - * - * Auth: should be possible by anyone with comment access */ abstract createThread(options: { initialComment: { @@ -29,8 +25,6 @@ export abstract class ThreadStore { /** * Adds a comment to a thread. - * - * Auth: should be possible by anyone with comment access */ abstract addComment(options: { comment: { @@ -42,8 +36,6 @@ export abstract class ThreadStore { /** * Updates a comment in a thread. - * - * Auth: should only be possible by the comment author */ abstract updateComment(options: { comment: { @@ -56,8 +48,6 @@ export abstract class ThreadStore { /** * Deletes a comment from a thread. - * - * Auth: should be possible by the comment author OR an editor of the document */ abstract deleteComment(options: { threadId: string; @@ -66,22 +56,16 @@ export abstract class ThreadStore { /** * Deletes a thread. - * - * Auth: should only be possible by an editor of the document */ abstract deleteThread(options: { threadId: string }): Promise; /** * Marks a thread as resolved. - * - * Auth: should be possible by anyone with comment access */ abstract resolveThread(options: { threadId: string }): Promise; /** * Marks a thread as unresolved. - * - * Auth: should be possible by anyone with comment access */ abstract unresolveThread(options: { threadId: string }): Promise; diff --git a/packages/core/src/extensions/Comments/threadstore/ThreadStoreAuth.ts b/packages/core/src/extensions/Comments/threadstore/ThreadStoreAuth.ts new file mode 100644 index 0000000000..57031c015c --- /dev/null +++ b/packages/core/src/extensions/Comments/threadstore/ThreadStoreAuth.ts @@ -0,0 +1,14 @@ +import { CommentData, ThreadData } from "../types.js"; + +export abstract class ThreadStoreAuth { + abstract canCreateThread(): boolean; + abstract canAddComment(thread: ThreadData): boolean; + abstract canUpdateComment(comment: CommentData): boolean; + abstract canDeleteComment(comment: CommentData): boolean; + abstract canDeleteThread(thread: ThreadData): boolean; + abstract canResolveThread(thread: ThreadData): boolean; + abstract canUnresolveThread(thread: ThreadData): boolean; + // TODO: reactions + // abstract canAddReaction(comment: CommentData): boolean; + // abstract canDeleteReaction(comment: CommentData): boolean; +} diff --git a/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts index fb9c7f9048..91e90d4837 100644 --- a/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts +++ b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts @@ -3,24 +3,16 @@ import * as Y from "yjs"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { CommentBody, CommentData, ThreadData } from "../types.js"; import { ThreadStore } from "./ThreadStore.js"; - -// type YjsType = { -// [K in keyof T]: T[K] extends Date ? string : T[K]; // TODO: dates as string? -// }; - -// type YjsTypeConvertArrays = { -// [K in keyof T]: T[K] extends Array -// ? Y.Array> -// : YjsType; -// }; +import { ThreadStoreAuth } from "./ThreadStoreAuth.js"; export class YjsThreadStore extends ThreadStore { constructor( private readonly editor: BlockNoteEditor, private readonly userId: string, - private readonly threadsYMap: Y.Map + private readonly threadsYMap: Y.Map, + auth: ThreadStoreAuth ) { - super(); + super(auth); } private transact = ( @@ -41,6 +33,10 @@ export class YjsThreadStore extends ThreadStore { }; metadata?: any; }) => { + if (!this.auth.canCreateThread()) { + throw new Error("Not authorized"); + } + const date = new Date(); const comment: CommentData = { @@ -83,6 +79,10 @@ export class YjsThreadStore extends ThreadStore { throw new Error("Thread not found"); } + if (!this.auth.canAddComment(yMapToThread(yThread))) { + throw new Error("Not authorized"); + } + const date = new Date(); const comment: CommentData = { type: "comment", @@ -129,6 +129,11 @@ export class YjsThreadStore extends ThreadStore { } const yComment = yThread.get("comments").get(yCommentIndex); + + if (!this.auth.canUpdateComment(yMapToComment(yComment))) { + throw new Error("Not authorized"); + } + yComment.set("body", options.comment.body); yComment.set("updatedAt", new Date().getTime()); yComment.set("metadata", options.comment.metadata); @@ -157,6 +162,10 @@ export class YjsThreadStore extends ThreadStore { const yComment = yThread.get("comments").get(yCommentIndex); + if (!this.auth.canDeleteComment(yMapToComment(yComment))) { + throw new Error("Not authorized"); + } + if (yComment.get("deletedAt")) { throw new Error("Comment already deleted"); } @@ -186,6 +195,14 @@ export class YjsThreadStore extends ThreadStore { ); public deleteThread = this.transact((options: { threadId: string }) => { + if ( + !this.auth.canDeleteThread( + yMapToThread(this.threadsYMap.get(options.threadId)) + ) + ) { + throw new Error("Not authorized"); + } + this.threadsYMap.delete(options.threadId); }); @@ -195,6 +212,10 @@ export class YjsThreadStore extends ThreadStore { throw new Error("Thread not found"); } + if (!this.auth.canResolveThread(yMapToThread(yThread))) { + throw new Error("Not authorized"); + } + yThread.set("resolved", true); yThread.set("resolvedUpdatedAt", new Date().getTime()); }); @@ -205,6 +226,10 @@ export class YjsThreadStore extends ThreadStore { throw new Error("Thread not found"); } + if (!this.auth.canUnresolveThread(yMapToThread(yThread))) { + throw new Error("Not authorized"); + } + yThread.set("resolved", false); yThread.set("resolvedUpdatedAt", new Date().getTime()); }); diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 90537faa74..3023b20954 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -300,19 +300,30 @@ export const Comment = ({ } let actions: ReactNode | undefined = undefined; + const canAddReaction = true; //editor.comments!.store.auth.canAddReaction(comment); + const canDeleteComment = + editor.comments!.store.auth.canDeleteComment(comment); + const canEditComment = editor.comments!.store.auth.canUpdateComment(comment); + + const showResolveOrReopen = + showResolveAction && + (thread.resolved + ? editor.comments!.store.auth.canUnresolveThread(thread) + : editor.comments!.store.auth.canResolveThread(thread)); if (showActions && !isEditing) { actions = ( - {additionalActions ?? null} - - R1 - - {showResolveAction && + {canAddReaction && ( + + R1 + + )} + {showResolveOrReopen && (thread.resolved ? ( ))} - - - - ... - - - - - Edit comment - - - Delete comment - - - + {(canDeleteComment || canEditComment) && ( + + + + ... + + + + {canEditComment && ( + + Edit comment + + )} + {canDeleteComment && ( + + Delete comment + + )} + + + )} ); } diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx index 0bd33c6619..590b0648cd 100644 --- a/packages/react/src/components/Comments/Thread.tsx +++ b/packages/react/src/components/Comments/Thread.tsx @@ -107,7 +107,8 @@ export const Thread = ({ newCommentEditor.removeBlocks(newCommentEditor.document); }, [editor.comments, newCommentEditor, thread.id]); - // TODO: extract component + const showComposer = editor.comments!.store.auth.canAddComment(thread); + return ( {thread.comments.map((comment, index) => { const isFirstComment = index === firstCommentIndex; - const hasRightToResolve = true; // TODO - const showResolveAction = isFirstComment && hasRightToResolve; + return ( ); })} - - { - if (!isFocused && isEmpty) { - return null; - } - - return ( - - - Save - - - ); - }} - /> - + {showComposer && ( + + { + if (!isFocused && isEmpty) { + return null; + } + + return ( + + + Save + + + ); + }} + /> + + )} ); }; From 81cb3cefd1774480062886fcd772db47df34eb8c Mon Sep 17 00:00:00 2001 From: Martin Anselmo Date: Fri, 17 Jan 2025 07:34:21 -0300 Subject: [PATCH 16/30] feat: stop suggestion menu event propagation (#1346) --- .../SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts index b7d0d2bb83..8a9bb1019c 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -35,6 +35,7 @@ export function useSuggestionMenuKeyboardNavigation( if (event.key === "Enter" && !event.isComposing) { event.preventDefault(); + event.stopPropagation(); if (items.length) { onItemClick?.(items[selectedIndex]); From c0bc5d0e29b5dd4e523f9a7b5663d839e4d425fb Mon Sep 17 00:00:00 2001 From: Caio Ricciuti Date: Fri, 17 Jan 2025 11:38:47 +0100 Subject: [PATCH 17/30] Add Italian Translations (#1345) --- packages/core/src/i18n/locales/index.ts | 1 + packages/core/src/i18n/locales/it.ts | 315 ++++++++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 packages/core/src/i18n/locales/it.ts diff --git a/packages/core/src/i18n/locales/index.ts b/packages/core/src/i18n/locales/index.ts index d17fc75f24..cead59d687 100644 --- a/packages/core/src/i18n/locales/index.ts +++ b/packages/core/src/i18n/locales/index.ts @@ -13,3 +13,4 @@ export * from "./pt.js"; export * from "./ru.js"; export * from "./vi.js"; export * from "./zh.js"; +export * from "./it.js" \ No newline at end of file diff --git a/packages/core/src/i18n/locales/it.ts b/packages/core/src/i18n/locales/it.ts new file mode 100644 index 0000000000..b1df6e780e --- /dev/null +++ b/packages/core/src/i18n/locales/it.ts @@ -0,0 +1,315 @@ +export const it = { + slash_menu: { + heading: { + title: "Intestazione 1", + subtext: "Intestazione di primo livello", + aliases: ["h", "intestazione1", "h1"], + group: "Intestazioni", + }, + heading_2: { + title: "Intestazione 2", + subtext: "Intestazione di sezione chiave", + aliases: ["h2", "intestazione2", "sottotitolo"], + group: "Intestazioni", + }, + heading_3: { + title: "Intestazione 3", + subtext: "Intestazione di sottosezione e gruppo", + aliases: ["h3", "intestazione3", "sottotitolo"], + group: "Intestazioni", + }, + numbered_list: { + title: "Elenco Numerato", + subtext: "Elenco con elementi ordinati", + aliases: ["ol", "li", "elenco", "elenconumerato", "elenco numerato"], + group: "Blocchi Base", + }, + bullet_list: { + title: "Elenco Puntato", + subtext: "Elenco con elementi non ordinati", + aliases: ["ul", "li", "elenco", "elencopuntato", "elenco puntato"], + group: "Blocchi Base", + }, + check_list: { + title: "Elenco di Controllo", + subtext: "Elenco con caselle di controllo", + aliases: [ + "ul", + "li", + "elenco", + "elencocontrollo", + "elenco controllo", + "elenco verificato", + "casella di controllo", + ], + group: "Blocchi Base", + }, + paragraph: { + title: "Paragrafo", + subtext: "Il corpo del tuo documento", + aliases: ["p", "paragrafo"], + group: "Blocchi Base", + }, + code_block: { + title: "Blocco di Codice", + subtext: "Blocco di codice con evidenziazione della sintassi", + aliases: ["code", "pre"], + group: "Blocchi Base", + }, + table: { + title: "Tabella", + subtext: "Tabella con celle modificabili", + aliases: ["tabella"], + group: "Avanzato", + }, + image: { + title: "Immagine", + subtext: "Immagine ridimensionabile con didascalia", + aliases: [ + "immagine", + "caricaImmagine", + "carica", + "img", + "foto", + "media", + "url", + ], + group: "Media", + }, + video: { + title: "Video", + subtext: "Video ridimensionabile con didascalia", + aliases: [ + "video", + "caricaVideo", + "carica", + "mp4", + "film", + "media", + "url", + ], + group: "Media", + }, + audio: { + title: "Audio", + subtext: "Audio incorporato con didascalia", + aliases: [ + "audio", + "caricaAudio", + "carica", + "mp3", + "suono", + "media", + "url", + ], + group: "Media", + }, + file: { + title: "File", + subtext: "File incorporato", + aliases: ["file", "carica", "embed", "media", "url"], + group: "Media", + }, + emoji: { + title: "Emoji", + subtext: "Cerca e inserisci un'emoji", + aliases: ["emoji", "emote", "emozione", "faccia"], + group: "Altri", + }, + }, + placeholders: { + default: "Inserisci testo o digita '/' per i comandi", + heading: "Intestazione", + bulletListItem: "Elenco", + numberedListItem: "Elenco", + checkListItem: "Elenco", + }, + file_blocks: { + image: { + add_button_text: "Aggiungi immagine", + }, + video: { + add_button_text: "Aggiungi video", + }, + audio: { + add_button_text: "Aggiungi audio", + }, + file: { + add_button_text: "Aggiungi file", + }, + }, + // from react package: + side_menu: { + add_block_label: "Aggiungi blocco", + drag_handle_label: "Apri menu blocco", + }, + drag_handle: { + delete_menuitem: "Elimina", + colors_menuitem: "Colori", + }, + table_handle: { + delete_column_menuitem: "Elimina colonna", + delete_row_menuitem: "Elimina riga", + add_left_menuitem: "Aggiungi colonna a sinistra", + add_right_menuitem: "Aggiungi colonna a destra", + add_above_menuitem: "Aggiungi riga sopra", + add_below_menuitem: "Aggiungi riga sotto", + }, + suggestion_menu: { + no_items_title: "Nessun elemento trovato", + loading: "Caricamento…", + }, + color_picker: { + text_title: "Testo", + background_title: "Sfondo", + colors: { + default: "Predefinito", + gray: "Grigio", + brown: "Marrone", + red: "Rosso", + orange: "Arancione", + yellow: "Giallo", + green: "Verde", + blue: "Blu", + purple: "Viola", + pink: "Rosa", + }, + }, + + formatting_toolbar: { + bold: { + tooltip: "Grassetto", + secondary_tooltip: "Cmd+B", + }, + italic: { + tooltip: "Corsivo", + secondary_tooltip: "Cmd+I", + }, + underline: { + tooltip: "Sottolineato", + secondary_tooltip: "Cmd+U", + }, + strike: { + tooltip: "Barrato", + secondary_tooltip: "Cmd+Shift+S", + }, + code: { + tooltip: "Codice", + secondary_tooltip: "", + }, + colors: { + tooltip: "Colori", + }, + link: { + tooltip: "Crea link", + secondary_tooltip: "Cmd+K", + }, + file_caption: { + tooltip: "Modifica didascalia", + input_placeholder: "Modifica didascalia", + }, + file_replace: { + tooltip: { + image: "Sostituisci immagine", + video: "Sostituisci video", + audio: "Sostituisci audio", + file: "Sostituisci file", + } as Record, + }, + file_rename: { + tooltip: { + image: "Rinomina immagine", + video: "Rinomina video", + audio: "Rinomina audio", + file: "Rinomina file", + } as Record, + input_placeholder: { + image: "Rinomina immagine", + video: "Rinomina video", + audio: "Rinomina audio", + file: "Rinomina file", + } as Record, + }, + file_download: { + tooltip: { + image: "Scarica immagine", + video: "Scarica video", + audio: "Scarica audio", + file: "Scarica file", + } as Record, + }, + file_delete: { + tooltip: { + image: "Elimina immagine", + video: "Elimina video", + audio: "Elimina audio", + file: "Elimina file", + } as Record, + }, + file_preview_toggle: { + tooltip: "Attiva/disattiva anteprima", + }, + nest: { + tooltip: "Annida blocco", + secondary_tooltip: "Tab", + }, + unnest: { + tooltip: "Disannida blocco", + secondary_tooltip: "Shift+Tab", + }, + align_left: { + tooltip: "Allinea testo a sinistra", + }, + align_center: { + tooltip: "Allinea testo al centro", + }, + align_right: { + tooltip: "Allinea testo a destra", + }, + align_justify: { + tooltip: "Giustifica testo", + }, + }, + file_panel: { + upload: { + title: "Carica", + file_placeholder: { + image: "Carica immagine", + video: "Carica video", + audio: "Carica audio", + file: "Carica file", + } as Record, + upload_error: "Errore: Caricamento fallito", + }, + embed: { + title: "Incorpora", + embed_button: { + image: "Incorpora immagine", + video: "Incorpora video", + audio: "Incorpora audio", + file: "Incorpora file", + } as Record, + url_placeholder: "Inserisci URL", + }, + }, + link_toolbar: { + delete: { + tooltip: "Rimuovi link", + }, + edit: { + text: "Modifica link", + tooltip: "Modifica", + }, + open: { + tooltip: "Apri in una nuova scheda", + }, + form: { + title_placeholder: "Modifica titolo", + url_placeholder: "Modifica URL", + }, + }, + generic: { + ctrl_shortcut: "Ctrl", + }, + }; + \ No newline at end of file From 9a8f957f14ad1b6763e1c590f2037e247d2a6ff4 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Fri, 17 Jan 2025 12:16:10 +0100 Subject: [PATCH 18/30] feat: Code block support in PDF & DOCX exporters (#1367) * feat: Support for code blocks in PDF exporter * feat: Support for code blocks in DOCX exporter * fix: Use Geist Mono for inline code tags in DOCX export * fix: Background color, example formatting, style definition * Removed TODO --------- Co-authored-by: matthewlipski --- .../05-converting-blocks-to-pdf/App.tsx | 9 +++++ .../06-converting-blocks-to-docx/App.tsx | 9 +++++ .../src/docx/__snapshots__/basic/document.xml | 26 ++++++++++----- .../src/docx/__snapshots__/basic/styles.xml | 4 +-- .../src/docx/defaultSchema/blocks.ts | 26 ++++++++++----- .../src/docx/defaultSchema/styles.ts | 2 +- .../xl-docx-exporter/src/docx/docxExporter.ts | 28 +++++++++++++--- .../src/docx/template/word/styles.xml | 4 +-- .../src/pdf/__snapshots__/example.jsx | 26 ++++++++++++--- .../exampleWithHeaderAndFooter.jsx | 26 ++++++++++++--- .../src/pdf/defaultSchema/blocks.tsx | 31 +++++++++++++++++- .../src/pdf/defaultSchema/styles.tsx | 2 +- .../xl-pdf-exporter/src/pdf/pdfExporter.tsx | 8 +++++ shared/assets/fonts/GeistMono-Regular.ttf | Bin 0 -> 116248 bytes shared/testDocument.ts | 13 +++++--- 15 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 shared/assets/fonts/GeistMono-Regular.ttf diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx b/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx index 84b3d7b073..63ba2109aa 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx @@ -282,6 +282,15 @@ export default function App() { ], }, }, + { + type: "codeBlock", + props: { + language: "javascript", + }, + content: `const helloWorld = (message) => { + console.log("Hello World", message); +};`, + }, ], }); diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx b/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx index 3f0618546e..a456d84f94 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx +++ b/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx @@ -277,6 +277,15 @@ export default function App() { ], }, }, + { + type: "codeBlock", + props: { + language: "javascript", + }, + content: `const helloWorld = (message) => { + console.log("Hello World", message); +};`, + }, ], }); diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml index 252040fe31..ef14525b4f 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml @@ -112,15 +112,6 @@ justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - - - Code Block -Line 2 - - @@ -675,6 +666,23 @@ Line 2 + + + + + + + const helloWorld = (message) => { + + + + console.log("Hello World", message); + + + + }; + + diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/styles.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/styles.xml index 2b3704b69a..d23b5bb2ff 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/styles.xml +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/styles.xml @@ -953,8 +953,8 @@ - - + + \ No newline at end of file diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 43bfba9781..5d6a9f7e32 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -3,6 +3,7 @@ import { COLORS_DEFAULT, DefaultBlockSchema, DefaultProps, + StyledText, UnreachableCaseError, } from "@blocknote/core"; import { @@ -131,17 +132,24 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< ...caption(block.props, exporter), ]; }, - // TODO - codeBlock: (block, exporter) => { + codeBlock: (block) => { + const textContent = (block.content as StyledText[])[0]?.text || ""; + return new Paragraph({ - // ...blockPropsToStyles(block.props, exporter.options.colors), style: "Codeblock", - children: exporter.transformInlineContent(block.content), - // children: [ - // new TextRun({ - // text: block..type + " not implemented", - // }), - // ], + shading: { + type: ShadingType.SOLID, + fill: "161616", + color: "161616", + }, + children: [ + ...textContent.split("\n").map((line, index) => { + return new TextRun({ + text: line, + break: index > 0 ? 1 : 0, + }); + }), + ], }); }, image: async (block, exporter) => { diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/styles.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/styles.ts index 313a0b244a..1d56e25aaa 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/styles.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/styles.ts @@ -67,7 +67,7 @@ export const docxStyleMappingForDefaultSchema: StyleMapping< return {}; } return { - font: "Courier New", + font: "GeistMono", }; }, }; diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index 2be6106591..7b0c8cc084 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -142,17 +142,35 @@ export class DOCXExporter< // Unfortunately, loading the variable font doesn't work // "./src/fonts/Inter-VariableFont_opsz,wght.ttf", - let font = await loadFileBuffer( + let interFont = await loadFileBuffer( await import("@shared/assets/fonts/inter/Inter_18pt-Regular.ttf") ); + let geistMonoFont = await loadFileBuffer( + await import("@shared/assets/fonts/GeistMono-Regular.ttf") + ); - if (font instanceof ArrayBuffer) { - // conversionw with Polyfill needed because docxjs requires Buffer + if ( + interFont instanceof ArrayBuffer || + geistMonoFont instanceof Uint8Array + ) { + // conversion with Polyfill needed because docxjs requires Buffer const Buffer = (await import("buffer")).Buffer; - font = Buffer.from(font); + + if (interFont instanceof ArrayBuffer) { + interFont = Buffer.from(interFont); + } + if (geistMonoFont instanceof ArrayBuffer) { + geistMonoFont = Buffer.from(geistMonoFont); + } } - return [{ name: "Inter", data: font as Buffer }]; + return [ + { name: "Inter", data: interFont as Buffer }, + { + name: "GeistMono", + data: geistMonoFont as Buffer, + }, + ]; } protected async createDefaultDocumentOptions(): Promise { diff --git a/packages/xl-docx-exporter/src/docx/template/word/styles.xml b/packages/xl-docx-exporter/src/docx/template/word/styles.xml index e3489ab35e..8e7d713135 100644 --- a/packages/xl-docx-exporter/src/docx/template/word/styles.xml +++ b/packages/xl-docx-exporter/src/docx/template/word/styles.xml @@ -983,8 +983,8 @@ - - + + \ No newline at end of file diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx index e7158793d3..4abe64202d 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx @@ -98,11 +98,6 @@ - - - codeBlock not implemented - - @@ -586,6 +581,27 @@ + + + + const helloWorld = (message) => { + + + console.log("Hello World", message); + + + }; + + + diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx index fc93b8c7d0..7f90f62314 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx @@ -103,11 +103,6 @@ - - - codeBlock not implemented - - @@ -591,6 +586,27 @@ + + + + const helloWorld = (message) => { + + + console.log("Hello World", message); + + + }; + + + diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx index 400422d28d..d555090c4c 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx @@ -2,6 +2,7 @@ import { BlockMapping, DefaultBlockSchema, DefaultProps, + StyledText, } from "@blocknote/core"; import { Image, Link, Path, Svg, Text, View } from "@react-pdf/renderer"; import { @@ -69,7 +70,35 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< ); }, codeBlock: (block) => { - return {block.type + " not implemented"}; + const textContent = (block.content as StyledText[])[0]?.text || ""; + const lines = textContent.split("\n").map((line, index) => { + const indent = line.match(/^\s*/)?.[0].length || 0; + + return ( + + {line.trimStart() || <> } + + ); + }); + + return ( + + {lines} + + ); }, audio: (block, exporter) => { return ( diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/styles.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/styles.tsx index c2e5ed98b4..40ee152c3f 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/styles.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/styles.tsx @@ -62,7 +62,7 @@ export const pdfStyleMappingForDefaultSchema: StyleMapping< return {}; } return { - fontFamily: "Courier", + fontFamily: "GeistMono", }; }, }; diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx index 969f1ce759..06cbf69319 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx @@ -216,6 +216,14 @@ export class PDFExporter< fontWeight: "bold", }); + font = await loadFontDataUrl( + await import("@shared/assets/fonts/GeistMono-Regular.ttf") + ); + Font.register({ + family: "GeistMono", + src: font, + }); + this.fontsRegistered = true; } diff --git a/shared/assets/fonts/GeistMono-Regular.ttf b/shared/assets/fonts/GeistMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..8c09dd96a8457ce53638f8e4dab2790d9d309a8d GIT binary patch literal 116248 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

+!hS5k8EEh#F4x12sl1fL+ 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 YHc_b|yQfv#^I*3oV+Zq?+gil`14 { + console.log("Hello World", message); +};`, + }, ] ); From 8461552ca540c31ae8077f691ce3b5098ca3d8d0 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Fri, 17 Jan 2025 13:18:33 +0100 Subject: [PATCH 19/30] feat: Improved collaboration cursor UX (#1374) * Improved collaboration cursor UX * - Made label show on selection changes too - Cleaned up code - Added visibility dot to cursors - Added animations * Implemented PR feedback & revised animations/hide delays * Added editor option flag and sorted CSS --- packages/core/src/editor/BlockNoteEditor.ts | 7 + .../core/src/editor/BlockNoteExtensions.ts | 121 +++++++++++++++--- packages/core/src/editor/editor.css | 27 +++- 3 files changed, 134 insertions(+), 21 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index bbfc6388a1..a7b78cdaba 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -196,6 +196,13 @@ export type BlockNoteEditorOptions< * Optional function to customize how cursors of users are rendered */ renderCursor?: (user: any) => HTMLElement; + /** + * Optional flag to set when the user label should be shown with the default + * collaboration cursor. Setting to "always" will always show the label, + * while "activity" will only show the label when the user moves the cursor + * or types. Defaults to "activity". + */ + showCursorLabels?: "always" | "activity"; }; /** diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index ac826dce26..e87ba832c7 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -1,4 +1,5 @@ import { AnyExtension, Extension, extensions } from "@tiptap/core"; +import { Awareness } from "y-protocols/awareness"; import type { BlockNoteEditor, BlockNoteExtension } from "./BlockNoteEditor.js"; @@ -64,6 +65,7 @@ type ExtensionOptions< }; provider: any; renderCursor?: (user: any) => HTMLElement; + showCursorLabels?: "always" | "activity"; }; disableExtensions: string[] | undefined; setIdAttribute?: boolean; @@ -250,25 +252,114 @@ const getTipTapExtensions = < fragment: opts.collaboration.fragment, }) ); - if (opts.collaboration.provider?.awareness) { - const defaultRender = (user: { color: string; name: string }) => { - const cursor = document.createElement("span"); - cursor.classList.add("collaboration-cursor__caret"); - cursor.setAttribute("style", `border-color: ${user.color}`); + const awareness = opts.collaboration?.provider.awareness as Awareness; + + if (awareness) { + const cursors = new Map< + number, + { element: HTMLElement; hideTimeout: NodeJS.Timeout | undefined } + >(); + + if (opts.collaboration.showCursorLabels !== "always") { + awareness.on( + "change", + ({ + updated, + }: { + added: Array; + updated: Array; + removed: Array; + }) => { + for (const clientID of updated) { + const cursor = cursors.get(clientID); + + if (cursor) { + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + } + + cursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + } + } + } + ); + } + + const createCursor = (clientID: number, name: string, color: string) => { + const cursorElement = document.createElement("span"); + + cursorElement.classList.add("collaboration-cursor__caret"); + cursorElement.setAttribute("style", `border-color: ${color}`); + if (opts.collaboration?.showCursorLabels !== "always") { + cursorElement.setAttribute("data-active", ""); + } + + const labelElement = document.createElement("span"); + + labelElement.classList.add("collaboration-cursor__label"); + labelElement.setAttribute("style", `background-color: ${color}`); + labelElement.insertBefore(document.createTextNode(name), null); + + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + cursorElement.insertBefore(labelElement, null); + cursorElement.insertBefore(document.createTextNode("\u2060"), null); // Non-breaking space + + cursors.set(clientID, { + element: cursorElement, + hideTimeout: undefined, + }); + + if (opts.collaboration?.showCursorLabels !== "always") { + cursorElement.addEventListener("mouseenter", () => { + const cursor = cursors.get(clientID)!; + cursor.element.setAttribute("data-active", ""); + + if (cursor.hideTimeout) { + clearTimeout(cursor.hideTimeout); + cursors.set(clientID, { + element: cursor.element, + hideTimeout: undefined, + }); + } + }); + + cursorElement.addEventListener("mouseleave", () => { + const cursor = cursors.get(clientID)!; + + cursors.set(clientID, { + element: cursor.element, + hideTimeout: setTimeout(() => { + cursor.element.removeAttribute("data-active"); + }, 2000), + }); + }); + } + + return cursors.get(clientID)!; + }; + + const defaultRender = (user: { color: string; name: string }) => { + const clientState = [...awareness.getStates().entries()].find( + (state) => state[1].user === user + ); - const label = document.createElement("span"); + if (!clientState) { + throw new Error("Could not find client state for user"); + } - label.classList.add("collaboration-cursor__label"); - label.setAttribute("style", `background-color: ${user.color}`); - label.insertBefore(document.createTextNode(user.name), null); + const clientID = clientState[0]; - const nonbreakingSpace1 = document.createTextNode("\u2060"); - const nonbreakingSpace2 = document.createTextNode("\u2060"); - cursor.insertBefore(nonbreakingSpace1, null); - cursor.insertBefore(label, null); - cursor.insertBefore(nonbreakingSpace2, null); - return cursor; + return ( + cursors.get(clientID) || createCursor(clientID, user.name, user.color) + ).element; }; tiptapExtensions.push( CollaborationCursor.configure({ diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index ba9dffb39d..0cc476e9b1 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -83,7 +83,6 @@ Tippy popups that are appended to document.body directly border-right: 1px solid #0d0d0d; margin-left: -1px; margin-right: -1px; - pointer-events: none; position: relative; word-break: normal; white-space: nowrap !important; @@ -92,17 +91,33 @@ Tippy popups that are appended to document.body directly /* Render the username above the caret */ .collaboration-cursor__label { border-radius: 3px 3px 3px 0; - color: #0d0d0d; font-size: 12px; font-style: normal; font-weight: 600; - left: -1px; line-height: normal; - padding: 0.1rem 0.3rem; + left: -1px; + overflow: hidden; position: absolute; - top: -1.4em; - user-select: none; white-space: nowrap; + + color: transparent; + max-height: 4px; + max-width: 4px; + padding: 0; + transform: translateY(3px); + + transition: all 0.2s; + +} + +.collaboration-cursor__caret[data-active] > .collaboration-cursor__label { + color: #0d0d0d; + max-height: 1.1rem; + max-width: 20rem; + padding: 0.1rem 0.3rem; + transform: translateY(-14px); + + transition: all 0.2s; } /* .tableWrapper { From 800a1f0bdf32c605dedfa937abd5003901ace857 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Mon, 20 Jan 2025 11:09:48 +0100 Subject: [PATCH 20/30] feat: Page break (#1372) * feat: Page break block * feat: Add page break support to PDF & DOCX exporters * feat: Add CSS to support Page break when printing * Small UX changes * Removed page break from default schema * Fixed unit tests * Fixed lint * Implemented PR feedback --------- Co-authored-by: matthewlipski --- examples/01-basic/04-all-blocks/App.tsx | 70 +- .../05-converting-blocks-to-pdf/App.tsx | 36 +- .../06-converting-blocks-to-docx/App.tsx | 35 +- package-lock.json | 39 + .../pageBreak/basic/external.html | 1 + .../pageBreak/basic/internal.html | 1 + .../__snapshots__/pageBreak/basic/markdown.md | 0 .../nodeConversions.test.ts.snap | 16 + .../src/api/testUtil/cases/defaultSchema.ts | 16 +- .../PageBreakBlockContent.ts | 49 + .../getPageBreakSlashMenuItems.ts | 45 + .../blocks/PageBreakBlockContent/schema.ts | 40 + packages/core/src/editor/Block.css | 15 +- .../SuggestionMenu/DefaultSuggestionItem.ts | 2 +- packages/core/src/i18n/locales/ar.ts | 6 + packages/core/src/i18n/locales/de.ts | 6 + packages/core/src/i18n/locales/en.ts | 6 + packages/core/src/i18n/locales/es.ts | 6 + packages/core/src/i18n/locales/fr.ts | 6 + packages/core/src/i18n/locales/hr.ts | 126 +- packages/core/src/i18n/locales/is.ts | 6 + packages/core/src/i18n/locales/ja.ts | 6 + packages/core/src/i18n/locales/ko.ts | 6 + packages/core/src/i18n/locales/nl.ts | 6 + packages/core/src/i18n/locales/pl.ts | 6 + packages/core/src/i18n/locales/pt.ts | 6 + packages/core/src/i18n/locales/ru.ts | 6 + packages/core/src/i18n/locales/vi.ts | 6 + packages/core/src/i18n/locales/zh.ts | 6 + packages/core/src/index.ts | 3 + .../getPageBreakReactSlashMenuItems.tsx | 29 + packages/react/src/editor/styles.css | 4 + packages/react/src/index.ts | 1 + .../src/docx/__snapshots__/basic/document.xml | 5 + .../src/docx/defaultSchema/blocks.ts | 9 +- .../src/docx/docxExporter.test.ts | 10 +- packages/xl-pdf-exporter/package.json | 1 + .../src/pdf/__snapshots__/example.jsx | 1491 +++++++++------- .../exampleWithHeaderAndFooter.jsx | 1524 ++++++++++------- .../src/pdf/defaultSchema/blocks.tsx | 6 +- .../src/pdf/pdfExporter.test.tsx | 19 +- .../xl-pdf-exporter/src/pdf/pdfExporter.tsx | 10 +- shared/testDocument.ts | 7 +- 43 files changed, 2374 insertions(+), 1320 deletions(-) create mode 100644 packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/external.html create mode 100644 packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/internal.html create mode 100644 packages/core/src/api/exporters/markdown/__snapshots__/pageBreak/basic/markdown.md create mode 100644 packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts create mode 100644 packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts create mode 100644 packages/core/src/blocks/PageBreakBlockContent/schema.ts create mode 100644 packages/react/src/blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.tsx diff --git a/examples/01-basic/04-all-blocks/App.tsx b/examples/01-basic/04-all-blocks/App.tsx index 6a34751b75..935e96d93e 100644 --- a/examples/01-basic/04-all-blocks/App.tsx +++ b/examples/01-basic/04-all-blocks/App.tsx @@ -1,34 +1,11 @@ -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"; +import { useCreateBlockNote } from "@blocknote/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", @@ -51,35 +28,6 @@ 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", @@ -189,20 +137,6 @@ export default function App() { ], }); - const slashMenuItems = useMemo(() => { - return combineByGroup( - getDefaultReactSlashMenuItems(editor), - getMultiColumnSlashMenuItems(editor) - ); - }, [editor]); - // Renders the editor instance using a React component. - return ( - - filterSuggestionItems(slashMenuItems, query)} - /> - - ); + return ; } diff --git a/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx b/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx index 63ba2109aa..269cc81c9a 100644 --- a/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx +++ b/examples/05-interoperability/05-converting-blocks-to-pdf/App.tsx @@ -1,13 +1,25 @@ +import { + BlockNoteSchema, + combineByGroup, + filterSuggestionItems, + withPageBreak, +} from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; -import { useCreateBlockNote } from "@blocknote/react"; +import { + getDefaultReactSlashMenuItems, + getPageBreakReactSlashMenuItems, + SuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; import { PDFExporter, pdfDefaultSchemaMappings, } from "@blocknote/xl-pdf-exporter"; import { PDFViewer } from "@react-pdf/renderer"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; + import "./styles.css"; export default function App() { @@ -16,6 +28,7 @@ export default function App() { // Creates a new editor instance with some initial content. const editor = useCreateBlockNote({ + schema: withPageBreak(BlockNoteSchema.create()), initialContent: [ { type: "paragraph", @@ -180,6 +193,9 @@ export default function App() { ], }, }, + { + type: "pageBreak", + }, { type: "file", }, @@ -308,11 +324,25 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const slashMenuItems = useMemo(() => { + return combineByGroup( + getDefaultReactSlashMenuItems(editor), + getPageBreakReactSlashMenuItems(editor) + ); + }, [editor]); + // Renders the editor instance, and its contents as HTML below. return (
- + + + filterSuggestionItems(slashMenuItems, query) + } + /> +
{pdfDocument} diff --git a/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx b/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx index a456d84f94..94bb8d8c91 100644 --- a/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx +++ b/examples/05-interoperability/06-converting-blocks-to-docx/App.tsx @@ -1,16 +1,30 @@ +import { + BlockNoteSchema, + combineByGroup, + filterSuggestionItems, + withPageBreak, +} from "@blocknote/core"; import "@blocknote/core/fonts/inter.css"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; -import { useCreateBlockNote } from "@blocknote/react"; +import { + getDefaultReactSlashMenuItems, + getPageBreakReactSlashMenuItems, + SuggestionMenuController, + useCreateBlockNote, +} from "@blocknote/react"; import { DOCXExporter, docxDefaultSchemaMappings, } from "@blocknote/xl-docx-exporter"; +import { useMemo } from "react"; + import "./styles.css"; export default function App() { // Creates a new editor instance with some initial content. const editor = useCreateBlockNote({ + schema: withPageBreak(BlockNoteSchema.create()), initialContent: [ { type: "paragraph", @@ -175,6 +189,9 @@ export default function App() { ], }, }, + { + type: "pageBreak", + }, { type: "file", }, @@ -305,6 +322,13 @@ export default function App() { window.URL.revokeObjectURL(link.href); }; + const slashMenuItems = useMemo(() => { + return combineByGroup( + getDefaultReactSlashMenuItems(editor), + getPageBreakReactSlashMenuItems(editor) + ); + }, [editor]); + // Renders the editor instance, and its contents as HTML below. return (
@@ -314,7 +338,14 @@ export default function App() {
- + + + filterSuggestionItems(slashMenuItems, query) + } + /> +
); diff --git a/package-lock.json b/package-lock.json index 2957c32180..51479e96d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3322,6 +3322,13 @@ "node": ">=6.9.0" } }, + "node_modules/@base2/pretty-print-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@base2/pretty-print-object/-/pretty-print-object-1.0.1.tgz", + "integrity": "sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/@blocknote/ariakit": { "resolved": "packages/ariakit", "link": true @@ -24963,6 +24970,29 @@ "react": "^18.3.1" } }, + "node_modules/react-element-to-jsx-string": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz", + "integrity": "sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@base2/pretty-print-object": "1.0.1", + "is-plain-object": "5.0.0", + "react-is": "18.1.0" + }, + "peerDependencies": { + "react": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0", + "react-dom": "^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0" + } + }, + "node_modules/react-element-to-jsx-string/node_modules/react-is": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.1.0.tgz", + "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", + "dev": true, + "license": "MIT" + }, "node_modules/react-github-btn": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/react-github-btn/-/react-github-btn-1.4.0.tgz", @@ -30544,6 +30574,10 @@ "vite-plugin-eslint": "^1.8.1", "vitest": "^2.0.3", "xml-formatter": "^3.6.3" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "packages/xl-docx-exporter/node_modules/buffer": { @@ -30595,6 +30629,10 @@ "vite": "^5.3.4", "vite-plugin-eslint": "^1.8.1", "vitest": "^2.0.3" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || >= 19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc" } }, "packages/xl-multi-column/node_modules/cssstyle": { @@ -30825,6 +30863,7 @@ "jest-image-snapshot": "^6.4.0", "pdf-to-img": "^4.2.0", "prettier": "^2.7.1", + "react-element-to-jsx-string": "^15.0.0", "rollup-plugin-webpack-stats": "^0.2.2", "typescript": "^5.0.4", "vite": "^5.3.4", diff --git a/packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/external.html b/packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/external.html new file mode 100644 index 0000000000..ba65bb0620 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/external.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/internal.html b/packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/internal.html new file mode 100644 index 0000000000..f4303a5ad1 --- /dev/null +++ b/packages/core/src/api/exporters/html/__snapshots__/pageBreak/basic/internal.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/packages/core/src/api/exporters/markdown/__snapshots__/pageBreak/basic/markdown.md b/packages/core/src/api/exporters/markdown/__snapshots__/pageBreak/basic/markdown.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap index 4b6cebdb80..ef16974ad5 100644 --- a/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap +++ b/packages/core/src/api/nodeConversions/__snapshots__/nodeConversions.test.ts.snap @@ -1605,6 +1605,22 @@ exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert } `; +exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert pageBreak/basic to/from prosemirror 1`] = ` +{ + "attrs": { + "backgroundColor": "default", + "id": "1", + "textColor": "default", + }, + "content": [ + { + "type": "pageBreak", + }, + ], + "type": "blockContainer", +} +`; + exports[`Test BlockNote-Prosemirror conversion > Case: default schema > Convert paragraph/basic to/from prosemirror 1`] = ` { "attrs": { diff --git a/packages/core/src/api/testUtil/cases/defaultSchema.ts b/packages/core/src/api/testUtil/cases/defaultSchema.ts index 2510f4e040..dfdff62784 100644 --- a/packages/core/src/api/testUtil/cases/defaultSchema.ts +++ b/packages/core/src/api/testUtil/cases/defaultSchema.ts @@ -6,16 +6,22 @@ import { DefaultInlineContentSchema, DefaultStyleSchema, } from "../../../blocks/defaultBlocks.js"; +import { + pageBreakSchema, + withPageBreak, +} from "../../../blocks/PageBreakBlockContent/schema.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { BlockNoteSchema } from "../../../editor/BlockNoteSchema.js"; export const defaultSchemaTestCases: EditorTestCases< - DefaultBlockSchema, + DefaultBlockSchema & typeof pageBreakSchema.blockSchema, DefaultInlineContentSchema, DefaultStyleSchema > = { name: "default schema", createEditor: () => { return BlockNoteEditor.create({ + schema: withPageBreak(BlockNoteSchema.create()), uploadFile: uploadToTmpFilesDotOrg_DEV_ONLY, }); }, @@ -202,6 +208,14 @@ export const defaultSchemaTestCases: EditorTestCases< }, ], }, + { + name: "pageBreak/basic", + blocks: [ + { + type: "pageBreak", + }, + ], + }, { name: "file/button", blocks: [ diff --git a/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts new file mode 100644 index 0000000000..e1e72ab59c --- /dev/null +++ b/packages/core/src/blocks/PageBreakBlockContent/PageBreakBlockContent.ts @@ -0,0 +1,49 @@ +import { + createBlockSpec, + CustomBlockConfig, + Props, +} from "../../schema/index.js"; + +export const pageBreakConfig = { + type: "pageBreak" as const, + propSchema: {}, + content: "none", + isFileBlock: false, + isSelectable: false, +} satisfies CustomBlockConfig; +export const pageBreakRender = () => { + const pageBreak = document.createElement("div"); + + pageBreak.className = "bn-page-break"; + pageBreak.setAttribute("data-page-break", ""); + + return { + dom: pageBreak, + }; +}; +export const pageBreakParse = ( + element: HTMLElement +): Partial> | undefined => { + if (element.tagName === "DIV" && element.hasAttribute("data-page-break")) { + return { + type: "pageBreak", + }; + } + + return undefined; +}; +export const pageBreakToExternalHTML = () => { + const pageBreak = document.createElement("div"); + + pageBreak.setAttribute("data-page-break", ""); + + return { + dom: pageBreak, + }; +}; + +export const PageBreak = createBlockSpec(pageBreakConfig, { + render: pageBreakRender, + parse: pageBreakParse, + toExternalHTML: pageBreakToExternalHTML, +}); diff --git a/packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts b/packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts new file mode 100644 index 0000000000..6c7b10ad80 --- /dev/null +++ b/packages/core/src/blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.ts @@ -0,0 +1,45 @@ +import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { DefaultSuggestionItem } from "../../extensions/SuggestionMenu/DefaultSuggestionItem.js"; +import { insertOrUpdateBlock } from "../../extensions/SuggestionMenu/getDefaultSlashMenuItems.js"; +import { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; +import { pageBreakSchema } from "./schema.js"; + +export function checkPageBreakBlocksInSchema< + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor +): editor is BlockNoteEditor { + return ( + "pageBreak" in editor.schema.blockSchema && + editor.schema.blockSchema["pageBreak"] === + pageBreakSchema.blockSchema["pageBreak"] + ); +} + +export function getPageBreakSlashMenuItems< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>(editor: BlockNoteEditor) { + const items: (Omit & { key: "page_break" })[] = + []; + + if (checkPageBreakBlocksInSchema(editor)) { + items.push({ + ...editor.dictionary.slash_menu.page_break, + onItemClick: () => { + insertOrUpdateBlock(editor, { + type: "pageBreak", + }); + }, + key: "page_break", + }); + } + + return items; +} diff --git a/packages/core/src/blocks/PageBreakBlockContent/schema.ts b/packages/core/src/blocks/PageBreakBlockContent/schema.ts new file mode 100644 index 0000000000..c9b64a59c5 --- /dev/null +++ b/packages/core/src/blocks/PageBreakBlockContent/schema.ts @@ -0,0 +1,40 @@ +import { BlockNoteSchema } from "../../editor/BlockNoteSchema.js"; +import { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; +import { PageBreak } from "./PageBreakBlockContent.js"; + +export const pageBreakSchema = BlockNoteSchema.create({ + blockSpecs: { + pageBreak: PageBreak, + }, +}); + +/** + * Adds page break support to the given schema. + */ +export const withPageBreak = < + B extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + schema: BlockNoteSchema +) => { + return BlockNoteSchema.create({ + blockSpecs: { + ...schema.blockSpecs, + ...pageBreakSchema.blockSpecs, + }, + inlineContentSpecs: schema.inlineContentSpecs, + styleSpecs: schema.styleSpecs, + }) as any as BlockNoteSchema< + // typescript needs some help here + B & { + pageBreak: typeof PageBreak.config; + }, + I, + S + >; +}; diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 54849c2b6c..284cbeb071 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -308,6 +308,19 @@ NESTED BLOCKS transition-delay: 0.1s; } +/* PAGE BREAK */ +.bn-block-content[data-content-type="pageBreak"] > div { + width: 100%; + height: 0; + border-top: dotted rgb(125, 121, 122) 4px; +} + +@media print { + .bn-block-content[data-content-type="pageBreak"] > div { + page-break-after: always; + } +} + /* FILES */ /* Element that wraps content for all file blocks */ @@ -336,7 +349,7 @@ NESTED BLOCKS .bn-editor[contenteditable="true"] [data-file-block] .bn-add-file-button:hover, [data-file-block] .bn-file-name-with-icon:hover, -.ProseMirror-selectednode .bn-file-name-with-icon{ +.ProseMirror-selectednode .bn-file-name-with-icon { background-color: rgb(225, 225, 225); } diff --git a/packages/core/src/extensions/SuggestionMenu/DefaultSuggestionItem.ts b/packages/core/src/extensions/SuggestionMenu/DefaultSuggestionItem.ts index f2e11d5ee8..6789b76616 100644 --- a/packages/core/src/extensions/SuggestionMenu/DefaultSuggestionItem.ts +++ b/packages/core/src/extensions/SuggestionMenu/DefaultSuggestionItem.ts @@ -1,7 +1,7 @@ import type { Dictionary } from "../../i18n/dictionary.js"; export type DefaultSuggestionItem = { - key: keyof Dictionary["slash_menu"]; + key: keyof Omit; title: string; onItemClick: () => void; subtext?: string; diff --git a/packages/core/src/i18n/locales/ar.ts b/packages/core/src/i18n/locales/ar.ts index b18a202599..c4c2ad8a8e 100644 --- a/packages/core/src/i18n/locales/ar.ts +++ b/packages/core/src/i18n/locales/ar.ts @@ -58,6 +58,12 @@ export const ar: Dictionary = { aliases: ["كود", "مسبق"], group: "الكتل الأساسية", }, + page_break: { + title: "فاصل الصفحة", + subtext: "فاصل الصفحة", + aliases: ["page", "break", "separator", "فاصل", "الصفحة"], + group: "الكتل الأساسية", + }, table: { title: "جدول", subtext: "يستخدم للجداول", diff --git a/packages/core/src/i18n/locales/de.ts b/packages/core/src/i18n/locales/de.ts index 6331d722e7..f4b9e5f5f0 100644 --- a/packages/core/src/i18n/locales/de.ts +++ b/packages/core/src/i18n/locales/de.ts @@ -56,6 +56,12 @@ export const de = { aliases: ["code", "pre"], group: "Grundlegende blöcke", }, + page_break: { + title: "Seitenumbruch", + subtext: "Seitentrenner", + aliases: ["page", "break", "separator", "seitenumbruch", "trenner"], + group: "Grundlegende Blöcke", + }, table: { title: "Tabelle", subtext: "Tabelle mit editierbaren Zellen", diff --git a/packages/core/src/i18n/locales/en.ts b/packages/core/src/i18n/locales/en.ts index e23d0e2638..442f7a3895 100644 --- a/packages/core/src/i18n/locales/en.ts +++ b/packages/core/src/i18n/locales/en.ts @@ -56,6 +56,12 @@ export const en = { aliases: ["code", "pre"], group: "Basic blocks", }, + page_break: { + title: "Page Break", + subtext: "Page separator", + aliases: ["page", "break", "separator"], + group: "Basic blocks", + }, table: { title: "Table", subtext: "Table with editable cells", diff --git a/packages/core/src/i18n/locales/es.ts b/packages/core/src/i18n/locales/es.ts index 2ffc5587fc..0a8e11ac9f 100644 --- a/packages/core/src/i18n/locales/es.ts +++ b/packages/core/src/i18n/locales/es.ts @@ -55,6 +55,12 @@ export const es = { aliases: ["code", "pre"], group: "Bloques básicos", }, + page_break: { + title: "Salto de página", + subtext: "Separador de página", + aliases: ["page", "break", "separator", "salto", "separador"], + group: "Bloques básicos", + }, table: { title: "Tabla", subtext: "Tabla con celdas editables", diff --git a/packages/core/src/i18n/locales/fr.ts b/packages/core/src/i18n/locales/fr.ts index 2dc28972a9..9f694dadb6 100644 --- a/packages/core/src/i18n/locales/fr.ts +++ b/packages/core/src/i18n/locales/fr.ts @@ -71,6 +71,12 @@ export const fr: Dictionary = { aliases: ["code", "pre"], group: "Blocs de base", }, + page_break: { + title: "Saut de page", + subtext: "Séparateur de page", + aliases: ["page", "break", "separator", "saut", "séparateur"], + group: "Blocs de base", + }, table: { title: "Tableau", subtext: "Utilisé pour les tableaux", diff --git a/packages/core/src/i18n/locales/hr.ts b/packages/core/src/i18n/locales/hr.ts index 5b65fc5468..b7604e6ff8 100644 --- a/packages/core/src/i18n/locales/hr.ts +++ b/packages/core/src/i18n/locales/hr.ts @@ -21,26 +21,38 @@ export const hr = { numbered_list: { title: "Numerirani popis", subtext: "Popis s numeriranim stavkama", - aliases: ["poredaniPopis", "stavkaPopisa", "popis", "numeriraniPopis", "numerirani popis"], + aliases: [ + "poredaniPopis", + "stavkaPopisa", + "popis", + "numeriraniPopis", + "numerirani popis", + ], group: "Osnovni blokovi", }, bullet_list: { title: "Popis s oznakama", subtext: "Popis s grafičkim oznakama", - aliases: ["neporedaniPopis", "stavkaPopisa", "popis", "popisSOznakama", "popis s oznakama"], + aliases: [ + "neporedaniPopis", + "stavkaPopisa", + "popis", + "popisSOznakama", + "popis s oznakama", + ], group: "Osnovni blokovi", }, check_list: { title: "Check lista", subtext: "Popis s kućicama za označavanje", aliases: [ - "neporedaniPopis", - "stavkaPopisa", - "popis", - "popisZaProvjeru", - "check lista", - "označeni popis", - "kućicaZaOznačavanje", + "neporedaniPopis", + "stavkaPopisa", + "popis", + "popisZaProvjeru", + "check lista", + "označeni popis", + "kućicaZaOznačavanje", ], group: "Osnovni blokovi", }, @@ -56,17 +68,23 @@ export const hr = { aliases: ["tablica"], group: "Napredno", }, + page_break: { + title: "Prijelom stranice", + subtext: "Razdjelnik stranice", + aliases: ["page", "break", "separator", "prijelom", "razdjelnik"], + group: "Osnovni blokovi", + }, image: { title: "Slika", subtext: "Slika s podesivom veličinom i natpisom", aliases: [ - "slika", - "učitavanjeSlike", - "učitaj", - "img", - "fotografija", - "medij", - "url", + "slika", + "učitavanjeSlike", + "učitaj", + "img", + "fotografija", + "medij", + "url", ], group: "Mediji", }, @@ -74,13 +92,13 @@ export const hr = { title: "Video", subtext: "Video s podesivom veličinom i natpisom", aliases: [ - "video", - "učitavanjeVidea", - "učitaj", - "mp4", - "film", - "medij", - "url", + "video", + "učitavanjeVidea", + "učitaj", + "mp4", + "film", + "medij", + "url", ], group: "Mediji", }, @@ -88,13 +106,13 @@ export const hr = { title: "Audio", subtext: "Audio s natpisom", aliases: [ - "audio", - "učitavanjeAudija", - "učitaj", - "mp3", - "zvuk", - "medij", - "url", + "audio", + "učitavanjeAudija", + "učitaj", + "mp3", + "zvuk", + "medij", + "url", ], group: "Mediji", }, @@ -157,16 +175,16 @@ export const hr = { text_title: "Tekst", background_title: "Pozadina", colors: { - default: "Zadano", - gray: "Siva", - brown: "Smeđa", - red: "Crvena", - orange: "Narančasta", - yellow: "Žuta", - green: "Zelena", - blue: "Plava", - purple: "Ljubičasta", - pink: "Ružičasta", + default: "Zadano", + gray: "Siva", + brown: "Smeđa", + red: "Crvena", + orange: "Narančasta", + yellow: "Žuta", + green: "Zelena", + blue: "Plava", + purple: "Ljubičasta", + pink: "Ružičasta", }, }, @@ -240,29 +258,29 @@ export const hr = { file: "Ukloni datoteku", } as Record, }, - file_preview_toggle: { + file_preview_toggle: { tooltip: "Prikaži/sakrij pregled", - }, - nest: { + }, + nest: { tooltip: "Ugnijezdi blok", secondary_tooltip: "Tab", - }, - unnest: { + }, + unnest: { tooltip: "Razgnijezdi blok", secondary_tooltip: "Shift+Tab", - }, - align_left: { + }, + align_left: { tooltip: "Poravnaj tekst lijevo", - }, - align_center: { + }, + align_center: { tooltip: "Poravnaj tekst po sredini", - }, - align_right: { + }, + align_right: { tooltip: "Poravnaj tekst desno", - }, - align_justify: { + }, + align_justify: { tooltip: "Poravnaj tekst obostrano", - }, + }, }, file_panel: { upload: { diff --git a/packages/core/src/i18n/locales/is.ts b/packages/core/src/i18n/locales/is.ts index 07fcb3bfb7..978ba481ba 100644 --- a/packages/core/src/i18n/locales/is.ts +++ b/packages/core/src/i18n/locales/is.ts @@ -50,6 +50,12 @@ export const is: Dictionary = { aliases: ["kóði", "pre"], group: "Grunnblokkar", }, + page_break: { + title: "Síðubrot", + subtext: "Síðuskil", + aliases: ["page", "break", "separator", "síðubrot", "síðuskil"], + group: "Grunnblokkir", + }, table: { title: "Tafla", subtext: "Notað fyrir töflur", diff --git a/packages/core/src/i18n/locales/ja.ts b/packages/core/src/i18n/locales/ja.ts index 2172289b0b..a4c2ff04a4 100644 --- a/packages/core/src/i18n/locales/ja.ts +++ b/packages/core/src/i18n/locales/ja.ts @@ -74,6 +74,12 @@ export const ja: Dictionary = { aliases: ["code", "pre", "コード", "コードブロック"], group: "基本ブロック", }, + page_break: { + title: "改ページ", + subtext: "ページ区切り", + aliases: ["page", "break", "separator", "改ページ", "区切り"], + group: "基本ブロック", + }, table: { title: "表", subtext: "表に使用", diff --git a/packages/core/src/i18n/locales/ko.ts b/packages/core/src/i18n/locales/ko.ts index cc0144481d..0bf8e24a1f 100644 --- a/packages/core/src/i18n/locales/ko.ts +++ b/packages/core/src/i18n/locales/ko.ts @@ -58,6 +58,12 @@ export const ko: Dictionary = { aliases: ["code", "pre"], group: "기본 블록", }, + page_break: { + title: "페이지 나누기", + subtext: "페이지 구분자", + aliases: ["page", "break", "separator", "페이지", "구분자"], + group: "기본 블록", + }, table: { title: "표", subtext: "간단한 표를 추가합니다.", diff --git a/packages/core/src/i18n/locales/nl.ts b/packages/core/src/i18n/locales/nl.ts index 152e640001..25c3426d2f 100644 --- a/packages/core/src/i18n/locales/nl.ts +++ b/packages/core/src/i18n/locales/nl.ts @@ -50,6 +50,12 @@ export const nl: Dictionary = { aliases: ["code", "pre"], group: "Basisblokken", }, + page_break: { + title: "Pagina-einde", + subtext: "Paginascheiding", + aliases: ["page", "break", "separator", "pagina", "scheiding"], + group: "Basisblokken", + }, table: { title: "Tabel", subtext: "Gebruikt voor tabellen", diff --git a/packages/core/src/i18n/locales/pl.ts b/packages/core/src/i18n/locales/pl.ts index fdf53fe591..befa6ace7c 100644 --- a/packages/core/src/i18n/locales/pl.ts +++ b/packages/core/src/i18n/locales/pl.ts @@ -50,6 +50,12 @@ export const pl: Dictionary = { aliases: ["kod", "pre"], group: "Podstawowe bloki", }, + page_break: { + title: "Podział strony", + subtext: "Separator strony", + aliases: ["page", "break", "separator", "podział", "separator"], + group: "Podstawowe bloki", + }, table: { title: "Tabela", subtext: "Używana do tworzenia tabel", diff --git a/packages/core/src/i18n/locales/pt.ts b/packages/core/src/i18n/locales/pt.ts index 0f435b8955..6dd7dab3ca 100644 --- a/packages/core/src/i18n/locales/pt.ts +++ b/packages/core/src/i18n/locales/pt.ts @@ -57,6 +57,12 @@ export const pt: Dictionary = { aliases: ["codigo", "pre"], group: "Blocos básicos", }, + page_break: { + title: "Quebra de página", + subtext: "Separador de página", + aliases: ["page", "break", "separator", "quebra", "separador"], + group: "Blocos básicos", + }, table: { title: "Tabela", subtext: "Usado para tabelas", diff --git a/packages/core/src/i18n/locales/ru.ts b/packages/core/src/i18n/locales/ru.ts index 54afd7aeb2..60309d5d90 100644 --- a/packages/core/src/i18n/locales/ru.ts +++ b/packages/core/src/i18n/locales/ru.ts @@ -75,6 +75,12 @@ export const ru: Dictionary = { aliases: ["code", "pre", "блок кода"], group: "Базовые блоки", }, + page_break: { + title: "Разрыв страницы", + subtext: "Разделитель страницы", + aliases: ["page", "break", "separator", "разрыв", "разделитель"], + group: "Основные блоки", + }, table: { title: "Таблица", subtext: "Используется для таблиц", diff --git a/packages/core/src/i18n/locales/vi.ts b/packages/core/src/i18n/locales/vi.ts index 507146013f..eb1dbf31a2 100644 --- a/packages/core/src/i18n/locales/vi.ts +++ b/packages/core/src/i18n/locales/vi.ts @@ -57,6 +57,12 @@ export const vi: Dictionary = { aliases: ["code", "pre"], group: "Khối cơ bản", }, + page_break: { + title: "Ngắt trang", + subtext: "Phân cách trang", + aliases: ["page", "break", "separator", "ngắt", "phân cách"], + group: "Khối cơ bản", + }, table: { title: "Bảng", subtext: "Sử dụng để tạo bảng", diff --git a/packages/core/src/i18n/locales/zh.ts b/packages/core/src/i18n/locales/zh.ts index 955b9ca098..cfb32c155f 100644 --- a/packages/core/src/i18n/locales/zh.ts +++ b/packages/core/src/i18n/locales/zh.ts @@ -75,6 +75,12 @@ export const zh: Dictionary = { aliases: ["code", "pre", "代码", "预格式"], group: "基础", }, + page_break: { + title: "分页符", + subtext: "页面分隔符", + aliases: ["page", "break", "separator", "分页", "分隔符"], + group: "基础", + }, table: { title: "表格", subtext: "使用表格", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7b0115ae47..072d375bc5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,6 +6,9 @@ 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/PageBreakBlockContent/PageBreakBlockContent.js"; +export * from "./blocks/PageBreakBlockContent/getPageBreakSlashMenuItems.js"; +export * from "./blocks/PageBreakBlockContent/schema.js"; export * from "./blocks/FileBlockContent/FileBlockContent.js"; export * from "./blocks/FileBlockContent/helpers/parse/parseEmbedElement.js"; export * from "./blocks/FileBlockContent/helpers/parse/parseFigureElement.js"; diff --git a/packages/react/src/blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.tsx b/packages/react/src/blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.tsx new file mode 100644 index 0000000000..00fc1c274a --- /dev/null +++ b/packages/react/src/blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.tsx @@ -0,0 +1,29 @@ +import { + BlockNoteEditor, + BlockSchema, + getPageBreakSlashMenuItems, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { DefaultReactSuggestionItem } from "../../components/SuggestionMenu/types.js"; +import { TbPageBreak } from "react-icons/tb"; + +const icons = { + page_break: TbPageBreak, +}; + +export function getPageBreakReactSlashMenuItems< + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + editor: BlockNoteEditor +): (Omit & { key: "page_break" })[] { + return getPageBreakSlashMenuItems(editor).map((item) => { + const Icon = icons[item.key]; + return { + ...item, + icon: , + }; + }); +} diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 2ac35161d8..1f1b3c25ad 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -238,3 +238,7 @@ .bn-side-menu[data-url="false"] { height: 54px; } + +.bn-side-menu[data-block-type="pageBreak"] { + transform: translateY(-10px); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 579f6bad1e..dd8215d700 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -15,6 +15,7 @@ export * from "./blocks/FileBlockContent/helpers/toExternalHTML/FigureWithCaptio export * from "./blocks/FileBlockContent/helpers/toExternalHTML/LinkWithCaption.js"; export * from "./blocks/FileBlockContent/useResolveUrl.js"; export * from "./blocks/ImageBlockContent/ImageBlockContent.js"; +export * from "./blocks/PageBreakBlockContent/getPageBreakReactSlashMenuItems.js"; export * from "./blocks/VideoBlockContent/VideoBlockContent.js"; export * from "./components/FormattingToolbar/DefaultButtons/BasicTextStyleButton.js"; diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml index ef14525b4f..ccd1ff2147 100644 --- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml +++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml @@ -112,6 +112,11 @@ justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + + + + + diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts index 5d6a9f7e32..1b457f3c35 100644 --- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts +++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts @@ -3,6 +3,7 @@ import { COLORS_DEFAULT, DefaultBlockSchema, DefaultProps, + pageBreakSchema, StyledText, UnreachableCaseError, } from "@blocknote/core"; @@ -12,6 +13,7 @@ import { ExternalHyperlink, IParagraphOptions, ImageRun, + PageBreak, Paragraph, ParagraphChild, ShadingType, @@ -56,7 +58,7 @@ function blockPropsToStyles( }; } export const docxBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema, + DefaultBlockSchema & typeof pageBreakSchema.blockSchema, any, any, | Promise @@ -152,6 +154,11 @@ export const docxBlockMappingForDefaultSchema: BlockMapping< ], }); }, + pageBreak: () => { + return new Paragraph({ + children: [new PageBreak()], + }); + }, image: async (block, exporter) => { const blob = await exporter.resolveFile(block.props.url); const { width, height } = await getImageDimensions(blob); diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index 42f7449006..ec2d3d9eef 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -1,4 +1,4 @@ -import { BlockNoteSchema } from "@blocknote/core"; +import { BlockNoteSchema, defaultBlockSpecs, PageBreak } from "@blocknote/core"; import { testDocument } from "@shared/testDocument.js"; import AdmZip from "adm-zip"; import { Packer, Paragraph, TextRun } from "docx"; @@ -10,7 +10,9 @@ import { DOCXExporter } from "./docxExporter.js"; describe("exporter", () => { it("should export a document", { timeout: 10000 }, async () => { const exporter = new DOCXExporter( - BlockNoteSchema.create(), + BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, pageBreak: PageBreak }, + }), docxDefaultSchemaMappings ); const doc = await exporter.toDocxJsDocument(testDocument); @@ -33,7 +35,9 @@ describe("exporter", () => { { timeout: 10000 }, async () => { const exporter = new DOCXExporter( - BlockNoteSchema.create(), + BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, pageBreak: PageBreak }, + }), docxDefaultSchemaMappings ); diff --git a/packages/xl-pdf-exporter/package.json b/packages/xl-pdf-exporter/package.json index 4a50afbfea..4e0b753e30 100644 --- a/packages/xl-pdf-exporter/package.json +++ b/packages/xl-pdf-exporter/package.json @@ -61,6 +61,7 @@ "@types/jsdom": "^21.1.7", "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", + "react-element-to-jsx-string": "^15.0.0", "eslint": "^8.10.0", "jest-image-snapshot": "^6.4.0", "pdf-to-img": "^4.2.0", diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx index 4abe64202d..74a79c1821 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/example.jsx @@ -1,608 +1,935 @@ -
- - - - - - - Welcome to this - - - demo 🙌! - - - - - + + + + + - - - Hello World nested - - - - + - - - - Hello World double nested - - - - - - - - - This paragraph has a background color - - - - - - - Paragraph - - - - - + + + + + - - Heading - - - - - + + Hello World nested + + + + - - Heading right - - - - - - - justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - + + + + Hello World double nested + + + + + + + + + + + + + This paragraph has a background color + + + + + + + + + Paragraph + + + + + + + - - - - • - - - - - Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - - + Heading + + + + + + + - + Heading right + + + + + + + + + justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + + + + + + + + + + + Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + + + + + + + - - - - • - - - - + + + Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - - + + + + + + - - - - • - - - - + + + Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - - + + + + + + - - - - 1. - - - - + + + Numbered List Item 1 - - - - - + + + + + + - - - - 2. - - - - + + + Numbered List Item 2 - - - - - + + + + - - - - - 1. - - - - - Numbered List Item Nested 1 - - - - - - - - - 2. - - - - - Numbered List Item Nested 2 - - - - - - - - - 3. - - - - - Numbered List Item Nested funky right - - - - - - - - - 4. - - - - - Numbered List Item Nested funky center - - - - - - - - - - - 1. - - - - - Numbered List Item - - - - - - - - + - - - - - - Check List Item - - - - - - - - - - Wide Cell - - - - - Table Cell - - - - - Table Cell - - - - - - - Wide Cell - - - - - Table Cell - - - - - Table Cell - - - - - - - Wide Cell - - - - - Table Cell - - - - - Table Cell - - - - - - - - - + + + Numbered List Item Nested 1 + + + + + + + - - - - - Open file - - - - - - - - - - From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg - - - - - - - - - - - - + + + Numbered List Item Nested 2 + + + + + + + - - - - - Open video file - - - - - From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm - - - - - - - + + + Numbered List Item Nested funky right + + + + + + + - - - - - Open audio file - - - - + + + Numbered List Item Nested funky center + + + + + + + + + + + + + + + Numbered List Item + + + + + + + + }> + + + Check List Item + + + + + + + + + + + + + + + - From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3 - - - - - - - - - + + + + Open file + + + + + + + + + + + + From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg + + + + + + + + + + + + + + + + - - - - - - audio.mp3 - - - - + + + Open video file + + + + + From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm + + + + + + + + + - Audio file caption - - - - - - + + + + Open audio file + + + + + From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3 + + + + + + + + + + + + + + - Inline Content: - - - - + + + + audio.mp3 + + + + + Audio file caption + + + + + + + + + Inline Content: + + + + + + + + + Styled Text + + + {' '} + + + + Link + + + + + + + +
+ + + + + - - - Styled Text - - - - - - - Link - - - - - - - - - - Table Cell 1 - - - - - Table Cell 2 - - - - - Table Cell 3 - - - - - - - Table Cell 4 - - - - - Table Cell Bold 5 - - - - - Table Cell 6 - - - - - - - Table Cell 7 - - - - - Table Cell 8 - - - - - Table Cell 9 - - - - - - - - - const helloWorld = (message) => { - - - console.log("Hello World", message); - - - }; - - - - - - - \ No newline at end of file + {`const helloWorld = (message) => {`} + + + console.log("Hello World", message); + + + {`};`} + + + + + + \ No newline at end of file diff --git a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx index 7f90f62314..3c6a68a2f7 100644 --- a/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx +++ b/packages/xl-pdf-exporter/src/pdf/__snapshots__/exampleWithHeaderAndFooter.jsx @@ -1,618 +1,960 @@ -
- - + + - - - Header - - - - - - - Welcome to this - - - demo 🙌! - - - - - + Header + + + + + + - - - Hello World nested - - - - + - - - - Hello World double nested - - - - - - - - - This paragraph has a background color - - - - - - - Paragraph - - - - - + + + + + - - Heading - - - - - + + Hello World nested + + + + - - Heading right - - - - - - - justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - + + + + Hello World double nested + + + + + + + + + + + + + This paragraph has a background color + + + + + + + + + Paragraph + + + + + + + - - - - • - - - - - Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - - + Heading + + + + + + + - + Heading right + + + + + + + + + justified paragraph. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + + + + + + + + + + + Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + + + + + + + - - - - • - - - - + + + Bullet List Item. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - - + + + + + + - - - - • - - - - + + + Bullet List Item right. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. - - - - - + + + + + + - - - - 1. - - - - + + + Numbered List Item 1 - - - - - + + + + + + - - - - 2. - - - - + + + Numbered List Item 2 - - - - - + + + + - - - - - 1. - - - - - Numbered List Item Nested 1 - - - - - - - - - 2. - - - - - Numbered List Item Nested 2 - - - - - - - - - 3. - - - - - Numbered List Item Nested funky right - - - - - - - - - 4. - - - - - Numbered List Item Nested funky center - - - - - - - - - - - 1. - - - - - Numbered List Item - - - - - - - - + - - - - - - Check List Item - - - - - - - - - - Wide Cell - - - - - Table Cell - - - - - Table Cell - - - - - - - Wide Cell - - - - - Table Cell - - - - - Table Cell - - - - - - - Wide Cell - - - - - Table Cell - - - - - Table Cell - - - - - - - - - + + + Numbered List Item Nested 1 + + + + + + + - - - - - Open file - - - - - - - - - - From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg - - - - - - - - - - - - + + + Numbered List Item Nested 2 + + + + + + + - - - - - Open video file - - - - - From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm - - - - - - - + + + Numbered List Item Nested funky right + + + + + + + - - - - - Open audio file - - - - + + + Numbered List Item Nested funky center + + + + + + + + + + + + + + + Numbered List Item + + + + + + + + }> + + + Check List Item + + + + + + + +
+ + + + + + + - From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3 - - - - - - - - - + + + + Open file + + + + + + + + + + + + From https://interactive-examples.mdn.mozilla.net/media/cc0-images/grapefruit-slice-332-332.jpg + + + + + + + + + + + + + + + + - - - - - - audio.mp3 - - - - + + + Open video file + + + + + From https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm + + + + + + + + + - Audio file caption - - - - - - + + + + Open audio file + + + + + From https://interactive-examples.mdn.mozilla.net/media/cc0-audio/t-rex-roar.mp3 + + + + + + + + + + + + + + - Inline Content: - - - - + + + + audio.mp3 + + + + + Audio file caption + + + + + + + + + Inline Content: + + + + + + + + + Styled Text + + + {' '} + + + + Link + + + + + + + +
+ + + + + - - - Styled Text - - - - - - - Link - - - - - - - - - - Table Cell 1 - - - - - Table Cell 2 - - - - - Table Cell 3 - - - - - - - Table Cell 4 - - - - - Table Cell Bold 5 - - - - - Table Cell 6 - - - - - - - Table Cell 7 - - - - - Table Cell 8 - - - - - Table Cell 9 - - - - - - - - - const helloWorld = (message) => { - - - console.log("Hello World", message); - - - }; - - - - - - - Footer - - - - - \ No newline at end of file + {`const helloWorld = (message) => {`} + + + console.log("Hello World", message); + + + {`};`} + + + + + + + Footer + + + + \ No newline at end of file diff --git a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx index d555090c4c..9e75c2d273 100644 --- a/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx +++ b/packages/xl-pdf-exporter/src/pdf/defaultSchema/blocks.tsx @@ -2,6 +2,7 @@ import { BlockMapping, DefaultBlockSchema, DefaultProps, + pageBreakSchema, StyledText, } from "@blocknote/core"; import { Image, Link, Path, Svg, Text, View } from "@react-pdf/renderer"; @@ -17,7 +18,7 @@ const PIXELS_PER_POINT = 0.75; const FONT_SIZE = 16; export const pdfBlockMappingForDefaultSchema: BlockMapping< - DefaultBlockSchema, + DefaultBlockSchema & typeof pageBreakSchema.blockSchema, any, any, React.ReactElement, @@ -100,6 +101,9 @@ export const pdfBlockMappingForDefaultSchema: BlockMapping< ); }, + pageBreak: () => { + return ; + }, audio: (block, exporter) => { return ( diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx index e21fed3ab7..0d7df11349 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.test.tsx @@ -6,10 +6,11 @@ import { defaultBlockSpecs, defaultInlineContentSpecs, defaultStyleSpecs, + PageBreak, } from "@blocknote/core"; import { Text } from "@react-pdf/renderer"; import { testDocument } from "@shared/testDocument.js"; -import { prettyDOM, render } from "@testing-library/react"; +import reactElementToJSXString from "react-element-to-jsx-string"; import { describe, expect, it } from "vitest"; import { pdfDefaultSchemaMappings } from "./defaultSchema/index.js"; import { PDFExporter } from "./pdfExporter.js"; @@ -26,6 +27,7 @@ describe("exporter", () => { const schema = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, + pageBreak: PageBreak, extraBlock: createBlockSpec( { content: "none", @@ -155,13 +157,15 @@ describe("exporter", () => { it("should export a document", async () => { const exporter = new PDFExporter( - BlockNoteSchema.create(), + BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, pageBreak: PageBreak }, + }), pdfDefaultSchemaMappings ); const transformed = await exporter.toReactPDFDocument(testDocument); - const view = render(transformed); - const str = prettyDOM(view.container, undefined, { highlight: false }); + const str = reactElementToJSXString(transformed); + expect(str).toMatchFileSnapshot("__snapshots__/example.jsx"); // would be nice to compare pdf images, but currently doesn't work on mac os (due to node canvas installation issue) @@ -186,7 +190,9 @@ describe("exporter", () => { it("should export a document with header and footer", async () => { const exporter = new PDFExporter( - BlockNoteSchema.create(), + BlockNoteSchema.create({ + blockSpecs: { ...defaultBlockSpecs, pageBreak: PageBreak }, + }), pdfDefaultSchemaMappings ); @@ -194,8 +200,7 @@ describe("exporter", () => { header: Header, footer: Footer, }); - const view = render(transformed); - const str = prettyDOM(view.container, undefined, { highlight: false }); + const str = reactElementToJSXString(transformed); expect(str).toMatchFileSnapshot( "__snapshots__/exampleWithHeaderAndFooter.jsx" ); diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx index 06cbf69319..aa4b03d4a9 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx @@ -63,7 +63,6 @@ export class PDFExporter< fontSize: FONT_SIZE * PIXELS_PER_POINT, // pixels lineHeight: 1.5, }, - section: {}, block: {}, blockChildren: {}, header: {}, @@ -145,6 +144,11 @@ export class PDFExporter< numberedListIndex ); // TODO: any + if (b.type === "pageBreak") { + ret.push(self); + continue; + } + const style = this.blocknoteDefaultPropsToReactPDFStyle(b.props as any); ret.push( <> @@ -255,9 +259,7 @@ export class PDFExporter< {options.header} )} - - {await this.transformBlocks(blocks)} - + {await this.transformBlocks(blocks)} {options.footer && ( Date: Mon, 20 Jan 2025 23:57:37 +0100 Subject: [PATCH 21/30] fix: Minor collaboration cursor & docs changes (#1381) * Small collaboration cursor fixes * Slight edit to dot and label height offsets * Small fix * - Updated collaboration docs with `showCursorLabels` option - Changed `all-blocks` example to `default-blocks` --- docs/next.config.mjs | 5 +++++ docs/pages/docs/advanced/real-time-collaboration.mdx | 3 +++ docs/pages/docs/editor-basics/default-schema.mdx | 2 +- examples/01-basic/04-all-blocks/.bnexample.json | 9 --------- examples/01-basic/04-default-blocks/.bnexample.json | 6 ++++++ .../{04-all-blocks => 04-default-blocks}/App.tsx | 0 .../{04-all-blocks => 04-default-blocks}/README.md | 0 .../{04-all-blocks => 04-default-blocks}/index.html | 0 .../{04-all-blocks => 04-default-blocks}/main.tsx | 0 .../{04-all-blocks => 04-default-blocks}/package.json | 5 ++--- .../tsconfig.json | 0 .../vite.config.ts | 0 packages/core/src/editor/BlockNoteExtensions.ts | 2 +- packages/core/src/editor/editor.css | 4 ++-- playground/src/examples.gen.tsx | 11 ++++------- 15 files changed, 24 insertions(+), 23 deletions(-) delete mode 100644 examples/01-basic/04-all-blocks/.bnexample.json create mode 100644 examples/01-basic/04-default-blocks/.bnexample.json rename examples/01-basic/{04-all-blocks => 04-default-blocks}/App.tsx (100%) rename examples/01-basic/{04-all-blocks => 04-default-blocks}/README.md (100%) rename examples/01-basic/{04-all-blocks => 04-default-blocks}/index.html (100%) rename examples/01-basic/{04-all-blocks => 04-default-blocks}/main.tsx (100%) rename examples/01-basic/{04-all-blocks => 04-default-blocks}/package.json (87%) rename examples/01-basic/{04-all-blocks => 04-default-blocks}/tsconfig.json (100%) rename examples/01-basic/{04-all-blocks => 04-default-blocks}/vite.config.ts (100%) diff --git a/docs/next.config.mjs b/docs/next.config.mjs index e3de90a927..092a11b5f0 100644 --- a/docs/next.config.mjs +++ b/docs/next.config.mjs @@ -118,6 +118,11 @@ const nextConfig = withAnalyzer( destination: "/docs/advanced/vanilla-js", permanent: true, }, + { + source: "/examples/basic/all-blocks", + destination: "/examples/basic/default-blocks", + permanent: true, + }, ], experimental: { externalDir: true, diff --git a/docs/pages/docs/advanced/real-time-collaboration.mdx b/docs/pages/docs/advanced/real-time-collaboration.mdx index 2a9b5dc6cd..7d8059800a 100644 --- a/docs/pages/docs/advanced/real-time-collaboration.mdx +++ b/docs/pages/docs/advanced/real-time-collaboration.mdx @@ -38,6 +38,9 @@ const editor = useCreateBlockNote({ name: "My Username", color: "#ff0000", }, + // When to show user labels on the collaboration cursor. Set by default to + // "activity" (show when the cursor moves), but can also be set to "always". + showCursorLabels: "activity" }, // ... }); diff --git a/docs/pages/docs/editor-basics/default-schema.mdx b/docs/pages/docs/editor-basics/default-schema.mdx index 9787910c1a..cd1c09966e 100644 --- a/docs/pages/docs/editor-basics/default-schema.mdx +++ b/docs/pages/docs/editor-basics/default-schema.mdx @@ -14,7 +14,7 @@ BlockNote supports a number of built-in blocks, inline content types, and styles The demo below showcases each of BlockNote's built-in block and inline content types: - + ## Default Blocks diff --git a/examples/01-basic/04-all-blocks/.bnexample.json b/examples/01-basic/04-all-blocks/.bnexample.json deleted file mode 100644 index d38bcda2ee..0000000000 --- a/examples/01-basic/04-all-blocks/.bnexample.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "playground": true, - "docs": true, - "author": "yousefed", - "tags": ["Basic", "Blocks", "Inline Content"], - "dependencies": { - "@blocknote/xl-multi-column": "latest" - } -} diff --git a/examples/01-basic/04-default-blocks/.bnexample.json b/examples/01-basic/04-default-blocks/.bnexample.json new file mode 100644 index 0000000000..6b15063ef2 --- /dev/null +++ b/examples/01-basic/04-default-blocks/.bnexample.json @@ -0,0 +1,6 @@ +{ + "playground": true, + "docs": true, + "author": "yousefed", + "tags": ["Basic", "Blocks", "Inline Content"] +} diff --git a/examples/01-basic/04-all-blocks/App.tsx b/examples/01-basic/04-default-blocks/App.tsx similarity index 100% rename from examples/01-basic/04-all-blocks/App.tsx rename to examples/01-basic/04-default-blocks/App.tsx diff --git a/examples/01-basic/04-all-blocks/README.md b/examples/01-basic/04-default-blocks/README.md similarity index 100% rename from examples/01-basic/04-all-blocks/README.md rename to examples/01-basic/04-default-blocks/README.md diff --git a/examples/01-basic/04-all-blocks/index.html b/examples/01-basic/04-default-blocks/index.html similarity index 100% rename from examples/01-basic/04-all-blocks/index.html rename to examples/01-basic/04-default-blocks/index.html diff --git a/examples/01-basic/04-all-blocks/main.tsx b/examples/01-basic/04-default-blocks/main.tsx similarity index 100% rename from examples/01-basic/04-all-blocks/main.tsx rename to examples/01-basic/04-default-blocks/main.tsx diff --git a/examples/01-basic/04-all-blocks/package.json b/examples/01-basic/04-default-blocks/package.json similarity index 87% rename from examples/01-basic/04-all-blocks/package.json rename to examples/01-basic/04-default-blocks/package.json index 352d433f1f..d59814a01a 100644 --- a/examples/01-basic/04-all-blocks/package.json +++ b/examples/01-basic/04-default-blocks/package.json @@ -1,5 +1,5 @@ { - "name": "@blocknote/example-all-blocks", + "name": "@blocknote/example-default-blocks", "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", "private": true, "version": "0.12.4", @@ -17,8 +17,7 @@ "@blocknote/mantine": "latest", "@blocknote/shadcn": "latest", "react": "^18.3.1", - "react-dom": "^18.3.1", - "@blocknote/xl-multi-column": "latest" + "react-dom": "^18.3.1" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/examples/01-basic/04-all-blocks/tsconfig.json b/examples/01-basic/04-default-blocks/tsconfig.json similarity index 100% rename from examples/01-basic/04-all-blocks/tsconfig.json rename to examples/01-basic/04-default-blocks/tsconfig.json diff --git a/examples/01-basic/04-all-blocks/vite.config.ts b/examples/01-basic/04-default-blocks/vite.config.ts similarity index 100% rename from examples/01-basic/04-all-blocks/vite.config.ts rename to examples/01-basic/04-default-blocks/vite.config.ts diff --git a/packages/core/src/editor/BlockNoteExtensions.ts b/packages/core/src/editor/BlockNoteExtensions.ts index e87ba832c7..56cbd5067e 100644 --- a/packages/core/src/editor/BlockNoteExtensions.ts +++ b/packages/core/src/editor/BlockNoteExtensions.ts @@ -298,7 +298,7 @@ const getTipTapExtensions = < cursorElement.classList.add("collaboration-cursor__caret"); cursorElement.setAttribute("style", `border-color: ${color}`); - if (opts.collaboration?.showCursorLabels !== "always") { + if (opts.collaboration?.showCursorLabels === "always") { cursorElement.setAttribute("data-active", ""); } diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 0cc476e9b1..a53d90521b 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -104,7 +104,7 @@ Tippy popups that are appended to document.body directly max-height: 4px; max-width: 4px; padding: 0; - transform: translateY(3px); + top: 0; transition: all 0.2s; @@ -115,7 +115,7 @@ Tippy popups that are appended to document.body directly max-height: 1.1rem; max-width: 20rem; padding: 0.1rem 0.3rem; - transform: translateY(-14px); + top: -14px; transition: all 0.2s; } diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index 35a8320c98..1aa17a29dc 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -65,9 +65,9 @@ } }, { - "projectSlug": "all-blocks", - "fullSlug": "basic/all-blocks", - "pathFromRoot": "examples/01-basic/04-all-blocks", + "projectSlug": "default-blocks", + "fullSlug": "basic/default-blocks", + "pathFromRoot": "examples/01-basic/04-default-blocks", "config": { "playground": true, "docs": true, @@ -76,10 +76,7 @@ "Basic", "Blocks", "Inline Content" - ], - "dependencies": { - "@blocknote/xl-multi-column": "latest" - } as any + ] }, "title": "Default Schema Showcase", "group": { From 99385e0e3586992407061f900fc07cf3813b1fc5 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:25:42 +0100 Subject: [PATCH 22/30] fix: Adjust page break styles (#1383) --- packages/core/src/editor/Block.css | 3 ++- packages/react/src/editor/styles.css | 4 ---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/core/src/editor/Block.css b/packages/core/src/editor/Block.css index 284cbeb071..67db567702 100644 --- a/packages/core/src/editor/Block.css +++ b/packages/core/src/editor/Block.css @@ -312,7 +312,8 @@ NESTED BLOCKS .bn-block-content[data-content-type="pageBreak"] > div { width: 100%; height: 0; - border-top: dotted rgb(125, 121, 122) 4px; + border-top: dotted rgb(125, 121, 122) 2px; + margin-block: 11px; } @media print { diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css index 1f1b3c25ad..2ac35161d8 100644 --- a/packages/react/src/editor/styles.css +++ b/packages/react/src/editor/styles.css @@ -238,7 +238,3 @@ .bn-side-menu[data-url="false"] { height: 54px; } - -.bn-side-menu[data-block-type="pageBreak"] { - transform: translateY(-10px); -} From 7ce125a154e0b9a157b2fd37233f988731c63ef8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 21 Jan 2025 10:51:29 +0100 Subject: [PATCH 23/30] v0.23.0 --- docs/package.json | 14 ++-- lerna.json | 2 +- package-lock.json | 96 +++++++++++++------------- packages/ariakit/package.json | 6 +- packages/core/package.json | 2 +- packages/dev-scripts/package.json | 2 +- packages/mantine/package.json | 6 +- packages/react/package.json | 4 +- packages/server-util/package.json | 6 +- packages/shadcn/package.json | 6 +- packages/xl-docx-exporter/package.json | 4 +- packages/xl-multi-column/package.json | 6 +- packages/xl-pdf-exporter/package.json | 8 +-- playground/package.json | 16 ++--- shared/package.json | 4 +- tests/package.json | 12 ++-- 16 files changed, 97 insertions(+), 97 deletions(-) diff --git a/docs/package.json b/docs/package.json index f0d51b45a1..5e80b51c76 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "0.22.0", + "version": "0.23.0", "private": true, "scripts": { "dev": "next dev", @@ -9,12 +9,12 @@ "lint": "next lint" }, "dependencies": { - "@blocknote/ariakit": "^0.22.0", - "@blocknote/core": "^0.22.0", - "@blocknote/mantine": "^0.22.0", - "@blocknote/react": "^0.22.0", - "@blocknote/shadcn": "^0.22.0", - "@blocknote/xl-multi-column": "^0.22.0", + "@blocknote/ariakit": "^0.23.0", + "@blocknote/core": "^0.23.0", + "@blocknote/mantine": "^0.23.0", + "@blocknote/react": "^0.23.0", + "@blocknote/shadcn": "^0.23.0", + "@blocknote/xl-multi-column": "^0.23.0", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.4", "@mantine/core": "^7.10.1", diff --git a/lerna.json b/lerna.json index e5d282f591..6fe199c78b 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "useWorkspaces": true, - "version": "0.22.0" + "version": "0.23.0" } diff --git a/package-lock.json b/package-lock.json index 51479e96d7..1a46bc20b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,14 +25,14 @@ } }, "docs": { - "version": "0.22.0", - "dependencies": { - "@blocknote/ariakit": "^0.22.0", - "@blocknote/core": "^0.22.0", - "@blocknote/mantine": "^0.22.0", - "@blocknote/react": "^0.22.0", - "@blocknote/shadcn": "^0.22.0", - "@blocknote/xl-multi-column": "^0.22.0", + "version": "0.23.0", + "dependencies": { + "@blocknote/ariakit": "^0.23.0", + "@blocknote/core": "^0.23.0", + "@blocknote/mantine": "^0.23.0", + "@blocknote/react": "^0.23.0", + "@blocknote/shadcn": "^0.23.0", + "@blocknote/xl-multi-column": "^0.23.0", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.4", "@mantine/core": "^7.10.1", @@ -30143,12 +30143,12 @@ }, "packages/ariakit": { "name": "@blocknote/ariakit", - "version": "0.22.0", + "version": "0.23.0", "license": "MPL-2.0", "dependencies": { "@ariakit/react": "^0.4.3", - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0" + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0" }, "devDependencies": { "@types/react": "^18.0.25", @@ -30187,7 +30187,7 @@ }, "packages/core": { "name": "@blocknote/core", - "version": "0.22.0", + "version": "0.23.0", "license": "MPL-2.0", "dependencies": { "@emoji-mart/data": "^1.2.1", @@ -30344,7 +30344,7 @@ }, "packages/dev-scripts": { "name": "@blocknote/dev-scripts", - "version": "0.22.0", + "version": "0.23.0", "license": "MPL-2.0", "devDependencies": { "@types/react": "^18.0.25", @@ -30361,11 +30361,11 @@ }, "packages/mantine": { "name": "@blocknote/mantine", - "version": "0.22.0", + "version": "0.23.0", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@mantine/core": "^7.10.1", "@mantine/hooks": "^7.10.1", "@mantine/utils": "^6.0.21", @@ -30409,10 +30409,10 @@ }, "packages/react": { "name": "@blocknote/react", - "version": "0.22.0", + "version": "0.23.0", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.22.0", + "@blocknote/core": "^0.23.0", "@floating-ui/react": "^0.26.4", "@tiptap/core": "^2.7.1", "@tiptap/react": "^2.7.1", @@ -30460,11 +30460,11 @@ }, "packages/server-util": { "name": "@blocknote/server-util", - "version": "0.22.0", + "version": "0.23.0", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@tiptap/core": "^2.7.1", "@tiptap/pm": "^2.7.1", "jsdom": "^25.0.1", @@ -30491,11 +30491,11 @@ }, "packages/shadcn": { "name": "@blocknote/shadcn", - "version": "0.22.0", + "version": "0.23.0", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@hookform/resolvers": "^3.6.0", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", @@ -30555,10 +30555,10 @@ }, "packages/xl-docx-exporter": { "name": "@blocknote/xl-docx-exporter", - "version": "0.22.0", + "version": "0.23.0", "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { - "@blocknote/core": "^0.22.0", + "@blocknote/core": "^0.23.0", "buffer": "^6.0.3", "docx": "^9.0.2", "sharp": "^0.33.5" @@ -30605,11 +30605,11 @@ }, "packages/xl-multi-column": { "name": "@blocknote/xl-multi-column", - "version": "0.22.0", + "version": "0.23.0", "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@tiptap/core": "^2.7.1", "prosemirror-model": "^1.23.0", "prosemirror-state": "^1.4.3", @@ -30844,11 +30844,11 @@ }, "packages/xl-pdf-exporter": { "name": "@blocknote/xl-pdf-exporter", - "version": "0.22.0", + "version": "0.23.0", "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@react-pdf/renderer": "^4.0.0", "buffer": "^6.0.3", "docx": "^9.0.2" @@ -30932,17 +30932,17 @@ }, "playground": { "name": "@blocknote/example-editor", - "version": "0.22.0", + "version": "0.23.0", "dependencies": { "@aws-sdk/client-s3": "^3.609.0", "@aws-sdk/s3-request-presigner": "^3.609.0", - "@blocknote/ariakit": "^0.22.0", - "@blocknote/core": "^0.22.0", - "@blocknote/mantine": "^0.22.0", - "@blocknote/react": "^0.22.0", - "@blocknote/server-util": "^0.22.0", - "@blocknote/shadcn": "^0.22.0", - "@blocknote/xl-multi-column": "^0.22.0", + "@blocknote/ariakit": "^0.23.0", + "@blocknote/core": "^0.23.0", + "@blocknote/mantine": "^0.23.0", + "@blocknote/react": "^0.23.0", + "@blocknote/server-util": "^0.23.0", + "@blocknote/shadcn": "^0.23.0", + "@blocknote/xl-multi-column": "^0.23.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@liveblocks/client": "^1.10.0", @@ -31000,9 +31000,9 @@ }, "shared": { "name": "@blocknote/shared", - "version": "0.22.0", + "version": "0.23.0", "dependencies": { - "@blocknote/core": "^0.22.0" + "@blocknote/core": "^0.23.0" }, "devDependencies": { "typescript": "^5.3.3" @@ -31010,17 +31010,17 @@ }, "tests": { "name": "@blocknote/tests", - "version": "0.22.0", + "version": "0.23.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "@blocknote/ariakit": "^0.22.0", - "@blocknote/core": "^0.22.0", - "@blocknote/mantine": "^0.22.0", - "@blocknote/react": "^0.22.0", - "@blocknote/shadcn": "^0.22.0", + "@blocknote/ariakit": "^0.23.0", + "@blocknote/core": "^0.23.0", + "@blocknote/mantine": "^0.23.0", + "@blocknote/react": "^0.23.0", + "@blocknote/shadcn": "^0.23.0", "@playwright/experimental-ct-react": "^1.49.1", "@playwright/test": "^1.49.1", "eslint": "^8.10.0", diff --git a/packages/ariakit/package.json b/packages/ariakit/package.json index ef43192759..7134ff5506 100644 --- a/packages/ariakit/package.json +++ b/packages/ariakit/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -51,8 +51,8 @@ }, "dependencies": { "@ariakit/react": "^0.4.3", - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0" + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/packages/core/package.json b/packages/core/package.json index 51a0f104cd..feacca4a0b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", diff --git a/packages/dev-scripts/package.json b/packages/dev-scripts/package.json index 66478c08df..2412e043ad 100644 --- a/packages/dev-scripts/package.json +++ b/packages/dev-scripts/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/TypeCellOS/BlockNote", "private": true, "license": "MPL-2.0", - "version": "0.22.0", + "version": "0.23.0", "description": "", "type": "module", "scripts": { diff --git a/packages/mantine/package.json b/packages/mantine/package.json index 25e62ac976..933c672469 100644 --- a/packages/mantine/package.json +++ b/packages/mantine/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -50,8 +50,8 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@mantine/core": "^7.10.1", "@mantine/hooks": "^7.10.1", "@mantine/utils": "^6.0.21", diff --git a/packages/react/package.json b/packages/react/package.json index b6ca70bc2f..1f73404a2c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -52,7 +52,7 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.22.0", + "@blocknote/core": "^0.23.0", "@floating-ui/react": "^0.26.4", "@tiptap/core": "^2.7.1", "@tiptap/react": "^2.7.1", diff --git a/packages/server-util/package.json b/packages/server-util/package.json index 6644da658f..53c83c7834 100644 --- a/packages/server-util/package.json +++ b/packages/server-util/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -50,8 +50,8 @@ "test-watch": "vitest watch" }, "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@tiptap/core": "^2.7.1", "@tiptap/pm": "^2.7.1", "jsdom": "^25.0.1", diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index 99bb90d01f..d557764f5b 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -50,8 +50,8 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@hookform/resolvers": "^3.6.0", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", diff --git a/packages/xl-docx-exporter/package.json b/packages/xl-docx-exporter/package.json index ce73995654..e809015e30 100644 --- a/packages/xl-docx-exporter/package.json +++ b/packages/xl-docx-exporter/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/TypeCellOS/BlockNote", "private": false, "license": "AGPL-3.0 OR PROPRIETARY", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -50,7 +50,7 @@ "email": "email dev" }, "dependencies": { - "@blocknote/core": "^0.22.0", + "@blocknote/core": "^0.23.0", "buffer": "^6.0.3", "docx": "^9.0.2", "sharp": "^0.33.5" diff --git a/packages/xl-multi-column/package.json b/packages/xl-multi-column/package.json index f0a7d607c3..12abf41d47 100644 --- a/packages/xl-multi-column/package.json +++ b/packages/xl-multi-column/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/TypeCellOS/BlockNote", "private": false, "license": "AGPL-3.0 OR PROPRIETARY", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -45,8 +45,8 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@tiptap/core": "^2.7.1", "prosemirror-model": "^1.23.0", "prosemirror-state": "^1.4.3", diff --git a/packages/xl-pdf-exporter/package.json b/packages/xl-pdf-exporter/package.json index 4e0b753e30..3b5a4ea973 100644 --- a/packages/xl-pdf-exporter/package.json +++ b/packages/xl-pdf-exporter/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/TypeCellOS/BlockNote", "private": false, "license": "AGPL-3.0 OR PROPRIETARY", - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -49,8 +49,8 @@ "email": "email dev" }, "dependencies": { - "@blocknote/core": "^0.22.0", - "@blocknote/react": "^0.22.0", + "@blocknote/core": "^0.23.0", + "@blocknote/react": "^0.23.0", "@react-pdf/renderer": "^4.0.0", "buffer": "^6.0.3", "docx": "^9.0.2" @@ -61,11 +61,11 @@ "@types/jsdom": "^21.1.7", "@types/react": "^18.0.25", "@types/react-dom": "^18.0.9", - "react-element-to-jsx-string": "^15.0.0", "eslint": "^8.10.0", "jest-image-snapshot": "^6.4.0", "pdf-to-img": "^4.2.0", "prettier": "^2.7.1", + "react-element-to-jsx-string": "^15.0.0", "rollup-plugin-webpack-stats": "^0.2.2", "typescript": "^5.0.4", "vite": "^5.3.4", diff --git a/playground/package.json b/playground/package.json index 1f74c82345..c09b0bf5a6 100644 --- a/playground/package.json +++ b/playground/package.json @@ -1,7 +1,7 @@ { "name": "@blocknote/example-editor", "private": true, - "version": "0.22.0", + "version": "0.23.0", "scripts": { "dev": "vite --host", "build": "tsc && vite build", @@ -12,13 +12,13 @@ "dependencies": { "@aws-sdk/client-s3": "^3.609.0", "@aws-sdk/s3-request-presigner": "^3.609.0", - "@blocknote/ariakit": "^0.22.0", - "@blocknote/core": "^0.22.0", - "@blocknote/mantine": "^0.22.0", - "@blocknote/react": "^0.22.0", - "@blocknote/server-util": "^0.22.0", - "@blocknote/shadcn": "^0.22.0", - "@blocknote/xl-multi-column": "^0.22.0", + "@blocknote/ariakit": "^0.23.0", + "@blocknote/core": "^0.23.0", + "@blocknote/mantine": "^0.23.0", + "@blocknote/react": "^0.23.0", + "@blocknote/server-util": "^0.23.0", + "@blocknote/shadcn": "^0.23.0", + "@blocknote/xl-multi-column": "^0.23.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@liveblocks/client": "^1.10.0", diff --git a/shared/package.json b/shared/package.json index 70a1d1f3d6..208e6af209 100644 --- a/shared/package.json +++ b/shared/package.json @@ -2,7 +2,7 @@ "name": "@blocknote/shared", "homepage": "https://github.com/TypeCellOS/BlockNote", "private": true, - "version": "0.22.0", + "version": "0.23.0", "files": [ "dist", "types", @@ -13,7 +13,7 @@ "clean": "tsc --build --clean" }, "dependencies": { - "@blocknote/core": "^0.22.0" + "@blocknote/core": "^0.23.0" }, "devDependencies": { "typescript": "^5.3.3" diff --git a/tests/package.json b/tests/package.json index b63f7001c3..f9b10eb791 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,7 +1,7 @@ { "name": "@blocknote/tests", "private": true, - "version": "0.22.0", + "version": "0.23.0", "scripts": { "build": "tsc", "lint": "eslint src --max-warnings 0", @@ -17,11 +17,11 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@blocknote/ariakit": "^0.22.0", - "@blocknote/core": "^0.22.0", - "@blocknote/mantine": "^0.22.0", - "@blocknote/react": "^0.22.0", - "@blocknote/shadcn": "^0.22.0", + "@blocknote/ariakit": "^0.23.0", + "@blocknote/core": "^0.23.0", + "@blocknote/mantine": "^0.23.0", + "@blocknote/react": "^0.23.0", + "@blocknote/shadcn": "^0.23.0", "@playwright/experimental-ct-react": "^1.49.1", "@playwright/test": "^1.49.1", "eslint": "^8.10.0", From 060708d9cfae06cbd074e34fb5f00df4c1a0d9e5 Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Fri, 24 Jan 2025 12:22:42 +0100 Subject: [PATCH 24/30] Big comments UX WIP --- .../Comments/threadstore/ThreadStore.ts | 2 +- .../Comments/threadstore/YjsThreadStore.ts | 116 ++++++++++++-- packages/mantine/src/Badge/Badge.tsx | 40 +++++ packages/mantine/src/comments/Card.tsx | 16 +- packages/mantine/src/comments/Editor.tsx | 11 +- packages/mantine/src/components.tsx | 6 +- packages/mantine/src/style.css | 76 +++++++++ packages/mantine/src/toolbar/Toolbar.tsx | 3 +- .../react/src/components/Comments/Comment.tsx | 148 +++++++++++++++--- .../src/components/Comments/CommentEditor.tsx | 2 +- .../components/Comments/FloatingComposer.tsx | 12 +- .../react/src/components/Comments/Thread.tsx | 5 +- packages/react/src/editor/BlockNoteView.tsx | 8 +- .../react/src/editor/ComponentsContext.tsx | 13 ++ .../src/hooks/useUIElementPositioning.ts | 1 - 15 files changed, 394 insertions(+), 65 deletions(-) create mode 100644 packages/mantine/src/Badge/Badge.tsx diff --git a/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts index 936288d463..01c2b4c75d 100644 --- a/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts +++ b/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts @@ -77,7 +77,7 @@ export abstract class ThreadStore { abstract addReaction(options: { threadId: string; commentId: string; - // reaction: string; TODO + reaction: string; }): Promise; /** diff --git a/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts index 91e90d4837..aad1354c87 100644 --- a/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts +++ b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts @@ -1,7 +1,12 @@ import { v4 } from "uuid"; import * as Y from "yjs"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; -import { CommentBody, CommentData, ThreadData } from "../types.js"; +import { + CommentBody, + CommentData, + CommentReactionData, + ThreadData, +} from "../types.js"; import { ThreadStore } from "./ThreadStore.js"; import { ThreadStoreAuth } from "./ThreadStoreAuth.js"; @@ -234,19 +239,76 @@ export class YjsThreadStore extends ThreadStore { yThread.set("resolvedUpdatedAt", new Date().getTime()); }); - public addReaction = this.transact( - (options: { - threadId: string; - commentId: string; - // reaction: string; TODO - }) => { - throw new Error("Not implemented"); - } - ); + public toggleReaction = this.transact( + (options: { threadId: string; commentId: string; reaction: string }) => { + const yThread = this.threadsYMap.get(options.threadId); + if (!yThread) { + throw new Error("Thread not found"); + } + + const yCommentIndex = yArrayFindIndex( + yThread.get("comments"), + (comment) => comment.get("id") === options.commentId + ); + if (yCommentIndex === -1) { + throw new Error("Comment not found"); + } + + const yComment = yThread.get("comments").get(yCommentIndex); + if (!this.auth.canUpdateComment(yMapToComment(yComment))) { + throw new Error("Not authorized"); + } + + const yReactionIndex = yArrayFindIndex( + yComment.get("reactions"), + (reaction) => reaction.get("emoji") === options.reaction + ); - public deleteReaction = this.transact( - (options: { threadId: string; commentId: string; reactionId: string }) => { - throw new Error("Not implemented"); + if (yReactionIndex !== -1) { + const yReaction = yComment.get("reactions").get(yReactionIndex); + + const yUserIdIndex = yArrayFindIndex( + yReaction.get("usersIds"), + (userId) => userId === this.userId + ); + if (yUserIdIndex !== -1) { + // This user already reacted with this emoji, so it should be toggled + // off. + + // Reaction exists and contains user ID, so the user ID should be + // removed from the list. If the list is now empty, remove the reaction + // altogether. + yReaction.get("usersIds").delete(yUserIdIndex); + + if (yReaction.get("usersIds").length === 0) { + yComment.get("reactions").delete(yReactionIndex); + yComment.set("updatedAt", new Date().getTime()); + } + + return; + } + // Other users have reacted with this emoji, but this user has not, so + // it should be toggled on. + + // Reaction exists but does not contain user ID, so the user ID should + // be added to the list. + yReaction.get("usersIds").push([this.userId]); + + return; + } + // No one has reacted with this emoji, so it should again be toggled on. + + // Reaction does not exist, and so a new one should be created and this + // user should be added to the list. + const date = new Date(); + const reaction: CommentReactionData = { + emoji: options.reaction, + createdAt: date, + usersIds: [this.userId, "fesfes"], + }; + + yComment.get("reactions").push([reactionToYMap(reaction)]); + yComment.set("updatedAt", date.getTime()); } ); @@ -283,6 +345,22 @@ export class YjsThreadStore extends ThreadStore { // HELPERS +function reactionToYMap(reaction: CommentReactionData) { + const yMap = new Y.Map(); + yMap.set("emoji", reaction.emoji); + yMap.set("createdAt", reaction.createdAt.getTime()); + if (reaction.usersIds.length === 0) { + throw new Error("Need at least one user ID in reactionToYMap"); + } + const usersIdsArray = new Y.Array(); + + usersIdsArray.push([...reaction.usersIds]); + + yMap.set("usersIds", usersIdsArray); + + return yMap; +} + function commentToYMap(comment: CommentData) { const yMap = new Y.Map(); yMap.set("id", comment.id); @@ -320,6 +398,14 @@ function threadToYMap(thread: ThreadData) { return yMap; } +function yMapToReaction(yMap: Y.Map): CommentReactionData { + return { + emoji: yMap.get("emoji"), + createdAt: yMap.get("createdAt"), + usersIds: yMap.get("usersIds").toArray(), + }; +} + function yMapToComment(yMap: Y.Map): CommentData { return { type: "comment", @@ -330,7 +416,9 @@ function yMapToComment(yMap: Y.Map): CommentData { deletedAt: yMap.get("deletedAt") ? new Date(yMap.get("deletedAt")) : undefined, - reactions: [], + reactions: yMap + .get("reactions") + .map((reaction: Y.Map) => yMapToReaction(reaction)), metadata: yMap.get("metadata"), body: yMap.get("body"), }; diff --git a/packages/mantine/src/Badge/Badge.tsx b/packages/mantine/src/Badge/Badge.tsx new file mode 100644 index 0000000000..9d3a213c74 --- /dev/null +++ b/packages/mantine/src/Badge/Badge.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from "react"; +import { ComponentProps } from "@blocknote/react"; +import { Chip, Group } from "@mantine/core"; +import { assertEmpty } from "@blocknote/core"; + +export const Badge = forwardRef< + HTMLInputElement, + ComponentProps["Generic"]["Badge"]["Root"] +>((props, ref) => { + const { className, text, icon, isSelected, onClick, ...rest } = props; + + assertEmpty(rest); + + return ( + + {icon} + {text} + + ); +}); + +export const BadgeGroup = forwardRef< + HTMLDivElement, + ComponentProps["Generic"]["Badge"]["Group"] +>((props, ref) => { + const { className, children, ...rest } = props; + + assertEmpty(rest); + + return ( + + {children} + + ); +}); diff --git a/packages/mantine/src/comments/Card.tsx b/packages/mantine/src/comments/Card.tsx index 2e50ea092d..38865105ae 100644 --- a/packages/mantine/src/comments/Card.tsx +++ b/packages/mantine/src/comments/Card.tsx @@ -12,14 +12,7 @@ export const Card = forwardRef< assertEmpty(rest, false); return ( - + {children} ); @@ -34,12 +27,7 @@ export const CardSection = forwardRef< assertEmpty(rest, false); return ( - + {children} ); diff --git a/packages/mantine/src/comments/Editor.tsx b/packages/mantine/src/comments/Editor.tsx index af5a88f4c2..6bd4805b8f 100644 --- a/packages/mantine/src/comments/Editor.tsx +++ b/packages/mantine/src/comments/Editor.tsx @@ -1,6 +1,6 @@ import { assertEmpty } from "@blocknote/core"; import { ComponentProps } from "@blocknote/react"; -import { forwardRef } from "react"; +import { forwardRef, useEffect } from "react"; import { BlockNoteView } from "../BlockNoteView.js"; export const Editor = forwardRef< @@ -11,8 +11,17 @@ export const Editor = forwardRef< assertEmpty(rest, false); + // When we click the edit button on a comment, we also want to focus the + // comment editor + useEffect(() => { + if (editable) { + editor.focus(); + } + }, [editable, editor]); + return (
{props.children}
, TextInput: TextInput, diff --git a/packages/mantine/src/style.css b/packages/mantine/src/style.css index 9fe02136fa..495f845bb9 100644 --- a/packages/mantine/src/style.css +++ b/packages/mantine/src/style.css @@ -564,3 +564,79 @@ display: flex; justify-content: space-between; } + +/* TODO: Clean up */ +.bn-mantine .bn-thread { + background-color: var(--bn-colors-menu-background); + border: var(--bn-border); + border-radius: var(--bn-border-radius-medium); + box-shadow: var(--bn-shadow-medium); + color: var(--bn-colors-menu-text); + overflow: visible; +} + +.bn-mantine .bn-thread .bn-grid-suggestion-menu { + gap: 0; + max-height: 300px; + padding: 6px; +} + +.bn-mantine .bn-thread .bn-grid-suggestion-menu-item { + border-radius: var(--bn-border-radius-small); + font-size: 20px; + height: 32px; + margin: 0; + width: 32px; +} + +.bn-mantine .bn-thread-comments { + border-bottom: var(--bn-border); +} + +.bn-mantine .bn-comment-actions-wrapper { + width: 100%; + display: flex; + align-items: flex-end; + justify-content: flex-end; +} + +.bn-mantine .bn-action-toolbar { + background-color: var(--bn-colors-menu-background); + border: var(--bn-border); + border-radius: var(--bn-border-radius-medium); + gap: 0; + padding: 2px; +} + +.bn-action-toolbar .mantine-Button-root, +.bn-action-toolbar .mantine-ActionIcon-root { + background-color: var(--bn-colors-menu-background); + border: none; + border-radius: var(--bn-border-radius-small); + color: var(--bn-colors-menu-text); +} + +.bn-action-toolbar .mantine-Button-root:hover, +.bn-action-toolbar .mantine-ActionIcon-root:hover { + background-color: var(--bn-colors-hovered-background); + border: none; + color: var(--bn-colors-hovered-text); +} + +.bn-action-toolbar .mantine-Button-root[data-selected], +.bn-action-toolbar .mantine-ActionIcon-root[data-selected] { + background-color: var(--bn-colors-selected-background); + border: none; + color: var(--bn-colors-selected-text); +} + +.bn-action-toolbar .mantine-Button-root[data-disabled], +.bn-action-toolbar .mantine-ActionIcon-root[data-disabled] { + background-color: var(--bn-colors-disabled-background); + border: none; + color: var(--bn-colors-disabled-text); +} + +.bn-mantine .bn-action-toolbar .mantine-Menu-itemLabel { + font-size: 12px; +} \ No newline at end of file diff --git a/packages/mantine/src/toolbar/Toolbar.tsx b/packages/mantine/src/toolbar/Toolbar.tsx index 47dd1cb6e4..60e12a6dbe 100644 --- a/packages/mantine/src/toolbar/Toolbar.tsx +++ b/packages/mantine/src/toolbar/Toolbar.tsx @@ -36,8 +36,7 @@ export const Toolbar = forwardRef( // TODO: aria-label onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} - justify={variant === "action-toolbar" ? "flex-end" : undefined} - gap={variant === "action-toolbar" ? "xs" : undefined}> + gap={variant === "action-toolbar" ? 2 : undefined}> {children} ); diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 3023b20954..4dda62d608 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -1,12 +1,35 @@ "use client"; -import { CommentData, ThreadData, mergeCSSClasses } from "@blocknote/core"; -import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from "react"; -import { useCallback, useEffect, useState } from "react"; +import { + CommentData, + ThreadData, + mergeCSSClasses, + getDefaultEmojiPickerItems, + DefaultGridSuggestionItem, +} from "@blocknote/core"; +import { + ComponentPropsWithoutRef, + MouseEvent, + ReactNode, + useCallback, + useEffect, + useState, + useRef, +} from "react"; +import { + RiArrowGoBackFill, + RiCheckFill, + RiDeleteBinFill, + RiEditFill, + RiEmotionFill, + RiMoreFill, +} from "react-icons/ri"; + import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; +import { GridSuggestionMenu } from "../SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.js"; import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; import { useUser } from "./useUsers.js"; @@ -263,9 +286,16 @@ export const Comment = ({ }); }, [comment, thread.id, editor.comments]); - const onReactionSelect = useCallback(() => { - console.log("reaction select"); - }, []); + const onReactionSelect = useCallback( + (emoji: string) => { + editor.comments?.store.toggleReaction({ + threadId: thread.id, + commentId: comment.id, + reaction: emoji, + }); + }, + [comment.id, editor.comments?.store, thread.id] + ); const onResolve = useCallback(() => { editor.comments!.store.resolveThread({ @@ -295,6 +325,16 @@ export const Comment = ({ const user = useUser(editor, comment.userId); + // TODO: Change emoji picker implementation to premade component? + const emojis = useRef(undefined); + useEffect(() => { + const getEmojis = async () => { + emojis.current = await getDefaultEmojiPickerItems(editor, ""); + }; + + getEmojis(); + }, [editor]); + if (!showDeleted && !comment.body) { return null; } @@ -314,14 +354,32 @@ export const Comment = ({ if (showActions && !isEditing) { actions = ( + className={mergeCSSClasses("bn-action-toolbar", "bn-comment-actions")}> {canAddReaction && ( - - R1 - + + + + + + + + {/* TODO: Change emoji picker implementation to premade component? */} + ({ + ...item, + icon: <>{item.id}, + })) || [] + } + loadingState={"loaded"} + selectedIndex={-1} + columns={6} + onItemClick={(item) => onReactionSelect(item.id)} + /> + + )} {showResolveOrReopen && (thread.resolved ? ( @@ -329,14 +387,14 @@ export const Comment = ({ mainTooltip="Re-open" variant="compact" onClick={onReopen}> - R2 + ) : ( - R2 + ))} {(canDeleteComment || canEditComment) && ( @@ -345,17 +403,21 @@ export const Comment = ({ - ... + {canEditComment && ( - + } + onClick={handleEdit}> Edit comment )} {canDeleteComment && ( - + } + onClick={onDelete}> Delete comment )} @@ -389,13 +451,10 @@ export const Comment = ({ actions={({ isEmpty }) => ( - - X - + className={mergeCSSClasses( + "bn-action-toolbar", + "bn-comment-actions" + )}> Save + + Cancel + )} /> ) : comment.body ? ( <> - + 0 + ? () => ( + + {comment.reactions.map((reaction) => ( + + onReactionSelect(reaction.emoji) + }> + ))} + + ) + : undefined + } + /> {showReactions && comment.reactions.length > 0 && (
diff --git a/packages/react/src/components/Comments/CommentEditor.tsx b/packages/react/src/components/Comments/CommentEditor.tsx index 43841c4a16..e869cbae41 100644 --- a/packages/react/src/components/Comments/CommentEditor.tsx +++ b/packages/react/src/components/Comments/CommentEditor.tsx @@ -59,7 +59,7 @@ export const CommentEditor = (props: { editable={props.editable} /> {props.actions && ( -
+
)} diff --git a/packages/react/src/components/Comments/FloatingComposer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx index 214b4d740c..aa449d1d42 100644 --- a/packages/react/src/components/Comments/FloatingComposer.tsx +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -1,3 +1,5 @@ +import { mergeCSSClasses } from "@blocknote/core"; + import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; @@ -23,13 +25,19 @@ export function FloatingComposer() { }); return ( - + ( - + + className={mergeCSSClasses( + "bn-action-toolbar", + "bn-comment-actions" + )}> { editor.mount(element, portalManager); + + // Since we mount the editor ourselves, we also have to manually + // autofocus it on mount. + if (rest.autoFocus) { + element?.focus(); + } }, - [editor, portalManager] + [editor, portalManager, rest.autoFocus] ); return ( diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index 2ebdbce383..f8b5842c2b 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -184,6 +184,19 @@ export type ComponentProps = { }; // TODO: We should try to make everything as generic as we can Generic: { + Badge: { + Root: { + className?: string; + text: string; + icon?: ReactNode; + isSelected?: boolean; + onClick?: () => void; + }; + Group: { + className?: string; + children: ReactNode; + }; + }; Form: { Root: { children?: ReactNode; diff --git a/packages/react/src/hooks/useUIElementPositioning.ts b/packages/react/src/hooks/useUIElementPositioning.ts index 1e9de1b10d..926447b149 100644 --- a/packages/react/src/hooks/useUIElementPositioning.ts +++ b/packages/react/src/hooks/useUIElementPositioning.ts @@ -15,7 +15,6 @@ export function useUIElementPositioning( ) { const { refs, update, context, floatingStyles } = useFloating({ open: show, - strategy: "fixed", ...options, }); const { isMounted, styles } = useTransitionStyles(context); From 61b934e839f33fc8a3f713c2546b1ee607c6d6ad Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Mon, 27 Jan 2025 21:23:36 +0100 Subject: [PATCH 25/30] docs: Updated sponsors section (#1392) * Updated sponsors section * Renaming --- .../pages/landing/community/Sponsors.tsx | 78 ++++++-- docs/public/img/sponsors/atuin.png | Bin 0 -> 18690 bytes docs/public/img/sponsors/capitolDark.svg | 19 ++ docs/public/img/sponsors/capitolLight.svg | 12 ++ .../{deeporigin.svg => deepOrigin.svg} | 0 docs/public/img/sponsors/dinumDark.svg | 181 ++++++++++++++++++ docs/public/img/sponsors/dinumLight.svg | 159 +++++++++++++++ .../{fermat-dark.svg => fermatDark.svg} | 0 .../sponsors/{fermat.svg => fermatLight.svg} | 0 .../{nlnet-dark.svg => nlnetDark.svg} | 0 .../sponsors/{nlnet.svg => nlnetLight.svg} | 0 .../{noteplan-dark.png => notePlanDark.png} | Bin .../{noteplan.png => notePlanLight.png} | Bin .../{poggio-dark.svg => poggioDark.svg} | 0 .../sponsors/{poggio.svg => poggioLight.svg} | 0 .../{twenty-dark.png => twentyDark.png} | Bin .../sponsors/{twenty.png => twentyLight.png} | Bin .../{typecell-dark.svg => typeCellDark.svg} | 0 .../{typecell.svg => typeCellLight.svg} | 0 docs/public/img/sponsors/zendis.svg | 1 + examples/01-basic/04-default-blocks/App.tsx | 10 +- 21 files changed, 439 insertions(+), 21 deletions(-) create mode 100644 docs/public/img/sponsors/atuin.png create mode 100644 docs/public/img/sponsors/capitolDark.svg create mode 100644 docs/public/img/sponsors/capitolLight.svg rename docs/public/img/sponsors/{deeporigin.svg => deepOrigin.svg} (100%) create mode 100644 docs/public/img/sponsors/dinumDark.svg create mode 100644 docs/public/img/sponsors/dinumLight.svg rename docs/public/img/sponsors/{fermat-dark.svg => fermatDark.svg} (100%) rename docs/public/img/sponsors/{fermat.svg => fermatLight.svg} (100%) rename docs/public/img/sponsors/{nlnet-dark.svg => nlnetDark.svg} (100%) rename docs/public/img/sponsors/{nlnet.svg => nlnetLight.svg} (100%) rename docs/public/img/sponsors/{noteplan-dark.png => notePlanDark.png} (100%) rename docs/public/img/sponsors/{noteplan.png => notePlanLight.png} (100%) rename docs/public/img/sponsors/{poggio-dark.svg => poggioDark.svg} (100%) rename docs/public/img/sponsors/{poggio.svg => poggioLight.svg} (100%) rename docs/public/img/sponsors/{twenty-dark.png => twentyDark.png} (100%) rename docs/public/img/sponsors/{twenty.png => twentyLight.png} (100%) rename docs/public/img/sponsors/{typecell-dark.svg => typeCellDark.svg} (100%) rename docs/public/img/sponsors/{typecell.svg => typeCellLight.svg} (100%) create mode 100644 docs/public/img/sponsors/zendis.svg diff --git a/docs/components/pages/landing/community/Sponsors.tsx b/docs/components/pages/landing/community/Sponsors.tsx index c9684e28b9..1625aaac1c 100644 --- a/docs/components/pages/landing/community/Sponsors.tsx +++ b/docs/components/pages/landing/community/Sponsors.tsx @@ -5,29 +5,59 @@ import { import { FadeIn } from "@/components/pages/landing/shared/FadeIn"; import { SectionSubHeader } from "@/components/pages/landing/shared/Headings"; -import deeporigin from "../../../../public/img/sponsors/deeporigin.svg"; -import fermatDark from "../../../../public/img/sponsors/fermat-dark.svg"; -import fermatLight from "../../../../public/img/sponsors/fermat.svg"; -import nlnetDark from "../../../../public/img/sponsors/nlnet-dark.svg"; -import nlnetLight from "../../../../public/img/sponsors/nlnet.svg"; -import noteplanDark from "../../../../public/img/sponsors/noteplan-dark.png"; -import noteplanLight from "../../../../public/img/sponsors/noteplan.png"; -import poggioDark from "../../../../public/img/sponsors/poggio-dark.svg"; -import poggioLight from "../../../../public/img/sponsors/poggio.svg"; -import twentyDark from "../../../../public/img/sponsors/twenty-dark.png"; -import twentyLight from "../../../../public/img/sponsors/twenty.png"; -import typecellDark from "../../../../public/img/sponsors/typecell-dark.svg"; -import typecellLight from "../../../../public/img/sponsors/typecell.svg"; +import atuin from "../../../../public/img/sponsors/atuin.png"; +import capitolDark from "../../../../public/img/sponsors/capitolDark.svg"; +import capitolLight from "../../../../public/img/sponsors/capitolLight.svg"; +import deepOrigin from "../../../../public/img/sponsors/deepOrigin.svg"; +import dinumDark from "../../../../public/img/sponsors/dinumDark.svg"; +import dinumLight from "../../../../public/img/sponsors/dinumLight.svg"; +import fermatDark from "../../../../public/img/sponsors/fermatDark.svg"; +import fermatLight from "../../../../public/img/sponsors/fermatLight.svg"; +import nlnetDark from "../../../../public/img/sponsors/nlnetDark.svg"; +import nlnetLight from "../../../../public/img/sponsors/nlnetLight.svg"; +import notePlanDark from "../../../../public/img/sponsors/notePlanDark.png"; +import notePlanLight from "../../../../public/img/sponsors/notePlanLight.png"; +import poggioDark from "../../../../public/img/sponsors/poggioDark.svg"; +import poggioLight from "../../../../public/img/sponsors/poggioLight.svg"; +import twentyDark from "../../../../public/img/sponsors/twentyDark.png"; +import twentyLight from "../../../../public/img/sponsors/twentyLight.png"; +import typeCellDark from "../../../../public/img/sponsors/typeCellDark.svg"; +import typeCellLight from "../../../../public/img/sponsors/typeCellLight.svg"; +import zendis from "../../../../public/img/sponsors/zendis.svg"; export const sponsorsCardData: SponsorCardProps[] = [ { logo: { - light: deeporigin, - dark: deeporigin, + light: atuin, + dark: atuin, + }, + name: "Atuin", + link: "https://atuin.sh/", + }, + { + logo: { + light: capitolLight, + dark: capitolDark, + }, + name: "Capitol", + link: "https://www.capitol.ai/", + }, + { + logo: { + light: deepOrigin, + dark: deepOrigin, }, name: "Deep Origin", link: "https://www.deeporigin.com/", }, + { + logo: { + light: dinumLight, + dark: dinumDark, + }, + name: "DINUM", + link: "https://www.numerique.gouv.fr/dinum/", + }, { logo: { light: fermatLight, @@ -46,10 +76,10 @@ export const sponsorsCardData: SponsorCardProps[] = [ }, { logo: { - light: noteplanLight, - dark: noteplanDark, + light: notePlanLight, + dark: notePlanDark, }, - name: "Noteplan", + name: "NotePlan", link: "https://noteplan.co/", tagline: "Apple Top Notes Apps", }, @@ -72,12 +102,20 @@ export const sponsorsCardData: SponsorCardProps[] = [ }, { logo: { - light: typecellLight, - dark: typecellDark, + light: typeCellLight, + dark: typeCellDark, }, name: "TypeCell", link: "https://www.typecell.org/", }, + { + logo: { + light: zendis, + dark: zendis, + }, + name: "ZenDiS", + link: "https://zendis.de/", + }, ]; export function Sponsors() { diff --git a/docs/public/img/sponsors/atuin.png b/docs/public/img/sponsors/atuin.png new file mode 100644 index 0000000000000000000000000000000000000000..6a4c5ac3dee0a1eadcf21877e8f8f63352444a30 GIT binary patch literal 18690 zcmb5Wc|26_`#-LoWe=#K1OA zcfHi^J-n1u%X24L*zBH3-5k3wlK*D7Z(>ebcAkQn69`dk{CY$M0@VymRMj*1y|xa{ zXbFvqzI^S5#ZBk$KNed$K8#OIzbtuO^Y&d#Vu~Wj0EzjodF6)J{Se3C$B{+#mR`XX z)4yV?J1$u}2WOVLJxtE-oapGUGXzlMf?AqeF=50naFORa9$vat7?4tX{n-46a^M+j zz!m!-CMI4U#@_=krd;xwn8cZ`8eF!1Fuj@|rayCK?M-u{uQ{7c`@@sMytm%p7MFO2 zkUHKhOYI*VEa-rD?3F!ubm&dBU%B+k>x=?IE$)0mOihoCvkgf7NQTmlg0t!IT}3LF zkT;#qr1SUXi2*`?pHq}S9yV)+)YOX|KfuJ~cK_SjW7okyKl`O**_oIw@uZa$@jm|e zv;UARCvbVtN~Qj=tj@yvp=Drj`AozI!EDbw4ko5&rxUm*ccz!V;{hhax9+rmHR%1* ze?pcIu;JU+R-%FV?TiZ|z{SN<_tN|aXB6&-0EDSSVdY;x^-zv`oMd8pe?K2`w(Zj$ zEheUiKeRd=t|ZsOC+fKTi8aEP342(sdCjT2GYWs<8*k>6HCSZKtMLr>03imI&+%%%0sVk%QsE|yq|Hc%czrW z!9%L|VUN92sKA+yIo80Ja~f?AhX4*nzr)&6PpzIa z0tUx@AS5|R-&kf06jw#Bj;ZISBbNN$AkP7!HKFS++UegtH%}@7igGyox-eM|fORYG>EoM)t^nXnR^5lUTP;1k<8cB_ z0l+o2f&qHRt~=^S?%S})N6pv*Q4u}o35<4 zq0g{ z63-ViGhOn_Zm9T34+*Cn>0^M4I)DZzg!8cxhm!!}uY51<|a>TTFr0%r4m% zO!^}-a~@=836F5+4na=u9qnQEY}RqrU_C_N}4Y_p8`yd z`+d!QR`IoxBI8k~wUG)b#~r%Z1q6(GcJIAz=aEEn2Avx(HLJZ-F}> z9yt=(oWhw-szk&F6umzfKLr6#ysH^X;CML5{C?`-(uub^pK455gCV|KRv>;^S*4 zbW6k%X}F{%Verfe57Qrgx;}u|;#B9^He$l8jrvv|^~{xK{fkd;4O+ig8&_$+p4cW+ z<4CsA$n)B08oTys+OBZodZbH!o;LXCS6f8ZkG2O(q%G6X6zy^zUo)qAyci$Vs}AEs z-Dy?77}^IPbUg8C_GR9ctfm*##1mnaaqnbEw;ou$@M@K#1Q%jfo(G|32(1Q}2id=r z_m{VNDEp89{ZK)bs8f4II2LK;IgQI>LIl1UY=zCawZzgHrlB7)qW|Mu<(Q6qtJqY=iBb7 z>%?;vzIM<;yE0x8B(ugDs#{I!>WI0*(~8fasi(i=4FD8o$AYY8N#lR=GG)b~ET7$m zde3s(RF0+QLR>e)K%7)7J`aFt*yC317gln)scDWmf_$(sSGAX)MEsL5VW9`Xv6sGW zJ&1&ptluh9IJWC2SAy{Q53v$VDlwvL&KoSEmL6Bz!cmFnLYYt1haZ+#hU>RQ{4(#!tU7ZpjLh+0t4 z_1_kOebTN8W`4JzCb;WWiwO$`%+;6qPTL`8uI=`ewC;tSCF?~ACFwVKmD50z>eHeJS*0;HLrL_w3uc zY3SaLx5=7156rUuCgBB5@bWeTQo&?xa$0)ip~+{yy$z zyzoBA0_FbQ!WyR|@esSZ5!O|q7o97qfjTRD^Q7b`%3HhZ)w*fkfSYjXfgn{?jYQPr z@}an7Y}%GwlQx&uEAW}Zy-v}v%0Fs@?r;4bw;;FQ6;aQzfFyNmZ*JNJM2SPMJi5N_ zw!1ZAF~tLW75>t5WmG-4PRFvlFcuJZ5quQE5!sJ$kU`B7^yqSU>j!c-3cVeFI2BG9 z5yxq#kj&ttVjuaM_6k?QDYfjlPGa5G?9Td=D7PG0Cggen!Tbl<0}*gIuwznz($NDV zm9#8<${7oVl(xql3MHYJh+oY<(=mG}+RaC0!V~T|dzr;>*_+maEKV56VGpyx^LpO%?;F6S@NM_& zE-5vEVBw@krAF-vBWLt8KPa<^AcH#YBkOyLVli{)pu5gXoKA}Z^P%RDSPzZmzs#d6>b1RW8yOoHiyEFS?9Go)FsK#-t9sb z+z}_-v`&#&AqE*Sg|S>dUaA)nAaYPpN`m0m7wC!nBci#pWdwmry=Va%o|+adR-UU zZSi<;;-4UsMW}AnyB4>^HfNtGw7O9GwEK9IW|QljnD%bFNWvaD$oGKVqWzn$eW$(7 z0?Z=IAO5!GsNFJYS-3M@qm~4Z{2g5EZM!_aMvtjk0rhs?O4vt$Wi4jhS0Dc$kIUWLo{C1{vbR4UzR)>F~}dsw!u++TX}XisYqCm?E#AaSrJXN2fu;C)lXIdz$TvPhTp)y#0@S zRvFEfbN%au02NXDkGT4TRXrAGvjv)!u#5LX!xOormwyfhVE3%#M{0SX)%HXH(KSC8 zbKm6`Mc10bL+a>r9o*>9yVH+0V>zttUaL^pU)r+UvS43&vjc^~&!y%*2M>wUMKK9c zlrXT>6ZMk|Cf6$v6qV;>Q-+(d88DpIX$pjG-?|`6Zjq^d{j}J7KokfouF&+0b)G6E zW=$OqLVSh-x$CGXx|K_e6S{czXy}z2C*EkRxCoDFkGZtr%Q=i^c6CE7?w5VWo!$@0 z3y2V2N{cD?OX8iD{+Gv-<%cRr}~Gh>iE_SeFHb|$~O zYCPG4)a!6DLj7n^lCN2lMNeh`b@@(0Qpet@7air1W*Xl@*9$6j2Bu!yRw^t+Dk8|V z!%In^G|pf}q!q8nbHM*BErWU1b*xkkQ|`ChpXw4nK?a@c4QLzKiRdSUBG?R31sAi< ztHZtdXTRjsBeqGFuXGHFx~k!i_`7q1tA{l{%a-VsQukt?=>^B=Y0lf)?5=;Je7F!-SKhM>sD1tngs^%km3rBPDAZlMK zuJ_mtl+VCcZ|)};>by*PK*zi^mKzPAJNs!oRPEY-y#){ z6e{BCKNH4AU4~ba&;7;|-xXtyYC@5Yt3cd5u2GY<^7%+#wLCv-lU)0I{>CmMQexWH zAG?&~QYqL;X!4$&%03K)4avQxCjFkq<*0YUDOl&=Kt%{BWk=s-PUZU;y+t`DGX0AG zSmj(fYToNdq5FY2e47PZfqd$^^WB7T| zE9mq{X;#2Tc++{(Zt5Wy(C11lj&EaK=Y;LnDP;7Ez5&k@xdBmvg`n;Gvz!GAyTdqG_zsPKCK@Fh7Py=%2Vbh z0`L|js)v@a(jx~EH7wYvdjnd4g;&;xgV1gl)bhLFi-!3*v@@Wy=Cpf2^$6bqn5`Oq8p*e7OL4@2tS~}hd*mUIp?o5_2jWe1+`K8 zQX`NPA6wt#OpI7pIu8N48Yp`SHN-HZnOXC)t<U4}}#$%N6*cO*&>+ryPBoUj5jB&Tii#CMw04ep-ng2F)5OZ09RtV=xWddEZ3 zaI9{7L0B(o;bv*m`o$)6r=H{-%LfItI-vY4K=IX!00=Dq`dPRGSZwpVk=5(N>h_m0 zaX=n2u~HElm-jw6)|nB9Sm+P_*!g@>h2=AWbHCHNdk)CxFyy#3TWmE5j+3G zn0PR$M(fLI&L{Z{z8BriTUcQ~SwnjnQrieOZ$ZZnqE&j+ZkO%r6sl3{sC#i8Z=fJS6^Ypa?w+5c}?Tu)Q^ue%qq{Jk-qGvD+zh0L9Kj0xBW% z%_qRLBbEB~Z7x=l7mZ&2IZO-(!w38A80G1}{ilqlJfxNhS4g3o6Y4T;kJY65WP!Iqc%CFIgaP_G&GD0J)I*>&)QFuYi9fN=h$ zBMAsg;L!)*Q7)EDxI}~%YajI-NSnGYhB;+%1Soe*8A_E_*OZ;zY7qb$a!}6Vvb2y| z&vP5ZODG%=hvHG^OLGG{Ji#3;FW8XbCqYSGROW%Y#F0mSEex=Bc}OwVyd=}w5@5JI zFkDvjm4^X;NUIs>ghJ?421Ux_wAgB)x=u9XGXN9;YaWsy{LmVvLKY8pWixG0haE@hj{J0~wxEU(nOY_9|p%^8%CTWzvH}>I#y=l zCkjog*BSSU73VFu-7@TIZ%VcW>88>)k1uf|B`Mh0f*;YZrFK5Bw&ls>zXO2xm%j&2 zu^^^zmZFC8lcHbC;b#i6BO{Wr^}l;;xU|3m&~>Tb_k}D#-y&5uHwG*fVo0cGkyD?> zmt;zVJo4DB>fQ7JFLa8+q&vWMknC>54C>vzY{;DFl*wvV zYx+bWl#lAfzbot^@{%}HXEuJqey%W2>2`Q#q+BMZhm8F$n-;pbf!CS*we>74F<+hv zBziKIStJG_t9*)PhE1!qXz>I`opvHpT~DC>8@4&sIgNVT$Xkn>Z0{=fiPz6VB`|I$ z7e_3b=91y?d~f6m^@`wNTd2cofI1bkl48Y?d2wT(bRY$cwGzyF`w+2h#!D-fo)E$`)9kk7JW-Bv1f|V^`qsiW`}ezk4bU$YGlhR zeEG!jI~&uNJvxch=YE)1e*pB1iBF6rNFR=Wsv?c;%CMUOA)WpO|AhD109);Z_qlov z@9+(L>2Jkz!_`k1s|rjl)=)QRkugO-`~FbCGzZJe11s??2o)pQ)Y?|I(fINc_D=qq zII;L9SiSA9N*9kIF{ObY+r-7cwL{4?{le1}u`p_Knht`HJ?2snGf1>U_`yy*E;Lu+ zhbmGHW_k)_ss?uI^D>a*ee=C>*e;eDLvO!Nm7(mq zfoH+g!bN`h^%E$Mn=rt8lF*LgXS)xRp9OSenv8~Hz(_mS)-Szh^L?}#4G?GooVkaf zF{Vl1a|_mD@)DHWCDc^x)&oJSlpoV!00)8-(N?Dj-~aRqqfX8l6aA+1jrz3;OE+{@ z0EhsJaM2if&hn$YF{2unJLc2X%!r;WI5$N;XfjYW04d0nj|F9_1|LD+u56)M=|580 zr4%Xgy-&{Fo)|pqyUzj;q7EtrmNBde8x0^c;yI*tuha742X~5A;a%a32%zhegd%G6 zjaY-#)!gY=G9l>TSs}9Ag4p&>A2w z8By|Ph)^Om>z|8-NK&{)9X764E`Oh0-_W|ufX-u_cvlH%fHG~>kW_&m30$<25&iV0 zH0VAml9b~o;a(2Z0*h9@onVLZU98kQ2^tF+SUYOhwkEoOhr?jG;tY-Jy`a7y>1Ok= z2yZ)Hv;i`-p--kjNo%I^$I{)$`)a%%0*cx9YTB8GTAplfn4ug+qrhtZ7c?0!%?J}p zqL6cLfJHm+Y@-ZxjC3AU2aYlX_7V0SXs-^tYW|U4`tj$7)HZN!JRi+FmP-Vfs2Gyv zDCLji_9vY}amq4=@7l`mTGCW3z)A3E=y?wvSea(n;2)JhInGDo;PQ?Nlt_zfV z20q`dw2|R$WlH6%C3jsRBXjxESy*y7=fke?w=Pn@{m#bhQ{@eL{E(W>EO4Aa_+?`Y zVHTO$mnYsv6s+~}G9tq-bErX%cox8!`7=SC71w}saIC_Tm4xj1~e=kuxNNzob7v z>>hZo!G!1^Yk_~{gq)Km_}M)C2T*s}RV$uM^Yks|?HsJ(U?jUD^r%PQ+C@f+i6Cj6MN1Nk_?c|5sG8c~peO?cpF{h#nsj|%ZK3z2r;#N-|*_-?MSfIYaKGyb#d z*R>^ea8$lmo_8*`Bq`oI`84HfzkDw+-S6Y}paN=jclgbG-*MKq?B%YSq|+3`(f`lm zn)u~Rn2=E$c~TB~JMFF!kCsi~zo85AE?kn72@bG;1k|W6?tj+iSFtJu&4xlsb;o2L zvxb6=x{{=zUuOSj?SOfPA8CdDDgBSkhV;&Hvy9(7G)Fz8g{%M7zfmIzt*^I9Jx!5a zBF}0at9@c|%eV?lH|iKh?blsnqB{OZJs7bowQ>A%sZ5_Z<>%kbPsR?IXO?~jEmf#a z@K8)=V%pD|5-n!8=ft7ff0NOe=uYrv%smfx$amXerIi^)DO`Q<-ALfYRc zZb%i+7{_Cx?zCX7^3Mmo6rg!WA?xRa{CpHVj%i=KUV4-95)mh8$n=Q~a`ZyH!jqGPf2gxRz4 zyg!2e2AtRp;-KG8j)>jO=b5&mtJ6}H?M4>*fxqG?h82JgqBSbCY{QTA{x70VQiXBf=j#p{hJMN~+x7OpX$MWUk2Mfs^G!zwGcT zEF&SNPQX4abnS{yPbpAU)gibkJ*V9d-*yL=1Ez7QtSUu9LmI)+dw3xhILC9-wPs&Z z-9iAPP81C#m@8kUPUQ5w&d>#3=CX?3>kt%jZ zFaG5K=pfSA=0&NmW>jNGd)2M*D@!Sn^>y6zn=i3u%A^y`|Cgxw4XM-?&|}};26lo_ zZdA&!7zbZZ?_7i#z}B2-aXdmd`OAJqWWj-pF`9Kk&NBg#_hC&xyJ%mq4m94Su+SUY zMc(`tA!Y|D_SOpj7gf_=lo}s<3lGwLcH4AGQ&>xy+!Q!-G|pwj$A7PaTo@wiRrBY# zg1~}V+syjW`?PUYFxhc2s;Rw`hcP1q8SG&~T=)8&{MMq+ z+qZKo;m5`kb_56qV#{M*~wd06R$nP+VV{F z<+s_m&Tlo8vJ9fmfDv?qR1QxHHtw5F+?G<${69kit-R zg};Wal)NjUgy*=wDL9X$rhg+VDi%HqM1A@jz|G*yz-y5SM7h41%IJmZc~4D^dFZ{f z^rTn7n9$%lKUfm6Cw^cRqDO3L>Wl4%8#0WNq-b)e4@95!1XF%=eh?=OS&{i0G9}-= z-cl$bU&%jGIT4lv_l|F~5AmlTGrd&)uR8<0PTI^7g zG9KXfX()sD>q-nuzP7nlG8Qsd6O-puVByS5JE)cUZ`c{6sg7<(-uCj{oQS6Goez$o zY%?SxJHe=xOM`vfm~eKpx>?;ZJUG{1uetS&;;GUvj6GR~KyhTB&c_){-pCW8fa0!= zogFRLj_S9D!;opcGWei1$k7TDJM zT^_V2TBImeUWpXLwS9eLD>vz5>|4R)-THJl=&Eof8oK-I?XxIDlQ{>gsNl^Gi?7A3IL1~apspc{Ge!Hy z=K2>G3G_!Rm&Dm3_YbN!V+K2F*G!ijD?Jr4OVqU2-r&M+!ofL?ef6%!d>>A7p&T2s zVO#Q?a(b^iiL(>RS5))|28Te+8GSsUjU%PCG`PxjQ36c{(|W(%fBWhJ`*`(w_3DS{X(3(t@Ff=d zWbyla8R!b0b6DvTd#1M0Vl z?XMj?b>C#PIv<_`Kcn@g1pT8AX;}@#sXr8vwRQBN?PGgJ0K&XDs|^*iUrM08K2G-D|ME$TeKVLVAMlRVK>E}s*)Ho6 zQ5nM6*_EWc6LrYND?iew=!b&FhVC!_>}ja02#*>SA#dsk!o9-_AT5CA=l_OR*#^+) z76`JPDk_B0bQ@a3&yU=4)`H-EuQX;3p=_xYL6fDd`+JG|YJ81WV;zch#g1*<ei{x>_|E3?1; zR>(mRV~b2?7qX75Op_wUA{GR>;odINfq>Hp``HRRXFA<5S;9tcnGGzK*#7OM}t^i?!WRSdWeH{FNf?-A9PRBQ4q$gLeUCr?GbQTx`OB9gd15&~Cf( z7tNV?n-*dDMs9_6uBOl~{CNShJ2Y$RLJVHj^;SjB0Iw(*{yoN78&jLNo09!|k{n@} z9~9MiKCSrcex9(_FC`+{8U4{S5l2P_RdMX z>~E{cUohuI!`c3(pK1YKsU=2tNwZXfrm_WwxP;1=kh^PxY~~!a1CjpsVgTBt1N)N= zNFOX_`aO#PimAa1IKN?NOFL(pCqJhnkE0SH(RGLmR+ltoxgdfPuVG4|{&{qeHvlXAI_eoPXd8O;3Jl(SxaF zJ=hy)!)?V?Z*+Dp=PkU7A$jS~4f~~MQf2;Y1u?1Ew9n0$Z5Ft=kRIQ~t+9JTRYBC< zw#tLexbRx1<`55$?^Ber^33LymT@4ihW|BIqCa2vyF$4O+LLF}go$Tlfr@CK?YLg_=c=z2WOUHPbl?813x64a64k zP5^Keu*QH+s_cGT;T*#Jd>udHTU9|@DVp}^zS`37Jkwyri%w?^{YMxwxo-ZFi~c#s z5W1mylX|51xv$E?mtQCze)NjJBLI%q-+HYgO{tsY44-A?FM=3{$7Yf@mrteLJyoVAr8unndh>_k??Ky>8 z;rz}MIW?nEc}&ht{{`IVKnw?YepR>#Qv136%@yJ@uG?0!hWSc!zw-#g?uNf11Z+cH zB+X+T+Z{L9hAdofMw-j6h;G=Wra}8=)ttSjmG{m@D9rB5ra-;=eE49uHBFt?*@kMp zt8A-zXamz7cP@BY>zJbcgb7P>Y=Ui1;=e#;)ye)6=VY&5Eb$(*-0vT2^~by)1KDbg|o<1lDHj8lQ2Deg6Ht zeaNhMcKIz_pAxIYk>D5==>F#F^!QW)mu}=q|A!ro)goc}?H>>6X3|YA z?Uw-PIRX$gM#&J7QN#`K=TTiM}O_TM8H!`9;eD_`~Z zxf4tdY{d{UM~t2)6;$cMiHYw!M>f7*q{acq!4}^b-4_aUuD7X`p|m#COfCJ*4uL>7 z~pTmfK8UZ}$G_PJ<=K zI@Rj1S0_^|<15eAjG@U_`;1j!j8}3_53%cV&_XV{YU=GkZmF50>Fs__e4#msn0%Kz zGE>$knhR-#^*p))JG%Q21#ue-pe^D=d*3vr+sNLrrR%iK8|cMGcCEyv3hyq{i27Zd z{#(a~7>5ynCM7@NWDFJ#rmVcB6#q&faOR)XoKlx_nMv*tRhX)1swwhb{ro*K{a5oW zYJV*V&_80(*KA@fPgeJff+b#V3%Cg-3sVRWZLDkHZgSs4o6$p3MBW)-;v?tc3@^I3O=) zURCvnT5Tvr(T8}*sL7evsr9kd*Q+IAyV}|Q#SsrOJeH{aEw-!L|DsZT18D-qZ3=N?3C1eYGcc1`lvNgP$8j`>F>g4`w0>Akv(A@0vs3KwtX_OoZH@E)W*;C zW3bl9`8I1(Mj(6(_SzRbH+yn7Q;;zwp&tkWn{2wNSdcztg(*PxZMl~n&0A32Ps0(^ z(HvL+_vSwTGS_BVwmyz@?*80xBu+EHcj4{7q+|ijfT_oaI6Hl?^Q-Gg(U)9rP9GKi zkR*!REMHb24?y{caE7F58H#(o(vHP`qAV%DW(Wk%Sn5;KzIM**!0~KK(IvL*UX-!d zwDHDtu_@0|Kp0K5uA>5SP;;=Sk@!IPZ+!+jp8EH~?;dQmj&M*t-f4eG6t$Vl(fWqJ z@mlSQ_D(R* zgUc)kobzohrYOwdo)R{_8RIi1Gsm^?a*91Y50I@ck@J2EHOB} zM>Co+I|#@}9|Jrl$%d0Nh~k!-91FSOYh-yCKX!Pj(m0S(UEZFr8o48pdp0phLllWB z-`xt^ch)^&06Wn~45OGj=ijwtoicuR`+=3+DQW=dNqa-=vX@HmyS#^3;ZpG4jr&m- zC>X=PGpjMtUI1+ux7!~P%|9^t)<~{9*e&>=M0|S#A8!8Ln%s5SO={4L=ECm6ij~Gf zKJm;y#N{r7De#5cfjKf2Mz8ek(CleAYe}M`BV=sxth?d_>18B*!j_RJqc$}Imx zrGu#~Ll*t&_v|5Np9twDK_gLua7w${SfSN~?E3{wlkmhR++ezxi-F?)3zNeuOpF~G z1c|$-Fb9|wGM-@?jP$JB*hGz*y_cWp-ooGO-iuVPe!6n6qeT8IBO-n*Um9F}i1igf z>l)}Or!Iut(VY;2I}Ygy+5%0BqnoQNDK1YM+?VZi^yn*oSK6J{25h#cGUvzDzb4L2ZJy&DGS7 z_k2-32b?BD$AATbwZ$pNS)7H7YPVG%l^>)S{VcijAwoF8C`zi!>gp8!H}B^Cv2OpZK&zb=AxP zq{n6AG#7m|D4O5qp^MpklFh~$s91^rs-ad?vcMBlqHuZ@n}`KtE75rp@V1Hs)H#j> zI`7vNIQ;Zpi)c$t(lDxV!PzV02ta(4aSV=8Jb_H4p5jO4IE*wF4b#72QHk|ulu1dW zkU@ZI72s$l3FSt`!>K?_AhE#`wB%S(J}b*KuW4KM#BimiW|O$~2FWn#zyU5ZqWuZ2 zjQ?uM;oD|F-nrhK%}8^3;_&=wr1~VPQOJo{)|>oU>9(Q)FYHK6@+)rii`*`>urQKv zgNWwBsWo?1O8H4Pf41Q+s^t{5Jrp!Mf@ul`t0Fxth?+vkzGg?CmO8*IF*R&MBCAK_ z%gl46-GBqsQE&X^A7r<*CbrDk42jADmWA=5K@#Zq4B&txMJ-TtY*+rPlP~4;DIU>1 z8>e4mP(SX@wjgt`^)k>CN!a?6MW(GHTrgV%N#J-5+e~IvjrJ!j+`DYPyIsgHzv9p7 zbZMmPJ%qx_le(35BjVNxpbOajsJBq?dT$z?w;fNokSFd;EVoEk>TeQrb0D$^Ax&Fc ztI)OFFx?oEYl_I65EWsUr=61*PHb=B^%SnocAuSL#E-KC8p}}An2494T^F82Z_9f1 zwl{97#-2p`%dhI4@#$`}>wSy;tpdi}ic{DKxkQ}e2Bf#Is%%C`E>Xvy(6~A`t9|<% z!5V!{sJG{#ltipFUX>p=&H}1sOvpz8+b@>OzD`kFpVi;2UM<^Ymv-uQo;qL1hjjHo zC{SwZ78{KikcArj!IpVe=eLs^jg}l8m9E~Eji0j4<^ax&`aK_#1~!2}rVNgpAb~uq zI1i1{1YqXfsP=tY`PBO)Yg@)`q$I$P+Ftj))QH#&@QaIFZvGmPx0BG9AEYOQ^!GKL zzL{-1DMRUJ5z&x@4rwIwQB@i5T|pxE=ci%{z*qkipn2hXPIEaQb{gE2W-c}qI3tVQ z5ehqlb>D#K+F~$mCn+Jiu6{H3tze@zcBVI*{z;&*&h7Wv?J)}P`eq(80KGz7btUVT z!&7#k=d8vmzEm^?#VbQSe1)c zR#wh~0n6BgccwX-d13B|j6rxA7LEF4LU}h=%??C4i7&Hj;?^Bsw1EkS6S2+dK)vKVOUZTCnxnGfB!SHdHEL_c z3EJA%gO8lS8fH!5o4by3P<#hVemLVD${TP8{_FQz?a+QO#flBlF4PLR-b>cy7qBT3 zGq@sv4AKDRbHCHjl7ltOAJ-2c!x6;F*tMAU;O!rh@35SfThTeCY(zZa)V>2|kFkKP zz>t%$T7UHo@Louds)3%pfFgwp?hSTtwo78B*dB=)oB`PC0c>k@*HRb!Dc;ChoD%y` zzQ}Jd=MYLppD5y5sF5?ptEC7*46*}nVbP19`xMxHLf9EyoVMd|j3}@rWBnNG*sV+T z0;6n*t>N5Ss1tybrX}qTg#_y(YcC#nCc}`*^Ooa41j(vC78@1~Abu86AdkdUOlLY8 zfjibG?;vA|j=bU&zs{XRe_Lf)s7o6V7sr5rr$5ge5Vrv)>6MTR;7h{*vCj@aGJZv*RX|At_UxL{T_82UjdVo>-LG6*6;sbYou;JrmQn---h+K``oFks3` zvo`KpJ-pUTzW*>7OlV8MHrE3Vre0ht4;16G+W>6HYyfvC1cqyVyg;>m9A*5Bw#l~n zRt9<=h=m3QDQKHcfqKU0Lwj~W(;w|Y-9Yq zpy}8oIb0Vjyg3Rr@vL~BXS+CO?3QjQx?*m)+IiTBXiL-?1nQ$J&;!{cg@v5YQncrM z_-LK);odWz8`Z%Y2WhkUTKB|e^WuzZ$KII{n<>q~wepOThek+hD5D*S-i^oLa=$ju z8=u{%7CAK9T(9I530Yw`WB&QBnrDBpn+P1C96O#8aR)e5nNs90|5TakuJ{@g%S$qg zW_LFEZ$+=Nb`7)LLDAFQUD)ZH-cDoz0S)t^5ChKf>Qa-YiEEMikukc!dt2H`z*AE^ zw3}hm)+foLdb=w-J3G_{(<*0uz+sKNKKk`6_l|O6Vq=?fH(D6Y45kj-17L^A!1i>T z9F|r8&I&S^n>KmRe%%NH@<(V*<@Vqn>Sms&FqR4XJFYK{b@>X1f9%tRyzPW|=YGRb zH#W7xlUD(57;e~O0YqR-GR$Xnmun9PArPW;8m|$< zfOEeVYv+LUaOuUXfYek9c#%iRjxAdpTzgebAs&>VNS$93QB5SvKXfH9s*}J18tP+| z9GSwU-YKvsBC_ptyQ>1lgOzT7gSg|>(K^Esvs>EfPp;yI)%-pB05o-{w&r)Bi!qTv z!mp}6A5!LCYAv*g9(V$ovp>CWSGp452X0!NvF=5?{zh!A>8x$0aTXs+VCw+cCcg<@ za{;JPA^f80@Jfw)6WIMV0Z6(w`N>?nt1H&g7F6JmsX$AKG zWFyNN{^(QzHQ#hN<8DTrNOOWDUU2C4GooJZ8Oa``SR@wrX3Ow_E^%dUV{e2Le^r1Hbd*oEjD)@zQCjvzpg;Xd zR?E)B)4IBBPdNloY<$Cl|4eKc>3$?#_NPcEaf&8 z0azp%i22f1V)}(|jGl^&AWQgcw4(?gV}KJ$uyb~7KxrvhlK7&|!icD~_Br>GMFoEm z&s9slPAIi5PnA4RHyYKDT-l^_wZ=xGowC{5S*vQ;pHP3v$7t;EoS(2YOig zh?Zazn*S!`NyfshinT3DVSD-cji=k2Uj}WbpKf-v!-K0+Q_fSG{~oH+P7+->z?gwL zu%SnG$9jmyinay$ud3I22pzTMeRwbno&LVmhj+gyG@GPs1OS18av^H#@3mI+qW#&f zZ^iU?RE3Sz9TlJvEG$(>0HtZO*T>ZPeJDz>RK5~lNBsuU%(k4Pr`>yJa`Ntb+_r z&yS1}oz0TgIIv%K_=OnR0nBvVXim-%OfAGLf2Bbo6#i77NXEqKRpoIqmV181LyUT3 zOq7UGh#P>(!;n$BQ5riJ*Y~X|H`xy|q{k&9+(4Srz)6||feb$~UN&VL34})O9}75* z*xg7;iHA66?Z3wEv)&iaf)hnr2=MaI1WE=Jvpz69+o$^P$uXor{CZI4*gcisZ1B~c zx=jtj{!qs<=yQSqU@RgAUp40C+YA;{9L(h3KGr9|ro@J0+u!QAR?JA`f>0z`1R@+! zA2jyIhL0y1lVMVlecxWqHTeE)l;tehn;gwbr%#i`=$r{p8NWzMuttxrCjh}`3HA>a ziY}b4EK#6T_MKaY~T&ROW1WR?a2tmM&;JxkIUp?8(IN^{?nT4&B2nK>G* zhvomaKJQb0%7%v4$-R_~|M}@8umRCpc?kbz4WkIo3?s+!!cYWDi!#v5_2kW?!@Veh zBI_-)MYy}fc(hAa@6gK7eIQ1fCoUxEod8_!h`?KGH%D$W2mjza1$pv2oOzG3)VXyy zWx=L7N^$y(d7=EL`;#N}3C(N#fZCf*BBq0iz<+kZOGEfEDGNR$qIbZz@l`>%+5 zyG!I7d~YzXZ1{C;6yy_Q_15mdj1?{uT^Y{pFgeu20&)aXM}krO!vslTP`t~#D?Gmv zN~Y$a&(wDN$l|;gCo(p4Q&vU;n(pl0XR~>KLmX;>45b^jsvY-uU*0IC9cnt=B;ac~ z?dV$JR3MkmAK!K7a0 zS2TX!5-ar(yFD{B=pV|nU1d!ZL+SmB)cm7s=XY$kOP)ghK$_jT&J(UR?xM={Rn99W zhat2Kha2O(I%9U^Dem*~&p zJ2(V!8_eYG^`*j5-H!FxGA!_c1o4B&IgXgEKlmT>my6}bKhII=@5O|WQI{E?CJ1Ds zH{SIfu;_-I(0j9pLg@aIWMOe1FU{JB5gOU7vgKMJnhs(u*tt1@hxd7to!R|4PR z@JOxpY|(YIb>@zvCuph@!0+9~Td~5<7^Oh(NHY)bNW-5`z>u%gfOaRXR8?SbH^@;w z+9?Lz`z+uWsGi|Gh{1L_;PK-5ibmUuex>3R>Ujo#MCr@fsn+x+>|E681>ooVTsig| zehb3YZZW&T_w=BR#*7bmd_)xAF2-LoC(b=l_sRY75#VFMT?84PF*PC?QY)K5|o++hTc(!xqb*N@8hoJWOlE)o^q{PM+~fJb+LVd=8+%TK3I7b`n+K&p=T zob6Fx_4yIcbD)I^d}_~^Pv$!J{N`_v^?A$v&-)iG0(mhlDNpg3(T-NjB;cVq4J{he zyDpxtsVVEb0MeDH_B>5ld$RVqb5kuB1H))#>#O3=M+2p99qayFoI2?ZP>5-U*7+%7 zU5%@ravg0IQUe{`qWSGp%u17J9kHuBe{}y}Xc4V7Rm^ie(19_Z{xha4tek&h$IY8S Otqh*7elF{r5}E)PWCJ + + + + + + + + + + + + + + + + + + diff --git a/docs/public/img/sponsors/capitolLight.svg b/docs/public/img/sponsors/capitolLight.svg new file mode 100644 index 0000000000..3e7b6c1de2 --- /dev/null +++ b/docs/public/img/sponsors/capitolLight.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/public/img/sponsors/deeporigin.svg b/docs/public/img/sponsors/deepOrigin.svg similarity index 100% rename from docs/public/img/sponsors/deeporigin.svg rename to docs/public/img/sponsors/deepOrigin.svg diff --git a/docs/public/img/sponsors/dinumDark.svg b/docs/public/img/sponsors/dinumDark.svg new file mode 100644 index 0000000000..633c37d8b0 --- /dev/null +++ b/docs/public/img/sponsors/dinumDark.svg @@ -0,0 +1,181 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/img/sponsors/dinumLight.svg b/docs/public/img/sponsors/dinumLight.svg new file mode 100644 index 0000000000..d6f9a3d91c --- /dev/null +++ b/docs/public/img/sponsors/dinumLight.svg @@ -0,0 +1,159 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/public/img/sponsors/fermat-dark.svg b/docs/public/img/sponsors/fermatDark.svg similarity index 100% rename from docs/public/img/sponsors/fermat-dark.svg rename to docs/public/img/sponsors/fermatDark.svg diff --git a/docs/public/img/sponsors/fermat.svg b/docs/public/img/sponsors/fermatLight.svg similarity index 100% rename from docs/public/img/sponsors/fermat.svg rename to docs/public/img/sponsors/fermatLight.svg diff --git a/docs/public/img/sponsors/nlnet-dark.svg b/docs/public/img/sponsors/nlnetDark.svg similarity index 100% rename from docs/public/img/sponsors/nlnet-dark.svg rename to docs/public/img/sponsors/nlnetDark.svg diff --git a/docs/public/img/sponsors/nlnet.svg b/docs/public/img/sponsors/nlnetLight.svg similarity index 100% rename from docs/public/img/sponsors/nlnet.svg rename to docs/public/img/sponsors/nlnetLight.svg diff --git a/docs/public/img/sponsors/noteplan-dark.png b/docs/public/img/sponsors/notePlanDark.png similarity index 100% rename from docs/public/img/sponsors/noteplan-dark.png rename to docs/public/img/sponsors/notePlanDark.png diff --git a/docs/public/img/sponsors/noteplan.png b/docs/public/img/sponsors/notePlanLight.png similarity index 100% rename from docs/public/img/sponsors/noteplan.png rename to docs/public/img/sponsors/notePlanLight.png diff --git a/docs/public/img/sponsors/poggio-dark.svg b/docs/public/img/sponsors/poggioDark.svg similarity index 100% rename from docs/public/img/sponsors/poggio-dark.svg rename to docs/public/img/sponsors/poggioDark.svg diff --git a/docs/public/img/sponsors/poggio.svg b/docs/public/img/sponsors/poggioLight.svg similarity index 100% rename from docs/public/img/sponsors/poggio.svg rename to docs/public/img/sponsors/poggioLight.svg diff --git a/docs/public/img/sponsors/twenty-dark.png b/docs/public/img/sponsors/twentyDark.png similarity index 100% rename from docs/public/img/sponsors/twenty-dark.png rename to docs/public/img/sponsors/twentyDark.png diff --git a/docs/public/img/sponsors/twenty.png b/docs/public/img/sponsors/twentyLight.png similarity index 100% rename from docs/public/img/sponsors/twenty.png rename to docs/public/img/sponsors/twentyLight.png diff --git a/docs/public/img/sponsors/typecell-dark.svg b/docs/public/img/sponsors/typeCellDark.svg similarity index 100% rename from docs/public/img/sponsors/typecell-dark.svg rename to docs/public/img/sponsors/typeCellDark.svg diff --git a/docs/public/img/sponsors/typecell.svg b/docs/public/img/sponsors/typeCellLight.svg similarity index 100% rename from docs/public/img/sponsors/typecell.svg rename to docs/public/img/sponsors/typeCellLight.svg diff --git a/docs/public/img/sponsors/zendis.svg b/docs/public/img/sponsors/zendis.svg new file mode 100644 index 0000000000..7cdafa9098 --- /dev/null +++ b/docs/public/img/sponsors/zendis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/01-basic/04-default-blocks/App.tsx b/examples/01-basic/04-default-blocks/App.tsx index 935e96d93e..1a47d8a1cf 100644 --- a/examples/01-basic/04-default-blocks/App.tsx +++ b/examples/01-basic/04-default-blocks/App.tsx @@ -138,5 +138,13 @@ export default function App() { }); // Renders the editor instance using a React component. - return ; + return ( +
+ +
+ ); } From 844de93a2efb23fb84f2b3d38a374d2e8911a656 Mon Sep 17 00:00:00 2001 From: Yousef Date: Mon, 27 Jan 2025 21:24:23 +0100 Subject: [PATCH 26/30] feat: add factory function for extensions (#1390) --- packages/core/src/editor/BlockNoteEditor.ts | 32 +++++++++++++-------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index a7b78cdaba..e2ae4a7256 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -9,12 +9,6 @@ import { import { Node, Schema } from "prosemirror-model"; // import "./blocknote.css"; import * as Y from "yjs"; -import { - getBlock, - getNextBlock, - getParentBlock, - getPrevBlock, -} from "../api/blockManipulation/getBlock/getBlock.js"; import { insertBlocks } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import { moveBlocksDown, @@ -29,15 +23,21 @@ import { 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"; -import { insertContentAt } from "../api/blockManipulation/insertContentAt.js"; import { - getTextCursorPosition, - setTextCursorPosition, -} from "../api/blockManipulation/selections/textCursorPosition/textCursorPosition.js"; + getBlock, + getNextBlock, + getParentBlock, + getPrevBlock, +} from "../api/blockManipulation/getBlock/getBlock.js"; +import { insertContentAt } from "../api/blockManipulation/insertContentAt.js"; import { getSelection, setSelection, } from "../api/blockManipulation/selections/selection.js"; +import { + getTextCursorPosition, + setTextCursorPosition, +} from "../api/blockManipulation/selections/textCursorPosition/textCursorPosition.js"; import { createExternalHTMLExporter } from "../api/exporters/html/externalHTMLExporter.js"; import { blocksToMarkdown } from "../api/exporters/markdown/markdownExporter.js"; import { HTMLToBlocks } from "../api/parsers/html/parseHTML.js"; @@ -89,11 +89,15 @@ import { en } from "../i18n/locales/index.js"; import { Plugin, Transaction } from "@tiptap/pm/state"; import { dropCursor } from "prosemirror-dropcursor"; +import { EditorView } from "prosemirror-view"; import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js"; import { inlineContentToNodes } from "../api/nodeConversions/blockToNode.js"; import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js"; import "../style.css"; -import { EditorView } from "prosemirror-view"; + +export type BlockNoteExtensionFactory = ( + editor: BlockNoteEditor +) => BlockNoteExtension; export type BlockNoteExtension = | AnyExtension @@ -213,7 +217,7 @@ export type BlockNoteEditorOptions< /** * (experimental) add extra prosemirror plugins or tiptap extensions to the editor */ - _extensions: Record; + _extensions: Record; trailingBlock?: boolean; @@ -449,6 +453,10 @@ export class BlockNoteEditor< // add extensions from options Object.entries(newOptions._extensions || {}).forEach(([key, ext]) => { + if (typeof ext === "function") { + // factory + ext = ext(this); + } this.extensions[key] = ext; }); From 4d2fe517e1130a57aacf3ee514be1bd238600ac2 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 28 Jan 2025 00:17:16 +0100 Subject: [PATCH 27/30] fix: Side menu scrolling (#1394) --- .../core/src/extensions/SideMenu/SideMenuPlugin.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts index 62a1541c8e..4013a8846a 100644 --- a/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts +++ b/packages/core/src/extensions/SideMenu/SideMenuPlugin.ts @@ -187,6 +187,11 @@ export class SideMenuView< this.onKeyDown as EventListener, true ); + + // Setting capture=true ensures that any parent container of the editor that + // gets scrolled will trigger the scroll event. Scroll events do not bubble + // and so won't propagate to the document by default. + pmView.root.addEventListener("scroll", this.onScroll, true); } updateState = (state: SideMenuState) => { @@ -473,6 +478,13 @@ export class SideMenuView< return evt; } + onScroll = () => { + if (this.state?.show) { + this.state.referencePos = this.hoveredBlock!.getBoundingClientRect(); + this.emitUpdate(this.state); + } + }; + // 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 @@ -515,6 +527,7 @@ export class SideMenuView< this.onKeyDown as EventListener, true ); + this.pmView.root.removeEventListener("scroll", this.onScroll, true); } } From eefb4a1463b9a4ba112e09c18eda6824bfcbb731 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 28 Jan 2025 00:31:26 +0100 Subject: [PATCH 28/30] v0.23.1 --- docs/package.json | 14 ++-- lerna.json | 2 +- package-lock.json | 94 +++++++++++++------------- packages/ariakit/package.json | 6 +- packages/core/package.json | 2 +- packages/mantine/package.json | 6 +- packages/react/package.json | 4 +- packages/server-util/package.json | 6 +- packages/shadcn/package.json | 6 +- packages/xl-docx-exporter/package.json | 4 +- packages/xl-multi-column/package.json | 6 +- packages/xl-pdf-exporter/package.json | 6 +- playground/package.json | 16 ++--- shared/package.json | 4 +- tests/package.json | 12 ++-- 15 files changed, 94 insertions(+), 94 deletions(-) diff --git a/docs/package.json b/docs/package.json index 5e80b51c76..aa31da5938 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,6 +1,6 @@ { "name": "docs", - "version": "0.23.0", + "version": "0.23.1", "private": true, "scripts": { "dev": "next dev", @@ -9,12 +9,12 @@ "lint": "next lint" }, "dependencies": { - "@blocknote/ariakit": "^0.23.0", - "@blocknote/core": "^0.23.0", - "@blocknote/mantine": "^0.23.0", - "@blocknote/react": "^0.23.0", - "@blocknote/shadcn": "^0.23.0", - "@blocknote/xl-multi-column": "^0.23.0", + "@blocknote/ariakit": "^0.23.1", + "@blocknote/core": "^0.23.1", + "@blocknote/mantine": "^0.23.1", + "@blocknote/react": "^0.23.1", + "@blocknote/shadcn": "^0.23.1", + "@blocknote/xl-multi-column": "^0.23.1", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.4", "@mantine/core": "^7.10.1", diff --git a/lerna.json b/lerna.json index 6fe199c78b..43751f86a7 100644 --- a/lerna.json +++ b/lerna.json @@ -2,5 +2,5 @@ "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useNx": false, "useWorkspaces": true, - "version": "0.23.0" + "version": "0.23.1" } diff --git a/package-lock.json b/package-lock.json index 1a46bc20b3..5b3c1fb170 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,14 +25,14 @@ } }, "docs": { - "version": "0.23.0", - "dependencies": { - "@blocknote/ariakit": "^0.23.0", - "@blocknote/core": "^0.23.0", - "@blocknote/mantine": "^0.23.0", - "@blocknote/react": "^0.23.0", - "@blocknote/shadcn": "^0.23.0", - "@blocknote/xl-multi-column": "^0.23.0", + "version": "0.23.1", + "dependencies": { + "@blocknote/ariakit": "^0.23.1", + "@blocknote/core": "^0.23.1", + "@blocknote/mantine": "^0.23.1", + "@blocknote/react": "^0.23.1", + "@blocknote/shadcn": "^0.23.1", + "@blocknote/xl-multi-column": "^0.23.1", "@headlessui/react": "^1.7.18", "@heroicons/react": "^2.1.4", "@mantine/core": "^7.10.1", @@ -30143,12 +30143,12 @@ }, "packages/ariakit": { "name": "@blocknote/ariakit", - "version": "0.23.0", + "version": "0.23.1", "license": "MPL-2.0", "dependencies": { "@ariakit/react": "^0.4.3", - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0" + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1" }, "devDependencies": { "@types/react": "^18.0.25", @@ -30187,7 +30187,7 @@ }, "packages/core": { "name": "@blocknote/core", - "version": "0.23.0", + "version": "0.23.1", "license": "MPL-2.0", "dependencies": { "@emoji-mart/data": "^1.2.1", @@ -30361,11 +30361,11 @@ }, "packages/mantine": { "name": "@blocknote/mantine", - "version": "0.23.0", + "version": "0.23.1", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@mantine/core": "^7.10.1", "@mantine/hooks": "^7.10.1", "@mantine/utils": "^6.0.21", @@ -30409,10 +30409,10 @@ }, "packages/react": { "name": "@blocknote/react", - "version": "0.23.0", + "version": "0.23.1", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.23.0", + "@blocknote/core": "^0.23.1", "@floating-ui/react": "^0.26.4", "@tiptap/core": "^2.7.1", "@tiptap/react": "^2.7.1", @@ -30460,11 +30460,11 @@ }, "packages/server-util": { "name": "@blocknote/server-util", - "version": "0.23.0", + "version": "0.23.1", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@tiptap/core": "^2.7.1", "@tiptap/pm": "^2.7.1", "jsdom": "^25.0.1", @@ -30491,11 +30491,11 @@ }, "packages/shadcn": { "name": "@blocknote/shadcn", - "version": "0.23.0", + "version": "0.23.1", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@hookform/resolvers": "^3.6.0", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", @@ -30555,10 +30555,10 @@ }, "packages/xl-docx-exporter": { "name": "@blocknote/xl-docx-exporter", - "version": "0.23.0", + "version": "0.23.1", "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { - "@blocknote/core": "^0.23.0", + "@blocknote/core": "^0.23.1", "buffer": "^6.0.3", "docx": "^9.0.2", "sharp": "^0.33.5" @@ -30605,11 +30605,11 @@ }, "packages/xl-multi-column": { "name": "@blocknote/xl-multi-column", - "version": "0.23.0", + "version": "0.23.1", "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@tiptap/core": "^2.7.1", "prosemirror-model": "^1.23.0", "prosemirror-state": "^1.4.3", @@ -30844,11 +30844,11 @@ }, "packages/xl-pdf-exporter": { "name": "@blocknote/xl-pdf-exporter", - "version": "0.23.0", + "version": "0.23.1", "license": "AGPL-3.0 OR PROPRIETARY", "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@react-pdf/renderer": "^4.0.0", "buffer": "^6.0.3", "docx": "^9.0.2" @@ -30932,17 +30932,17 @@ }, "playground": { "name": "@blocknote/example-editor", - "version": "0.23.0", + "version": "0.23.1", "dependencies": { "@aws-sdk/client-s3": "^3.609.0", "@aws-sdk/s3-request-presigner": "^3.609.0", - "@blocknote/ariakit": "^0.23.0", - "@blocknote/core": "^0.23.0", - "@blocknote/mantine": "^0.23.0", - "@blocknote/react": "^0.23.0", - "@blocknote/server-util": "^0.23.0", - "@blocknote/shadcn": "^0.23.0", - "@blocknote/xl-multi-column": "^0.23.0", + "@blocknote/ariakit": "^0.23.1", + "@blocknote/core": "^0.23.1", + "@blocknote/mantine": "^0.23.1", + "@blocknote/react": "^0.23.1", + "@blocknote/server-util": "^0.23.1", + "@blocknote/shadcn": "^0.23.1", + "@blocknote/xl-multi-column": "^0.23.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@liveblocks/client": "^1.10.0", @@ -31000,9 +31000,9 @@ }, "shared": { "name": "@blocknote/shared", - "version": "0.23.0", + "version": "0.23.1", "dependencies": { - "@blocknote/core": "^0.23.0" + "@blocknote/core": "^0.23.1" }, "devDependencies": { "typescript": "^5.3.3" @@ -31010,17 +31010,17 @@ }, "tests": { "name": "@blocknote/tests", - "version": "0.23.0", + "version": "0.23.1", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "@blocknote/ariakit": "^0.23.0", - "@blocknote/core": "^0.23.0", - "@blocknote/mantine": "^0.23.0", - "@blocknote/react": "^0.23.0", - "@blocknote/shadcn": "^0.23.0", + "@blocknote/ariakit": "^0.23.1", + "@blocknote/core": "^0.23.1", + "@blocknote/mantine": "^0.23.1", + "@blocknote/react": "^0.23.1", + "@blocknote/shadcn": "^0.23.1", "@playwright/experimental-ct-react": "^1.49.1", "@playwright/test": "^1.49.1", "eslint": "^8.10.0", diff --git a/packages/ariakit/package.json b/packages/ariakit/package.json index 7134ff5506..d289e74e75 100644 --- a/packages/ariakit/package.json +++ b/packages/ariakit/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -51,8 +51,8 @@ }, "dependencies": { "@ariakit/react": "^0.4.3", - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0" + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1" }, "devDependencies": { "@types/react": "^18.0.25", diff --git a/packages/core/package.json b/packages/core/package.json index feacca4a0b..92ec332e00 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", diff --git a/packages/mantine/package.json b/packages/mantine/package.json index 933c672469..588fdc0620 100644 --- a/packages/mantine/package.json +++ b/packages/mantine/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -50,8 +50,8 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@mantine/core": "^7.10.1", "@mantine/hooks": "^7.10.1", "@mantine/utils": "^6.0.21", diff --git a/packages/react/package.json b/packages/react/package.json index 1f73404a2c..d7be35c21c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -52,7 +52,7 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.23.0", + "@blocknote/core": "^0.23.1", "@floating-ui/react": "^0.26.4", "@tiptap/core": "^2.7.1", "@tiptap/react": "^2.7.1", diff --git a/packages/server-util/package.json b/packages/server-util/package.json index 53c83c7834..04f682a3fc 100644 --- a/packages/server-util/package.json +++ b/packages/server-util/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -50,8 +50,8 @@ "test-watch": "vitest watch" }, "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@tiptap/core": "^2.7.1", "@tiptap/pm": "^2.7.1", "jsdom": "^25.0.1", diff --git a/packages/shadcn/package.json b/packages/shadcn/package.json index d557764f5b..da838ba4cd 100644 --- a/packages/shadcn/package.json +++ b/packages/shadcn/package.json @@ -6,7 +6,7 @@ "*.css" ], "license": "MPL-2.0", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -50,8 +50,8 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@hookform/resolvers": "^3.6.0", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", diff --git a/packages/xl-docx-exporter/package.json b/packages/xl-docx-exporter/package.json index e809015e30..e42ef0c05f 100644 --- a/packages/xl-docx-exporter/package.json +++ b/packages/xl-docx-exporter/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/TypeCellOS/BlockNote", "private": false, "license": "AGPL-3.0 OR PROPRIETARY", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -50,7 +50,7 @@ "email": "email dev" }, "dependencies": { - "@blocknote/core": "^0.23.0", + "@blocknote/core": "^0.23.1", "buffer": "^6.0.3", "docx": "^9.0.2", "sharp": "^0.33.5" diff --git a/packages/xl-multi-column/package.json b/packages/xl-multi-column/package.json index 12abf41d47..fc34c65c0a 100644 --- a/packages/xl-multi-column/package.json +++ b/packages/xl-multi-column/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/TypeCellOS/BlockNote", "private": false, "license": "AGPL-3.0 OR PROPRIETARY", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -45,8 +45,8 @@ "clean": "rimraf dist && rimraf types" }, "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@tiptap/core": "^2.7.1", "prosemirror-model": "^1.23.0", "prosemirror-state": "^1.4.3", diff --git a/packages/xl-pdf-exporter/package.json b/packages/xl-pdf-exporter/package.json index 3b5a4ea973..426ab2b500 100644 --- a/packages/xl-pdf-exporter/package.json +++ b/packages/xl-pdf-exporter/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/TypeCellOS/BlockNote", "private": false, "license": "AGPL-3.0 OR PROPRIETARY", - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -49,8 +49,8 @@ "email": "email dev" }, "dependencies": { - "@blocknote/core": "^0.23.0", - "@blocknote/react": "^0.23.0", + "@blocknote/core": "^0.23.1", + "@blocknote/react": "^0.23.1", "@react-pdf/renderer": "^4.0.0", "buffer": "^6.0.3", "docx": "^9.0.2" diff --git a/playground/package.json b/playground/package.json index c09b0bf5a6..c327a2fd2a 100644 --- a/playground/package.json +++ b/playground/package.json @@ -1,7 +1,7 @@ { "name": "@blocknote/example-editor", "private": true, - "version": "0.23.0", + "version": "0.23.1", "scripts": { "dev": "vite --host", "build": "tsc && vite build", @@ -12,13 +12,13 @@ "dependencies": { "@aws-sdk/client-s3": "^3.609.0", "@aws-sdk/s3-request-presigner": "^3.609.0", - "@blocknote/ariakit": "^0.23.0", - "@blocknote/core": "^0.23.0", - "@blocknote/mantine": "^0.23.0", - "@blocknote/react": "^0.23.0", - "@blocknote/server-util": "^0.23.0", - "@blocknote/shadcn": "^0.23.0", - "@blocknote/xl-multi-column": "^0.23.0", + "@blocknote/ariakit": "^0.23.1", + "@blocknote/core": "^0.23.1", + "@blocknote/mantine": "^0.23.1", + "@blocknote/react": "^0.23.1", + "@blocknote/server-util": "^0.23.1", + "@blocknote/shadcn": "^0.23.1", + "@blocknote/xl-multi-column": "^0.23.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@liveblocks/client": "^1.10.0", diff --git a/shared/package.json b/shared/package.json index 208e6af209..a508a0573a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -2,7 +2,7 @@ "name": "@blocknote/shared", "homepage": "https://github.com/TypeCellOS/BlockNote", "private": true, - "version": "0.23.0", + "version": "0.23.1", "files": [ "dist", "types", @@ -13,7 +13,7 @@ "clean": "tsc --build --clean" }, "dependencies": { - "@blocknote/core": "^0.23.0" + "@blocknote/core": "^0.23.1" }, "devDependencies": { "typescript": "^5.3.3" diff --git a/tests/package.json b/tests/package.json index f9b10eb791..8960ae885b 100644 --- a/tests/package.json +++ b/tests/package.json @@ -1,7 +1,7 @@ { "name": "@blocknote/tests", "private": true, - "version": "0.23.0", + "version": "0.23.1", "scripts": { "build": "tsc", "lint": "eslint src --max-warnings 0", @@ -17,11 +17,11 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@blocknote/ariakit": "^0.23.0", - "@blocknote/core": "^0.23.0", - "@blocknote/mantine": "^0.23.0", - "@blocknote/react": "^0.23.0", - "@blocknote/shadcn": "^0.23.0", + "@blocknote/ariakit": "^0.23.1", + "@blocknote/core": "^0.23.1", + "@blocknote/mantine": "^0.23.1", + "@blocknote/react": "^0.23.1", + "@blocknote/shadcn": "^0.23.1", "@playwright/experimental-ct-react": "^1.49.1", "@playwright/test": "^1.49.1", "eslint": "^8.10.0", From b4b3cd768fbab8f4701fa9fa59f862ac1bbd2db9 Mon Sep 17 00:00:00 2001 From: Matthew Lipski <50169049+matthewlipski@users.noreply.github.com> Date: Tue, 28 Jan 2025 16:42:50 +0100 Subject: [PATCH 29/30] docs: Default blocks example fix (#1397) --- examples/01-basic/04-default-blocks/App.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/examples/01-basic/04-default-blocks/App.tsx b/examples/01-basic/04-default-blocks/App.tsx index 1a47d8a1cf..935e96d93e 100644 --- a/examples/01-basic/04-default-blocks/App.tsx +++ b/examples/01-basic/04-default-blocks/App.tsx @@ -138,13 +138,5 @@ export default function App() { }); // Renders the editor instance using a React component. - return ( -
- -
- ); + return ; } From a5e07c09066be25592183bbf3c8a81da54e352fa Mon Sep 17 00:00:00 2001 From: matthewlipski Date: Wed, 29 Jan 2025 00:48:24 +0100 Subject: [PATCH 30/30] Updated reactions UX --- package-lock.json | 11 ++ .../Comments/threadstore/YjsThreadStore.ts | 2 +- packages/mantine/src/Badge/Badge.tsx | 40 ----- packages/mantine/src/badge/Badge.tsx | 76 ++++++++ packages/mantine/src/components.tsx | 2 +- packages/mantine/src/style.css | 40 ++++- .../mantine/src/toolbar/ToolbarButton.tsx | 8 +- packages/react/package.json | 3 + .../react/src/components/Comments/Comment.tsx | 165 +++++++++--------- .../react/src/editor/ComponentsContext.tsx | 2 + 10 files changed, 219 insertions(+), 130 deletions(-) delete mode 100644 packages/mantine/src/Badge/Badge.tsx create mode 100644 packages/mantine/src/badge/Badge.tsx diff --git a/package-lock.json b/package-lock.json index 1a862c1d96..566a3b1140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3648,6 +3648,15 @@ "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz", "integrity": "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==" }, + "node_modules/@emoji-mart/react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz", + "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==", + "peerDependencies": { + "emoji-mart": "^5.2", + "react": "^16.8 || ^17 || ^18" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", @@ -31907,6 +31916,8 @@ "license": "MPL-2.0", "dependencies": { "@blocknote/core": "^0.23.1", + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@floating-ui/react": "^0.26.4", "@tiptap/core": "^2.7.1", "@tiptap/react": "^2.7.1", diff --git a/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts index aad1354c87..aea14335f3 100644 --- a/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts +++ b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts @@ -304,7 +304,7 @@ export class YjsThreadStore extends ThreadStore { const reaction: CommentReactionData = { emoji: options.reaction, createdAt: date, - usersIds: [this.userId, "fesfes"], + usersIds: [this.userId], }; yComment.get("reactions").push([reactionToYMap(reaction)]); diff --git a/packages/mantine/src/Badge/Badge.tsx b/packages/mantine/src/Badge/Badge.tsx deleted file mode 100644 index 9d3a213c74..0000000000 --- a/packages/mantine/src/Badge/Badge.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { forwardRef } from "react"; -import { ComponentProps } from "@blocknote/react"; -import { Chip, Group } from "@mantine/core"; -import { assertEmpty } from "@blocknote/core"; - -export const Badge = forwardRef< - HTMLInputElement, - ComponentProps["Generic"]["Badge"]["Root"] ->((props, ref) => { - const { className, text, icon, isSelected, onClick, ...rest } = props; - - assertEmpty(rest); - - return ( - - {icon} - {text} - - ); -}); - -export const BadgeGroup = forwardRef< - HTMLDivElement, - ComponentProps["Generic"]["Badge"]["Group"] ->((props, ref) => { - const { className, children, ...rest } = props; - - assertEmpty(rest); - - return ( - - {children} - - ); -}); diff --git a/packages/mantine/src/badge/Badge.tsx b/packages/mantine/src/badge/Badge.tsx new file mode 100644 index 0000000000..213a05316f --- /dev/null +++ b/packages/mantine/src/badge/Badge.tsx @@ -0,0 +1,76 @@ +import { forwardRef } from "react"; +import { ComponentProps } from "@blocknote/react"; +import { + Chip as MantineChip, + Group as MantineGroup, + Tooltip as MantineTooltip, +} from "@mantine/core"; +import { assertEmpty } from "@blocknote/core"; + +import { TooltipContent } from "../toolbar/ToolbarButton.js"; + +export const Badge = forwardRef< + HTMLInputElement, + ComponentProps["Generic"]["Badge"]["Root"] +>((props, ref) => { + const { + className, + text, + icon, + isSelected, + mainTooltip, + secondaryTooltip, + onClick, + ...rest + } = props; + + // false, because rest props can be added by mantine when chip is used as a trigger + // assertEmpty in this case is only used at typescript level, not runtime level + assertEmpty(rest, false); + + const badge = ( + + {icon} + {text} + + ); + + if (!mainTooltip) { + return badge; + } + + return ( + + } + multiline> + {badge} + + ); +}); + +export const BadgeGroup = forwardRef< + HTMLDivElement, + ComponentProps["Generic"]["Badge"]["Group"] +>((props, ref) => { + const { className, children, ...rest } = props; + + assertEmpty(rest); + + return ( + + {children} + + ); +}); diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx index 74a5b93b35..f39e4b89c3 100644 --- a/packages/mantine/src/components.tsx +++ b/packages/mantine/src/components.tsx @@ -1,6 +1,6 @@ import { Components } from "@blocknote/react"; -import { Badge, BadgeGroup } from "./Badge/Badge.js"; +import { Badge, BadgeGroup } from "./badge/Badge.js"; import { Card, CardSection } from "./comments/Card.js"; import { Comment } from "./comments/Comment.js"; import { Editor } from "./comments/Editor.js"; diff --git a/packages/mantine/src/style.css b/packages/mantine/src/style.css index 495f845bb9..5692c4dbea 100644 --- a/packages/mantine/src/style.css +++ b/packages/mantine/src/style.css @@ -549,6 +549,7 @@ color: var(--bn-colors-tooltip-text); padding: 4px 10px; text-align: center; + white-space: pre-wrap; } /* Additional menu styles */ @@ -596,11 +597,48 @@ .bn-mantine .bn-comment-actions-wrapper { width: 100%; display: flex; - align-items: flex-end; justify-content: flex-end; } +.bn-mantine .bn-badge-group { + display: flex; + gap: 4px; + justify-content: flex-start; + width: 100%; +} + +.bn-mantine .bn-badge { + flex-grow: 0; +} + +.bn-mantine .bn-badge .mantine-Chip-label { + + padding: 0 8px; +} + +.bn-mantine .bn-badge .mantine-Chip-label:not([data-checked="true"]) { + background-color: var(--bn-colors-menu-background); + border: var(--bn-border); + color: var(--bn-colors-menu-text); +} + +.bn-mantine .bn-badge .mantine-Chip-label > span:not(.mantine-Chip-iconWrapper) { + display: inline-flex; + gap: 4px; +} + +.bn-mantine .bn-badge .mantine-Chip-label > span:not(.mantine-Chip-iconWrapper) > span { + align-items: center; + display: inline-flex; + justify-content: center; +} + +.bn-mantine .bn-badge .mantine-Chip-iconWrapper { + display: none; +} + .bn-mantine .bn-action-toolbar { + align-self: flex-end; background-color: var(--bn-colors-menu-background); border: var(--bn-border); border-radius: var(--bn-border-radius-medium); diff --git a/packages/mantine/src/toolbar/ToolbarButton.tsx b/packages/mantine/src/toolbar/ToolbarButton.tsx index 4f2fd9e5fc..29dea8ee48 100644 --- a/packages/mantine/src/toolbar/ToolbarButton.tsx +++ b/packages/mantine/src/toolbar/ToolbarButton.tsx @@ -15,9 +15,13 @@ export const TooltipContent = (props: { secondaryTooltip?: string; }) => ( - {props.mainTooltip} + + {props.mainTooltip} + {props.secondaryTooltip && ( - {props.secondaryTooltip} + + {props.secondaryTooltip} + )} ); diff --git a/packages/react/package.json b/packages/react/package.json index d7be35c21c..0fb8a7eaba 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -53,6 +53,8 @@ }, "dependencies": { "@blocknote/core": "^0.23.1", + "@emoji-mart/data": "^1.2.1", + "@emoji-mart/react": "^1.1.1", "@floating-ui/react": "^0.26.4", "@tiptap/core": "^2.7.1", "@tiptap/react": "^2.7.1", @@ -60,6 +62,7 @@ "react-icons": "^5.2.1" }, "devDependencies": { + "@types/emoji-mart": "^3.0.14", "@types/lodash.foreach": "^4.5.9", "@types/lodash.groupby": "^4.6.9", "@types/lodash.merge": "^4.6.9", diff --git a/packages/react/src/components/Comments/Comment.tsx b/packages/react/src/components/Comments/Comment.tsx index 4dda62d608..0b2cb1c863 100644 --- a/packages/react/src/components/Comments/Comment.tsx +++ b/packages/react/src/components/Comments/Comment.tsx @@ -1,12 +1,9 @@ "use client"; -import { - CommentData, - ThreadData, - mergeCSSClasses, - getDefaultEmojiPickerItems, - DefaultGridSuggestionItem, -} from "@blocknote/core"; +import { CommentData, ThreadData, mergeCSSClasses } from "@blocknote/core"; +import data from "@emoji-mart/data"; +import Picker from "@emoji-mart/react"; +import type { EmojiData } from "emoji-mart"; import { ComponentPropsWithoutRef, MouseEvent, @@ -14,7 +11,6 @@ import { useCallback, useEffect, useState, - useRef, } from "react"; import { RiArrowGoBackFill, @@ -25,11 +21,11 @@ import { RiMoreFill, } from "react-icons/ri"; +import { useBlockNoteContext } from "../../editor/BlockNoteContext.js"; import { useComponentsContext } from "../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; import { useCreateBlockNote } from "../../hooks/useCreateBlockNote.js"; import { useDictionary } from "../../i18n/dictionary.js"; -import { GridSuggestionMenu } from "../SuggestionMenu/GridSuggestionMenu/GridSuggestionMenu.js"; import { CommentEditor } from "./CommentEditor.js"; import { schema } from "./schema.js"; import { useUser } from "./useUsers.js"; @@ -325,15 +321,7 @@ export const Comment = ({ const user = useUser(editor, comment.userId); - // TODO: Change emoji picker implementation to premade component? - const emojis = useRef(undefined); - useEffect(() => { - const getEmojis = async () => { - emojis.current = await getDefaultEmojiPickerItems(editor, ""); - }; - - getEmojis(); - }, [editor]); + const blockNoteContext = useBlockNoteContext(); if (!showDeleted && !comment.body) { return null; @@ -365,18 +353,12 @@ export const Comment = ({
- {/* TODO: Change emoji picker implementation to premade component? */} - ({ - ...item, - icon: <>{item.id}, - })) || [] + + onReactionSelect(emoji.native) } - loadingState={"loaded"} - selectedIndex={-1} - columns={6} - onItemClick={(item) => onReactionSelect(item.id)} + theme={blockNoteContext?.colorSchemePreference} /> @@ -443,73 +425,86 @@ export const Comment = ({ timeString={timeString} showActions={showActions} actions={actions}> - {isEditing ? ( + {comment.body ? ( <> ( - - - Save - - - Cancel - - - )} - /> - - ) : comment.body ? ( - <> - 0 - ? () => ( - - {comment.reactions.map((reaction) => ( + <> + {showReactions && comment.reactions.length > 0 && ( + + {comment.reactions.map((reaction) => ( + onReactionSelect(reaction.emoji)} + mainTooltip={"Reacted by"} + secondaryTooltip={`${reaction.usersIds.map( + (userId) => userId + "\n" + )}`} + /> + ))} + + } + /> + + + + onReactionSelect(emoji.native) } - onClick={() => - onReactionSelect(reaction.emoji) - }> - ))} - - ) - : undefined - } + theme={blockNoteContext?.colorSchemePreference} + /> + + + + )} + {isEditing && ( + + + Save + + + Cancel + + + )} + + )} /> - - {showReactions && comment.reactions.length > 0 && ( -
- )} ) : ( // Soft deletes diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx index f8b5842c2b..6d5194ab23 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -190,6 +190,8 @@ export type ComponentProps = { text: string; icon?: ReactNode; isSelected?: boolean; + mainTooltip?: string; + secondaryTooltip?: string; onClick?: () => void; }; Group: {