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? */}
+
+
+ Submit
+
+
+
+ );
+});
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 (
+
+ );
+};
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 (
+
+ );
+};
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 (
+//
+//
+// {reaction.users.length}
+//
+// );
+// });
+
+// 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 (
+
+ );
+ }
+);
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 (
);
};
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 (
+
+ //
+ //
+ // {thread.resolved ? (
+ //
+ // ) : (
+ //
+ // )}
+ //
+ //
+ //
+ // ) : 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 Numbered 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 Numbered List Item
console.log("Hello World");
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
Table Cell
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 (
-
+ )}
+ >
+ ) : (
+
+ {/*
{$.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 (
);
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? */}
-
-
- Submit
-
-
-
- );
-});
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
+
+ )}
+
+ );
+};
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 (
);
};
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 (
+
+ //
+ //
+ // {thread.resolved ? (
+ //
+ // ) : (
+ //
+ // )}
+ //
+ //
+ //
+ // ) : null
+ // }
+ />
+ );
+ })}
+
+
+ {
+ if (!isFocused && isEmpty) {
+ return null;
+ }
return (
-
- //
- //
- // {thread.resolved ? (
- //
- // ) : (
- //
- // )}
- //
- //
- //
- // ) : 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 (
- //
- //
- // {thread.resolved ? (
- //
- // ) : (
- //
- // )}
- //
- //
- //
- // ) : 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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