diff --git a/examples/01-basic/01-minimal/App.tsx b/examples/01-basic/01-minimal/App.tsx index a3b92bafd..d4fd6f2e1 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 1bd8ab3c5..4a76cf4c7 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 a1ed9a79e..6da0a2ab6 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 4d73393dd..455499b01 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 54849c2b6..e699fd478 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:not([data-orphan="true"]) { + background: rgba(255, 200, 0, 0.15); +} + +.bn-thread-mark:not([data-orphan="true"]) .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 0231b4773..9b19014c8 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 b6ce68a7c..33052cb24 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 ba9dffb39..248c5313e 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -10,6 +10,15 @@ --N40: #dfe1e6; /* Light neutral used for subtle borders and text on dark background */ } +.bn-comment-editor { + width: 100%; + padding: 0; +} + +.bn-comment-editor .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 000000000..3b4f2e8fe --- /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-bn-thread-id"), + renderHTML: (attributes) => { + return { + "data-bn-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 000000000..ed71492da --- /dev/null +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -0,0 +1,278 @@ +import { Node } from "prosemirror-model"; +import { Plugin, PluginKey } from "prosemirror-state"; +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"; +import { UserStore } from "./userstore/UserStore.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 | undefined, + 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), + threadPositions, + }; +} + +export class CommentsPlugin extends EventEmitter { + public readonly plugin: Plugin; + public readonly store: ThreadStore; + private pendingComment = false; + private selectedThreadId: string | undefined; + + private emitStateUpdate() { + this.emit("update", { + selectedThreadId: this.selectedThreadId, + pendingComment: this.pendingComment, + }); + } + + /** + * 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, + 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(); + + const doc = new Y.Doc(); + this.store = new YjsThreadStore( + editor, + "blablauserid", + doc.getMap("threads"), + new DefaultThreadStoreAuth("blablauserid", "comment") + ); + + // TODO: unsubscribe + this.store.subscribe(this.updateMarksFromThreads); + + // initial + this.updateMarksFromThreads(this.store.getThreads()); + + // TODO: remove settimeout + setTimeout(() => { + editor.onSelectionChange(() => { + // TODO: filter out yjs transactions + if (this.pendingComment) { + this.pendingComment = false; + this.emitStateUpdate(); + } + }); + }, 600); + + const self = this; + + this.plugin = new Plugin({ + key: PLUGIN_KEY, + state: { + init() { + return { + threadPositions: new Map(), + decorations: DecorationSet.empty, + } satisfies CommentsPluginState; + }, + apply(tr, state) { + const action = tr.getMeta(PLUGIN_KEY) as CommentsPluginAction; + if (!tr.docChanged && !action) { + return state; + } + + // Doc changed, but no action, just update rects + return updateState(tr.doc, self.selectedThreadId, markType); + }, + }, + props: { + decorations(state) { + return PLUGIN_KEY.getState(state)?.decorations ?? DecorationSet.empty; + }, + handleClick: (view, pos, event) => { + if (event.button !== 0) { + return; + } + + const selectThread = (threadId: string | undefined) => { + self.selectedThreadId = threadId; + self.emitStateUpdate(); + view.dispatch( + view.state.tr.setMeta(PLUGIN_KEY, { + name: CommentsPluginActions.SET_SELECTED_THREAD_ID, + }) + ); + }; + + const node = view.state.doc.nodeAt(pos); + if (!node) { + selectThread(undefined); + return; + } + const commentMark = node.marks.find( + (mark) => mark.type.name === markType + ); + // don't allow selecting orphaned threads + if (commentMark?.attrs.orphan) { + selectThread(undefined); + return; + } + const threadId = commentMark?.attrs.threadId as string | undefined; + selectThread(threadId); + }, + }, + }); + } + + public onUpdate( + callback: (state: { + pendingComment: boolean; + selectedThreadId: string | undefined; + }) => void + ) { + return this.on("update", callback); + } + + public startPendingComment() { + this.pendingComment = true; + this.emitStateUpdate(); + } + + public stopPendingComment() { + this.pendingComment = false; + this.emitStateUpdate(); + } + + 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, + }); + } +} 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 000000000..4faf24011 --- /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/LiveBlocksThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/LiveBlocksThreadStore.ts new file mode 100644 index 000000000..d7b28a527 --- /dev/null +++ b/packages/core/src/extensions/Comments/threadstore/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/threadstore/ThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts new file mode 100644 index 000000000..936288d46 --- /dev/null +++ b/packages/core/src/extensions/Comments/threadstore/ThreadStore.ts @@ -0,0 +1,101 @@ +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. + */ +export abstract class ThreadStore { + public readonly auth: ThreadStoreAuth; + + constructor(auth: ThreadStoreAuth) { + this.auth = auth; + } + + /** + * Creates a new thread with an initial comment. + */ + abstract createThread(options: { + initialComment: { + body: CommentBody; + metadata?: any; + }; + metadata?: any; + }): Promise; + + /** + * Adds a comment to a thread. + */ + abstract addComment(options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + }): Promise; + + /** + * Updates a comment in a thread. + */ + abstract updateComment(options: { + comment: { + body: CommentBody; + metadata?: any; + }; + threadId: string; + commentId: string; + }): Promise; + + /** + * Deletes a comment from a thread. + */ + abstract deleteComment(options: { + threadId: string; + commentId: string; + }): Promise; + + /** + * Deletes a thread. + */ + abstract deleteThread(options: { threadId: string }): Promise; + + /** + * Marks a thread as resolved. + */ + abstract resolveThread(options: { threadId: string }): Promise; + + /** + * Marks a thread as unresolved. + */ + 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; + 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/threadstore/ThreadStoreAuth.ts b/packages/core/src/extensions/Comments/threadstore/ThreadStoreAuth.ts new file mode 100644 index 000000000..57031c015 --- /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/TipTapThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/TipTapThreadStore.ts new file mode 100644 index 000000000..8d068a37c --- /dev/null +++ b/packages/core/src/extensions/Comments/threadstore/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/threadstore/YjsThreadStore.test.ts b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.test.ts new file mode 100644 index 000000000..d3f939e57 --- /dev/null +++ b/packages/core/src/extensions/Comments/threadstore/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/threadstore/YjsThreadStore.ts b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts new file mode 100644 index 000000000..91e90d483 --- /dev/null +++ b/packages/core/src/extensions/Comments/threadstore/YjsThreadStore.ts @@ -0,0 +1,364 @@ +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"; +import { ThreadStoreAuth } from "./ThreadStoreAuth.js"; + +export class YjsThreadStore extends ThreadStore { + constructor( + private readonly editor: BlockNoteEditor, + private readonly userId: string, + private readonly threadsYMap: Y.Map, + auth: ThreadStoreAuth + ) { + super(auth); + } + + 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; + }) => { + if (!this.auth.canCreateThread()) { + throw new Error("Not authorized"); + } + + const date = new Date(); + + const comment: CommentData = { + type: "comment", + id: v4(), + userId: this.userId, + createdAt: date, + updatedAt: date, + reactions: [], + metadata: options.initialComment.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"); + } + + if (!this.auth.canAddComment(yMapToThread(yThread))) { + throw new Error("Not authorized"); + } + + 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); + + 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); + } + ); + + 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 (!this.auth.canDeleteComment(yMapToComment(yComment))) { + throw new Error("Not authorized"); + } + + 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 }) => { + if ( + !this.auth.canDeleteThread( + yMapToThread(this.threadsYMap.get(options.threadId)) + ) + ) { + throw new Error("Not authorized"); + } + + 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"); + } + + if (!this.auth.canResolveThread(yMapToThread(yThread))) { + throw new Error("Not authorized"); + } + + 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"); + } + + if (!this.auth.canUnresolveThread(yMapToThread(yThread))) { + throw new Error("Not authorized"); + } + + 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 new file mode 100644 index 000000000..c50f769ec --- /dev/null +++ b/packages/core/src/extensions/Comments/types.ts @@ -0,0 +1,45 @@ +export type CommentBody = any; + +export type CommentReactionData = { + emoji: string; + createdAt: Date; + usersIds: string[]; +}; + +export type CommentData = { + type: "comment"; + id: string; + userId: string; + createdAt: Date; + updatedAt: Date; + reactions: CommentReactionData[]; + // attachments: CommentAttachment[]; + metadata: any; +} & ( + | { + deletedAt: Date; + body: undefined; + } + | { + deletedAt?: never; + body: CommentBody; + } +); + +export type ThreadData = { + type: "thread"; + id: string; + createdAt: Date; + updatedAt: Date; + comments: CommentData[]; + resolved: boolean; + resolvedUpdatedAt?: Date; + 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 000000000..4cad7326e --- /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/core/src/extensions/Placeholder/PlaceholderPlugin.ts b/packages/core/src/extensions/Placeholder/PlaceholderPlugin.ts index 440457eb2..db1681e89 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/core/src/index.ts b/packages/core/src/index.ts index 7b0115ae4..aa12a962d 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 9ecdf3250..2a0a2905b 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 000000000..f9403d6a1 --- /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 000000000..2e50ea092 --- /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 000000000..9167c0936 --- /dev/null +++ b/packages/mantine/src/comments/Comment.tsx @@ -0,0 +1,88 @@ +import { assertEmpty } from "@blocknote/core"; +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, + Pick +>((props, ref) => { + const { authorInfo, timeString, ...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, + 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 ( + + {doShowActions ? ( + + {actions} + + ) : null} + + {children} + + ); +}); diff --git a/packages/mantine/src/comments/Editor.tsx b/packages/mantine/src/comments/Editor.tsx new file mode 100644 index 000000000..af5a88f4c --- /dev/null +++ b/packages/mantine/src/comments/Editor.tsx @@ -0,0 +1,28 @@ +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, onFocus, onBlur, editor, editable, ...rest } = props; + + assertEmpty(rest, false); + + return ( + + ); +}); diff --git a/packages/mantine/src/components.tsx b/packages/mantine/src/components.tsx new file mode 100644 index 000000000..b892d2b33 --- /dev/null +++ b/packages/mantine/src/components.tsx @@ -0,0 +1,109 @@ +import { Components } from "@blocknote/react"; + +import { Card, CardSection } from "./comments/Card.js"; +import { Comment } from "./comments/Comment.js"; + +import { Editor } from "./comments/Editor.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, + }, + Toolbar: { + Root: Toolbar, + Button: ToolbarButton, + }, + }, + Comments: { + Comment, + Editor, + Card, + CardSection, + }, +}; diff --git a/packages/mantine/src/index.tsx b/packages/mantine/src/index.tsx index 136e12280..f3f6a4bfc 100644 --- a/packages/mantine/src/index.tsx +++ b/packages/mantine/src/index.tsx @@ -1,191 +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 { 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, - }, - }, -}; - -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/mantine/src/toolbar/Toolbar.tsx b/packages/mantine/src/toolbar/Toolbar.tsx index 9ad042e85..47dd1cb6e 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 bd22f0411..4f2fd9e5f 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 new file mode 100644 index 000000000..3023b2095 --- /dev/null +++ b/packages/react/src/components/Comments/Comment.tsx @@ -0,0 +1,427 @@ +"use client"; + +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"; +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"; +import { useUser } from "./useUsers.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; + + /** + * The thread id. + */ + 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`. + */ + showDeleted?: boolean; + + /** + * Whether to show reactions. + */ + showReactions?: boolean; + + /** + * @internal + */ + additionalActions?: ReactNode; +} + +// interface CommentReactionButtonProps +// extends ComponentPropsWithoutRef { +// reaction: CommentReactionData; +// // overrides?: Partial; +// } + +// interface CommentReactionProps extends ComponentPropsWithoutRef<"button"> { +// comment: CommentData; +// reaction: CommentReactionData; +// // overrides?: Partial; +// } + +// type CommentNonInteractiveReactionProps = Omit; + +// const CommentReactionButton = forwardRef< +// HTMLButtonElement, +// CommentReactionButtonProps +// >(({ reaction, overrides, className, ...props }, forwardedRef) => { +// const $ = useOverrides(overrides); +// return ( +// +// ); +// }); + +// export const CommentReaction = forwardRef< +// HTMLButtonElement, +// CommentReactionProps +// >(({ comment, reaction, overrides, disabled, ...props }, forwardedRef) => { +// const addReaction = useAddRoomCommentReaction(comment.roomId); +// const removeReaction = useRemoveRoomCommentReaction(comment.roomId); +// const currentId = useCurrentUserId(); +// const isActive = useMemo(() => { +// return reaction.users.some((users) => users.id === currentId); +// }, [currentId, reaction]); +// const $ = useOverrides(overrides); +// const tooltipContent = useMemo( +// () => ( +// +// {$.COMMENT_REACTION_LIST( +// ( +// +// ))} +// formatRemaining={$.LIST_REMAINING_USERS} +// truncate={REACTIONS_TRUNCATE} +// locale={$.locale} +// />, +// reaction.emoji, +// reaction.users.length +// )} +// +// ), +// [$, reaction] +// ); + +// const stopPropagation = useCallback((event: SyntheticEvent) => { +// event.stopPropagation(); +// }, []); + +// const handlePressedChange = useCallback( +// (isPressed: boolean) => { +// if (isPressed) { +// addReaction({ +// threadId: comment.threadId, +// commentId: comment.id, +// emoji: reaction.emoji, +// }); +// } else { +// removeReaction({ +// threadId: comment.threadId, +// commentId: comment.id, +// emoji: reaction.emoji, +// }); +// } +// }, +// [addReaction, comment.threadId, comment.id, reaction.emoji, removeReaction] +// ); + +// return ( +// +// +// +// +// +// ); +// }); + +// export const CommentNonInteractiveReaction = forwardRef< +// HTMLButtonElement, +// CommentNonInteractiveReactionProps +// >(({ reaction, overrides, ...props }, forwardedRef) => { +// const currentId = useCurrentUserId(); +// const isActive = useMemo(() => { +// return reaction.users.some((users) => users.id === currentId); +// }, [currentId, reaction]); + +// return ( +// +// ); +// }); + +export const Comment = ({ + comment, + thread, + showDeleted, + showActions = "hover", + showReactions = true, + showResolveAction = false, + className, + additionalActions, +}: CommentProps) => { + 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, + }, + [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: thread.id, + }); + + setEditing(false); + }, + [comment, thread.id, commentEditor, editor.comments] + ); + + const onDelete = useCallback(() => { + editor.comments!.store.deleteComment({ + commentId: comment.id, + threadId: thread.id, + }); + }, [comment, thread.id, editor.comments]); + + const onReactionSelect = useCallback(() => { + console.log("reaction select"); + }, []); + + const onResolve = useCallback(() => { + 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"; + 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 + + const user = useUser(editor, comment.userId); + + if (!showDeleted && !comment.body) { + return null; + } + + 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 = ( + + {canAddReaction && ( + + R1 + + )} + {showResolveOrReopen && + (thread.resolved ? ( + + R2 + + ) : ( + + R2 + + ))} + {(canDeleteComment || canEditComment) && ( + + + + ... + + + + {canEditComment && ( + + Edit comment + + )} + {canDeleteComment && ( + + Delete comment + + )} + + + )} + + ); + } + + const timeString = + comment.createdAt.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }) + + (comment.updatedAt.getTime() !== comment.createdAt.getTime() + ? " (edited)" + : ""); // TODO: needs editedAt? + + return ( + + {isEditing ? ( + <> + ( + + + X + + + Save + + + )} + /> + + ) : comment.body ? ( + <> + + + {showReactions && comment.reactions.length > 0 && ( +
+ )} + + ) : ( + // Soft deletes + // TODO, test +
+

Deleted

+
+ )} +
+ ); +}; diff --git a/packages/react/src/components/Comments/CommentEditor.tsx b/packages/react/src/components/Comments/CommentEditor.tsx new file mode 100644 index 000000000..43841c4a1 --- /dev/null +++ b/packages/react/src/components/Comments/CommentEditor.tsx @@ -0,0 +1,68 @@ +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/FloatingComposer.tsx b/packages/react/src/components/Comments/FloatingComposer.tsx new file mode 100644 index 000000000..214b4d740 --- /dev/null +++ b/packages/react/src/components/Comments/FloatingComposer.tsx @@ -0,0 +1,51 @@ +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 function FloatingComposer() { + const editor = useBlockNoteEditor(); + const Components = useComponentsContext()!; + const dict = useDictionary(); + + const newCommentEditor = useCreateBlockNote({ + trailingBlock: false, + dictionary: { + ...dict, + placeholders: { + ...dict.placeholders, + default: "Write a comment...", // TODO: only for empty doc + }, + }, + schema, + }); + + return ( + + ( + + { + 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 new file mode 100644 index 000000000..75722dfe9 --- /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 { ComponentProps, FC, useMemo } from "react"; + +import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useUIElementPositioning } from "../../hooks/useUIElementPositioning.js"; +import { useUIPluginState } from "../../hooks/useUIPluginState.js"; +import { FloatingComposer } from "./FloatingComposer.js"; + +export const FloatingComposerController = < + B extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema +>(props: { + floatingComposer?: 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) { + // TODO + editor.comments!.stopPendingComment(); + editor.focus(); + } + }, + ...props.floatingOptions, + } + ); + + if (!isMounted || !state) { + return null; + } + + const Component = props.floatingComposer || FloatingComposer; + + return ( +
+ +
+ ); +}; diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx new file mode 100644 index 000000000..93bdf1ee3 --- /dev/null +++ b/packages/react/src/components/Comments/FloatingThreadController.tsx @@ -0,0 +1,102 @@ +import { + BlockSchema, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + InlineContentSchema, + StyleSchema, +} from "@blocknote/core"; +import { UseFloatingOptions, flip, offset } from "@floating-ui/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 { 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, + S extends StyleSchema = DefaultStyleSchema +>(props: { + filePanel?: FC; // TODO + 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) + ); + + // TODO: review + const { isMounted, ref, style, getFloatingProps, setReference } = + useUIElementPositioning(!!state?.selectedThreadId, null, 5000, { + placement: "bottom", + middleware: [offset(10), flip()], + onOpenChange: (open) => { + if (!open) { + // editor.filePanel!.closeMenu(); + // editor.focus(); + } + }, + ...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; + } + + if (!state.selectedThreadId) { + return null; // TODO + } + + const Component = props.filePanel || Thread; + + return ( +
+ {/*
hello
*/} + +
+ ); +}; diff --git a/packages/react/src/components/Comments/Thread.tsx b/packages/react/src/components/Comments/Thread.tsx new file mode 100644 index 000000000..590b0648c --- /dev/null +++ b/packages/react/src/components/Comments/Thread.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { mergeCSSClasses } from "@blocknote/core"; +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"; +import { useUsers } from "./useUsers.js"; + +export interface ThreadProps extends ComponentPropsWithoutRef<"div"> { + /** + * The thread to display. + */ + threadId: string; + + /** + * 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 deleted comments. + */ + showDeletedComments?: CommentProps["showDeleted"]; +} + +export const Thread = ({ + threadId, + showActions = "hover", + showDeletedComments, + showResolveAction = true, + showReactions = true, + 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 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: { + ...dict, + placeholders: { + ...dict.placeholders, + default: "Add comment...", // TODO: only for empty doc + }, + }, + schema, + }); + + const firstCommentIndex = useMemo(() => { + return showDeletedComments + ? 0 + : thread.comments.findIndex((comment) => comment.body); + }, [showDeletedComments, thread.comments]); + + const onNewCommentSave = useCallback(async () => { + await editor.comments!.store.addComment({ + comment: { + body: newCommentEditor.document, + }, + threadId: thread.id, + }); + + // reset editor + newCommentEditor.removeBlocks(newCommentEditor.document); + }, [editor.comments, newCommentEditor, thread.id]); + + const showComposer = editor.comments!.store.auth.canAddComment(thread); + + return ( + + + {thread.comments.map((comment, index) => { + const isFirstComment = index === firstCommentIndex; + + return ( + + ); + })} + + {showComposer && ( + + { + if (!isFocused && isEmpty) { + return null; + } + + return ( + + + Save + + + ); + }} + /> + + )} + + ); +}; diff --git a/packages/react/src/components/Comments/schema.ts b/packages/react/src/components/Comments/schema.ts new file mode 100644 index 000000000..c9078380d --- /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/components/Comments/useThreadStore.ts b/packages/react/src/components/Comments/useThreadStore.ts new file mode 100644 index 000000000..1c37a2534 --- /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/Comments/useUsers.ts b/packages/react/src/components/Comments/useUsers.ts new file mode 100644 index 000000000..62279d981 --- /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/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/AddCommentButton.tsx index 50c8a6911..bd00a988f 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?.startPendingComment(); + 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 757dac798..606389aca 100644 --- a/packages/react/src/editor/BlockNoteDefaultUI.tsx +++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx @@ -1,3 +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"; @@ -15,6 +17,7 @@ export type BlockNoteDefaultUIProps = { filePanel?: boolean; tableHandles?: boolean; emojiPicker?: boolean; + comments?: boolean; }; export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) { @@ -45,6 +48,12 @@ 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 123264a19..2ebdbce38 100644 --- a/packages/react/src/editor/ComponentsContext.tsx +++ b/packages/react/src/editor/ComponentsContext.tsx @@ -9,27 +9,35 @@ import { useContext, } from "react"; +import { BlockNoteEditor, User } 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; + variant?: "default" | "action-toolbar"; +}; + +type ToolbarButtonType = { + className?: string; + mainTooltip: string; + secondaryTooltip?: string; + icon?: ReactNode; + onClick?: (e: MouseEvent) => void; + isSelected?: boolean; + isDisabled?: boolean; + variant?: "default" | "compact"; +} & ( + | { 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: { @@ -81,24 +89,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: { @@ -257,6 +249,35 @@ export type ComponentProps = { children?: ReactNode; }; }; + Toolbar: { + Root: ToolbarRootType; + Button: ToolbarButtonType; + }; + }; + Comments: { + Card: { + className?: string; + children?: ReactNode; + }; + CardSection: { + className?: string; + children?: ReactNode; + }; + Editor: { + className?: string; + editable: boolean; + editor: BlockNoteEditor; + onFocus?: () => void; + onBlur?: () => void; + }; + Comment: { + className?: string; + children?: ReactNode; + authorInfo: "loading" | User; + timeString: string; + actions?: ReactNode; + showActions?: boolean | "hover"; + }; }; }; diff --git a/packages/react/src/hooks/useUIElementPositioning.ts b/packages/react/src/hooks/useUIElementPositioning.ts index 328e481eb..1e9de1b10 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, diff --git a/playground/package.json b/playground/package.json index ca5a6c881..d1e04bc77 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 7e716f2b4..43b27bc08 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 6ca41017b..04c99061c 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/" },