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
+
+ )}
+
+ );
+};
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 (
+
+ );
+};
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