feat(rnotes): add real-time Yjs collaboration, comments, and suggestions

Replace whole-content Automerge sync with character-level Yjs CRDT for
NOTE-type notes. Adds cursor presence, inline comments with threaded
replies, and track-changes suggesting mode.

- Custom Yjs WebSocket provider bridging over existing rSpace WS
- Server-side yjs-sync/yjs-awareness message relay (pure broadcast)
- y-indexeddb for offline persistence, periodic plaintext sync to Automerge
- Comment mark + panel with resolve/reply/delete
- Suggestion insert/delete marks with accept/reject support
- Schema v3→v4 (collabEnabled, comments fields)
- Collab toolbar: comment button, suggesting toggle, peer indicators

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 10:36:09 -07:00
parent b51dac1b22
commit a736321189
12 changed files with 1551 additions and 62 deletions

120
bun.lock
View File

@ -25,9 +25,14 @@
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@tiptap/y-tiptap": "^3.0.2",
"@types/qrcode": "^1.5.6",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"cron-parser": "^5.5.0",
"h3-js": "^4.4.0",
"hono": "^4.11.7",
"imapflow": "^1.0.170",
"jose": "^6.0.11",
@ -39,11 +44,16 @@
"perfect-arrows": "^0.3.7",
"perfect-freehand": "^1.2.2",
"postgres": "^3.4.5",
"qrcode": "^1.5.4",
"sharp": "^0.33.0",
"web-push": "^3.6.7",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.3.7",
"yaml": "^2.8.2",
"yjs": "^13.6.30",
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/mailparser": "^3.4.0",
"@types/node": "^22.10.1",
"@types/nodemailer": "^6.4.0",
@ -261,6 +271,8 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@ -539,6 +551,8 @@
"@tiptap/starter-kit": ["@tiptap/starter-kit@3.20.0", "", { "dependencies": { "@tiptap/core": "^3.20.0", "@tiptap/extension-blockquote": "^3.20.0", "@tiptap/extension-bold": "^3.20.0", "@tiptap/extension-bullet-list": "^3.20.0", "@tiptap/extension-code": "^3.20.0", "@tiptap/extension-code-block": "^3.20.0", "@tiptap/extension-document": "^3.20.0", "@tiptap/extension-dropcursor": "^3.20.0", "@tiptap/extension-gapcursor": "^3.20.0", "@tiptap/extension-hard-break": "^3.20.0", "@tiptap/extension-heading": "^3.20.0", "@tiptap/extension-horizontal-rule": "^3.20.0", "@tiptap/extension-italic": "^3.20.0", "@tiptap/extension-link": "^3.20.0", "@tiptap/extension-list": "^3.20.0", "@tiptap/extension-list-item": "^3.20.0", "@tiptap/extension-list-keymap": "^3.20.0", "@tiptap/extension-ordered-list": "^3.20.0", "@tiptap/extension-paragraph": "^3.20.0", "@tiptap/extension-strike": "^3.20.0", "@tiptap/extension-text": "^3.20.0", "@tiptap/extension-underline": "^3.20.0", "@tiptap/extensions": "^3.20.0", "@tiptap/pm": "^3.20.0" } }, "sha512-W4+1re35pDNY/7rpXVg+OKo/Fa4Gfrn08Bq3E3fzlJw6gjE3tYU8dY9x9vC2rK9pd9NOp7Af11qCFDaWpohXkw=="],
"@tiptap/y-tiptap": ["@tiptap/y-tiptap@3.0.2", "", { "dependencies": { "lib0": "^0.2.100" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
@ -557,6 +571,8 @@
"@types/nodemailer": ["@types/nodemailer@6.4.22", "", { "dependencies": { "@types/node": "*" } }, "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw=="],
"@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
@ -573,6 +589,10 @@
"@x402/fetch": ["@x402/fetch@2.5.0", "", { "dependencies": { "@x402/core": "~2.5.0", "viem": "^2.39.3", "zod": "^3.24.2" } }, "sha512-D2jH3bn0nf8w9Jg3Vxo+6reE6Z9GickzkSIw+udITJFvsrGOpfjZvhcTeflLcthCODk4Nuu9Oe8x7Q3NLUdaRQ=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="],
"abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="],
@ -583,9 +603,9 @@
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"apg-js": ["apg-js@4.4.0", "", {}, "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q=="],
@ -623,6 +643,10 @@
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
@ -647,6 +671,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
@ -657,6 +683,8 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@ -671,7 +699,7 @@
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="],
@ -705,6 +733,8 @@
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@ -721,6 +751,8 @@
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@ -733,6 +765,8 @@
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"h3-js": ["h3-js@4.4.0", "", {}, "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
@ -773,6 +807,8 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
"isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
@ -791,6 +827,8 @@
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
"libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="],
@ -803,6 +841,8 @@
"linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="],
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
@ -849,14 +889,22 @@
"ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="],
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
@ -877,6 +925,12 @@
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
@ -929,14 +983,20 @@
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
@ -955,6 +1015,8 @@
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
"setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="],
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
@ -979,13 +1041,13 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@ -1037,14 +1099,30 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"y-indexeddb": ["y-indexeddb@9.0.12", "", { "dependencies": { "lib0": "^0.2.74" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg=="],
"y-prosemirror": ["y-prosemirror@1.3.7", "", { "dependencies": { "lib0": "^0.2.109" }, "peerDependencies": { "prosemirror-model": "^1.7.1", "prosemirror-state": "^1.2.3", "prosemirror-view": "^1.9.10", "y-protocols": "^1.0.1", "yjs": "^13.5.38" } }, "sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg=="],
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"yjs": ["yjs@13.6.30", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@automerge/automerge/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
@ -1059,6 +1137,12 @@
"@encryptid/sdk/@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@openfort/openfort-node/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@ -1089,17 +1173,7 @@
"mailparser/nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@ -1107,14 +1181,14 @@
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"ethers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],

View File

@ -0,0 +1,49 @@
/**
* TipTap mark extension for inline comments.
*
* Applies a highlight to selected text and associates it with a comment thread
* stored in Automerge. The mark position is synced via Yjs (as part of the doc content),
* while the thread data (messages, resolved state) lives in Automerge.
*/
import { Mark, mergeAttributes } from '@tiptap/core';
export const CommentMark = Mark.create({
name: 'comment',
addAttributes() {
return {
threadId: { default: null },
resolved: { default: false },
};
},
parseHTML() {
return [
{
tag: 'span[data-thread-id]',
getAttrs: (el) => {
const element = el as HTMLElement;
return {
threadId: element.getAttribute('data-thread-id'),
resolved: element.getAttribute('data-resolved') === 'true',
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes(
{
class: `comment-highlight${HTMLAttributes.resolved ? ' resolved' : ''}`,
'data-thread-id': HTMLAttributes.threadId,
'data-resolved': HTMLAttributes.resolved ? 'true' : 'false',
},
),
0,
];
},
});

View File

@ -0,0 +1,308 @@
/**
* <notes-comment-panel> Right sidebar panel for viewing/managing inline comments.
*
* Shows threaded comments anchored to highlighted text in the editor.
* Comment thread data is stored in Automerge, while the highlight mark
* position is stored in Yjs (part of the document content).
*/
import type { Editor } from '@tiptap/core';
import type { DocumentId } from '../../../shared/local-first/document';
interface CommentMessage {
id: string;
authorId: string;
authorName: string;
text: string;
createdAt: number;
}
interface CommentThread {
id: string;
anchor: string;
resolved: boolean;
messages: CommentMessage[];
createdAt: number;
}
interface NotebookDoc {
items: Record<string, {
comments?: Record<string, CommentThread>;
[key: string]: any;
}>;
[key: string]: any;
}
class NotesCommentPanel extends HTMLElement {
private shadow: ShadowRoot;
private _noteId: string | null = null;
private _doc: any = null;
private _subscribedDocId: string | null = null;
private _activeThreadId: string | null = null;
private _editor: Editor | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
}
set noteId(v: string | null) { this._noteId = v; this.render(); }
set doc(v: any) { this._doc = v; this.render(); }
set subscribedDocId(v: string | null) { this._subscribedDocId = v; }
set activeThreadId(v: string | null) { this._activeThreadId = v; this.render(); }
set editor(v: Editor | null) { this._editor = v; }
private getThreads(): CommentThread[] {
if (!this._doc || !this._noteId) return [];
const item = this._doc.items?.[this._noteId];
if (!item?.comments) return [];
return Object.values(item.comments as Record<string, CommentThread>)
.sort((a, b) => a.createdAt - b.createdAt);
}
private render() {
const threads = this.getThreads();
if (threads.length === 0 && !this._activeThreadId) {
this.shadow.innerHTML = '';
return;
}
const esc = (s: string) => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; };
const timeAgo = (ts: number) => {
const diff = Date.now() - ts;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return `${Math.floor(diff / 86400000)}d ago`;
};
this.shadow.innerHTML = `
<style>
:host { display: block; }
.panel { border-left: 1px solid var(--rs-border, #e5e7eb); padding: 12px; font-family: system-ui, sans-serif; font-size: 13px; max-height: 80vh; overflow-y: auto; }
.panel-title { font-weight: 600; font-size: 14px; margin-bottom: 12px; color: var(--rs-text-primary, #111); display: flex; justify-content: space-between; align-items: center; }
.thread { margin-bottom: 16px; padding: 10px; border-radius: 8px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border-subtle, #f0f0f0); cursor: pointer; transition: border-color 0.15s; }
.thread:hover { border-color: var(--rs-border, #e5e7eb); }
.thread.active { border-color: var(--rs-primary, #3b82f6); }
.thread.resolved { opacity: 0.6; }
.thread-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.thread-author { font-weight: 600; color: var(--rs-text-primary, #111); }
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; }
.thread-actions { display: flex; gap: 4px; }
.thread-action { padding: 2px 6px; border: none; background: none; color: var(--rs-text-secondary, #666); cursor: pointer; font-size: 11px; border-radius: 4px; }
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); }
.message { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.4; }
.reply-form { margin-top: 8px; display: flex; gap: 6px; }
.reply-input { flex: 1; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 12px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
.reply-input:focus { border-color: var(--rs-primary, #3b82f6); outline: none; }
.reply-btn { padding: 6px 10px; border: none; background: var(--rs-primary, #3b82f6); color: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500; }
.reply-btn:hover { opacity: 0.9; }
</style>
<div class="panel">
<div class="panel-title">
<span>Comments (${threads.filter(t => !t.resolved).length})</span>
</div>
${threads.map(thread => `
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
<div class="thread-header">
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
</div>
${thread.messages.map(msg => `
<div class="message">
<div class="message-author">${esc(msg.authorName)}</div>
<div class="message-text">${esc(msg.text)}</div>
</div>
`).join('')}
${thread.messages.length === 0 ? '<div class="message"><div class="message-text" style="color: var(--rs-text-muted, #999)">Click to add a comment...</div></div>' : ''}
<div class="reply-form">
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
</div>
<div class="thread-actions">
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
<button class="thread-action" data-delete="${thread.id}">Delete</button>
</div>
</div>
`).join('')}
</div>
`;
this.wireEvents();
}
private wireEvents() {
// Click thread to scroll editor to it
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
el.addEventListener('click', (e) => {
const threadId = (el as HTMLElement).dataset.thread;
if (!threadId || !this._editor) return;
this._activeThreadId = threadId;
// Find the comment mark in the editor and scroll to it
this._editor.state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
if (mark) {
this._editor!.commands.setTextSelection(pos);
this._editor!.commands.scrollIntoView();
return false;
}
});
this.render();
});
});
// Reply
this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.reply;
if (!threadId) return;
const input = this.shadow.querySelector(`input[data-thread="${threadId}"]`) as HTMLInputElement;
const text = input?.value?.trim();
if (!text) return;
this.addReply(threadId, text);
input.value = '';
});
});
// Reply on Enter
this.shadow.querySelectorAll('.reply-input').forEach(input => {
input.addEventListener('keydown', (e) => {
if ((e as KeyboardEvent).key === 'Enter') {
e.stopPropagation();
const threadId = (input as HTMLInputElement).dataset.thread;
const text = (input as HTMLInputElement).value.trim();
if (threadId && text) {
this.addReply(threadId, text);
(input as HTMLInputElement).value = '';
}
}
});
input.addEventListener('click', (e) => e.stopPropagation());
});
// Resolve / re-open
this.shadow.querySelectorAll('[data-resolve]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.resolve;
if (threadId) this.toggleResolve(threadId);
});
});
// Delete
this.shadow.querySelectorAll('[data-delete]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.delete;
if (threadId) this.deleteThread(threadId);
});
});
}
private addReply(threadId: string, text: string) {
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
let authorName = 'Anonymous';
let authorId = 'anon';
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
authorName = sess?.username || sess?.displayName || 'Anonymous';
authorId = sess?.userId || sess?.sub || 'anon';
} catch {}
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Add comment reply', (d: NotebookDoc) => {
const item = d.items[noteId];
if (!item?.comments?.[threadId]) return;
const thread = item.comments[threadId] as any;
if (!thread.messages) thread.messages = [];
thread.messages.push({
id: `m_${Date.now()}`,
authorId,
authorName,
text,
createdAt: Date.now(),
});
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
this.render();
}
private toggleResolve(threadId: string) {
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Toggle comment resolve', (d: NotebookDoc) => {
const item = d.items[noteId];
if (!item?.comments?.[threadId]) return;
(item.comments[threadId] as any).resolved = !(item.comments[threadId] as any).resolved;
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
// Also update the mark in the editor
if (this._editor) {
const thread = this._doc?.items?.[this._noteId]?.comments?.[threadId];
if (thread) {
this._editor.state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
if (mark) {
const { tr } = this._editor!.state;
tr.removeMark(pos, pos + node.nodeSize, mark);
tr.addMark(pos, pos + node.nodeSize,
this._editor!.schema.marks.comment.create({ threadId, resolved: thread.resolved })
);
this._editor!.view.dispatch(tr);
return false;
}
});
}
}
this.render();
}
private deleteThread(threadId: string) {
if (!this._noteId || !this._subscribedDocId) return;
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized) return;
const noteId = this._noteId;
runtime.change(this._subscribedDocId as DocumentId, 'Delete comment thread', (d: NotebookDoc) => {
const item = d.items[noteId];
if (item?.comments?.[threadId]) {
delete (item.comments as any)[threadId];
}
});
this._doc = runtime.get(this._subscribedDocId as DocumentId);
// Remove the comment mark from the editor
if (this._editor) {
const { state } = this._editor;
const { tr } = state;
state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m => m.type.name === 'comment' && m.attrs.threadId === threadId);
if (mark) {
tr.removeMark(pos, pos + node.nodeSize, mark);
}
});
if (tr.docChanged) {
this._editor.view.dispatch(tr);
}
}
if (this._activeThreadId === threadId) this._activeThreadId = null;
this.render();
}
}
customElements.define('notes-comment-panel', NotesCommentPanel);

View File

@ -30,6 +30,14 @@ import type { ImportExportDialog } from './import-export-dialog';
import { SpeechDictation } from '../../../lib/speech-dictation';
import { TourEngine } from '../../../shared/tour-engine';
import { ViewHistory } from '../../../shared/view-history.js';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap';
import { RSpaceYjsProvider } from '../yjs-ws-provider';
import { CommentMark } from './comment-mark';
import { SuggestionInsertMark, SuggestionDeleteMark } from './suggestion-marks';
import { createSuggestionPlugin } from './suggestion-plugin';
import './comment-panel';
const lowlight = createLowlight(common);
@ -149,6 +157,15 @@ class FolkNotesApp extends HTMLElement {
private editorUpdateTimer: ReturnType<typeof setTimeout> | null = null;
private dictation: SpeechDictation | null = null;
// Yjs collaboration state
private ydoc: Y.Doc | null = null;
private yjsProvider: RSpaceYjsProvider | null = null;
private yIndexedDb: IndexeddbPersistence | null = null;
private yjsPlainTextTimer: ReturnType<typeof setInterval> | null = null;
// Comments/suggestions state
private suggestingMode = false;
// Audio recording (AUDIO note view)
private audioRecorder: MediaRecorder | null = null;
private audioSegments: { id: string; text: string; timestamp: number; isFinal: boolean }[] = [];
@ -620,26 +637,29 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
updated_at: noteItem.updatedAt ? new Date(noteItem.updatedAt).toISOString() : new Date().toISOString(),
};
// Update editor content if different (remote change)
const remoteContent = noteItem.content || "";
const currentContent = noteItem.contentFormat === 'tiptap-json'
? JSON.stringify(this.editor.getJSON())
: this.editor.getHTML();
// Skip content replacement when Yjs is active — Yjs handles content sync
if (!this.ydoc) {
// Legacy mode: update editor content if different (remote change)
const remoteContent = noteItem.content || "";
const currentContent = noteItem.contentFormat === 'tiptap-json'
? JSON.stringify(this.editor.getJSON())
: this.editor.getHTML();
if (remoteContent !== currentContent) {
this.isRemoteUpdate = true;
try {
if (noteItem.contentFormat === 'tiptap-json') {
try {
this.editor.commands.setContent(JSON.parse(remoteContent), { emitUpdate: false });
} catch {
if (remoteContent !== currentContent) {
this.isRemoteUpdate = true;
try {
if (noteItem.contentFormat === 'tiptap-json') {
try {
this.editor.commands.setContent(JSON.parse(remoteContent), { emitUpdate: false });
} catch {
this.editor.commands.setContent(remoteContent, { emitUpdate: false });
}
} else {
this.editor.commands.setContent(remoteContent, { emitUpdate: false });
}
} else {
this.editor.commands.setContent(remoteContent, { emitUpdate: false });
} finally {
this.isRemoteUpdate = false;
}
} finally {
this.isRemoteUpdate = false;
}
}
@ -1010,6 +1030,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) {
const useYjs = !isDemo && isEditable;
this.contentZone.innerHTML = `
<div class="editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
@ -1021,6 +1043,134 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const container = this.shadow.getElementById('tiptap-container');
if (!container) return;
if (useYjs) {
this.mountTiptapWithYjs(note, container);
} else {
this.mountTiptapLegacy(note, isEditable, isDemo, container);
}
this.editor!.registerPlugin(createSlashCommandPlugin(this.editor!, this.shadow));
container.addEventListener('slash-insert-image', () => {
if (!this.editor) return;
const { from } = this.editor.view.state.selection;
const coords = this.editor.view.coordsAtPos(from);
const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
this.showUrlPopover(rect, 'Enter image URL...').then(url => {
if (url) this.editor!.chain().focus().setImage({ src: url }).run();
});
});
container.addEventListener('slash-create-typed-note', ((e: CustomEvent) => {
const { type } = e.detail || {};
if (type && this.selectedNotebook) {
this.createNoteViaSync({ type });
}
}) as EventListener);
this.wireTitleInput(note, isEditable, isDemo);
this.attachToolbarListeners();
}
/** Mount TipTap with Yjs collaboration (real-time co-editing). */
private mountTiptapWithYjs(note: Note, container: HTMLElement) {
const runtime = (window as any).__rspaceOfflineRuntime;
const spaceSlug = runtime?.space || this.space;
const roomName = `rnotes:${spaceSlug}:${note.id}`;
// Create Y.Doc
this.ydoc = new Y.Doc();
const fragment = this.ydoc.getXmlFragment('content');
// IndexedDB persistence for offline
this.yIndexedDb = new IndexeddbPersistence(roomName, this.ydoc);
// Connect Yjs provider over rSpace WebSocket
if (runtime?.isInitialized) {
this.yjsProvider = new RSpaceYjsProvider(note.id, this.ydoc, runtime);
}
// Content migration: if Y.Doc fragment is empty and Automerge has content
this.yIndexedDb.on('synced', () => {
if (fragment.length === 0 && note.content) {
// Migrate existing content into Yjs by creating a temp editor, setting content, then destroying
let content: any = '';
if (note.content_format === 'tiptap-json') {
try { content = JSON.parse(note.content); } catch { content = note.content; }
} else {
content = note.content;
}
if (this.editor && content) {
this.editor.commands.setContent(content, { emitUpdate: false });
}
// Mark as collab-enabled in Automerge
if (this.doc?.items?.[note.id]) {
this.updateNoteField(note.id, 'collabEnabled', 'true');
}
}
});
// Create editor with Yjs sync/undo plugins registered directly
this.editor = new Editor({
element: container,
editable: true,
extensions: [
StarterKit.configure({
codeBlock: false,
heading: { levels: [1, 2, 3, 4] },
undoRedo: false, // Yjs has its own undo/redo
}),
Link.configure({ openOnClick: false }),
Image, TaskList, TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
CodeBlockLowlight.configure({ lowlight }), Typography, Underline,
CommentMark,
SuggestionInsertMark,
SuggestionDeleteMark,
],
onSelectionUpdate: () => { this.updateToolbarState(); },
});
// Register Yjs sync and undo plugins
this.editor.registerPlugin(ySyncPlugin(fragment));
this.editor.registerPlugin(yUndoPlugin());
// Register suggestion plugin for track-changes mode
const suggestionPlugin = createSuggestionPlugin(
() => this.suggestingMode,
() => {
const s = this.getSessionInfo();
return { authorId: s.userId, authorName: s.username };
},
);
this.editor.registerPlugin(suggestionPlugin);
// Register cursor presence plugin
if (this.yjsProvider) {
const cursorPlugin = yCursorPlugin(
this.yjsProvider.awareness,
{ cursorBuilder: this.buildCollabCursor.bind(this) }
);
this.editor.registerPlugin(cursorPlugin);
// Set local user state on awareness
const session = this.getSessionInfo();
this.yjsProvider.awareness.setLocalStateField('user', {
name: session.username || 'Anonymous',
color: this.userColor(session.userId || 'anon'),
});
}
// Periodic plaintext sync to Automerge (for search indexing)
this.yjsPlainTextTimer = setInterval(() => {
if (!this.editor || !this.editorNoteId) return;
const plain = this.editor.getText();
this.updateNoteField(this.editorNoteId, 'contentPlain', plain);
}, 5000);
}
/** Mount TipTap without Yjs (demo mode / read-only). */
private mountTiptapLegacy(note: Note, isEditable: boolean, isDemo: boolean, container: HTMLElement) {
let content: any = '';
if (note.content) {
if (note.content_format === 'tiptap-json') {
@ -1062,29 +1212,44 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
},
onSelectionUpdate: () => { this.updateToolbarState(); },
});
}
this.editor.registerPlugin(createSlashCommandPlugin(this.editor, this.shadow));
/** Build a DOM element for remote collaborator cursor. */
private buildCollabCursor(user: { name: string; color: string }) {
const cursor = document.createElement('span');
cursor.className = 'collab-cursor';
cursor.style.borderLeftColor = user.color;
container.addEventListener('slash-insert-image', () => {
if (!this.editor) return;
const { from } = this.editor.view.state.selection;
const coords = this.editor.view.coordsAtPos(from);
const rect = new DOMRect(coords.left, coords.top, 0, coords.bottom - coords.top);
this.showUrlPopover(rect, 'Enter image URL...').then(url => {
if (url) this.editor!.chain().focus().setImage({ src: url }).run();
});
});
const label = document.createElement('span');
label.className = 'collab-cursor-label';
label.style.backgroundColor = user.color;
label.textContent = user.name;
cursor.appendChild(label);
// Listen for slash-create-typed-note events
container.addEventListener('slash-create-typed-note', ((e: CustomEvent) => {
const { type } = e.detail || {};
if (type && this.selectedNotebook) {
this.createNoteViaSync({ type });
}
}) as EventListener);
return cursor;
}
this.wireTitleInput(note, isEditable, isDemo);
this.attachToolbarListeners();
/** Derive a stable color from a user ID string. */
private userColor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0;
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 50%)`;
}
/** Get session info for cursor display. */
private getSessionInfo(): { username: string; userId: string } {
try {
const sess = JSON.parse(localStorage.getItem('encryptid_session') || '{}');
return {
username: sess?.username || sess?.displayName || 'Anonymous',
userId: sess?.userId || sess?.sub || 'anon',
};
} catch {
return { username: 'Anonymous', userId: 'anon' };
}
}
private mountCodeEditor(note: Note, isEditable: boolean, isDemo: boolean) {
@ -1533,6 +1698,22 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
clearTimeout(this.editorUpdateTimer);
this.editorUpdateTimer = null;
}
if (this.yjsPlainTextTimer) {
clearInterval(this.yjsPlainTextTimer);
this.yjsPlainTextTimer = null;
}
if (this.yjsProvider) {
this.yjsProvider.destroy();
this.yjsProvider = null;
}
if (this.yIndexedDb) {
this.yIndexedDb.destroy();
this.yIndexedDb = null;
}
if (this.ydoc) {
this.ydoc.destroy();
this.ydoc = null;
}
if (this.dictation) {
this.dictation.destroy();
this.dictation = null;
@ -1549,6 +1730,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.audioRecorder.stop();
}
this.audioRecorder = null;
this.suggestingMode = false;
if (this.editor) {
this.editor.destroy();
this.editor = null;
@ -1609,6 +1791,16 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
<div class="toolbar-group">
${btn('summarize', 'Summarize Note')}
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group collab-tools">
<button class="toolbar-btn" data-cmd="comment" title="Add Comment">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3v-11a1 1 0 0 1 1-1z"/></svg>
</button>
<button class="toolbar-btn suggest-toggle" data-cmd="toggleSuggesting" title="Toggle Suggesting Mode">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11.5 1.5l3 3-9 9H2.5v-3z"/><path d="M9.5 3.5l3 3"/></svg>
</button>
<span class="collab-peers" id="collab-peers" title="Connected peers"></span>
</div>
</div>`;
}
@ -1714,6 +1906,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
case 'redo': this.editor.chain().focus().redo().run(); break;
case 'mic': this.toggleDictation(btn); break;
case 'summarize': this.summarizeNote(btn); break;
case 'comment': this.addComment(); break;
case 'toggleSuggesting': this.toggleSuggestingMode(btn); break;
}
});
@ -1732,6 +1926,71 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
}
}
/** Add a comment on the current selection. */
private addComment() {
if (!this.editor) return;
const { from, to, empty } = this.editor.state.selection;
if (empty) return; // Need selected text
const threadId = `c_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const session = this.getSessionInfo();
// Apply comment mark to the selection
this.editor.chain().focus()
.setMark('comment', { threadId, resolved: false })
.run();
// Create thread in Automerge
const noteId = this.editorNoteId;
if (noteId && this.doc?.items?.[noteId] && this.subscribedDocId) {
const runtime = (window as any).__rspaceOfflineRuntime;
if (runtime?.isInitialized) {
runtime.change(this.subscribedDocId as DocumentId, 'Add comment thread', (d: NotebookDoc) => {
const item = d.items[noteId] as any;
if (!item.comments) item.comments = {};
item.comments[threadId] = {
id: threadId,
anchor: `${from}-${to}`,
resolved: false,
messages: [],
createdAt: Date.now(),
};
});
this.doc = runtime.get(this.subscribedDocId as DocumentId);
}
}
// Open comment panel
this.showCommentPanel(threadId);
}
/** Toggle between editing and suggesting modes. */
private toggleSuggestingMode(btn: HTMLElement) {
this.suggestingMode = !this.suggestingMode;
btn.classList.toggle('active', this.suggestingMode);
btn.title = this.suggestingMode ? 'Switch to Editing Mode' : 'Switch to Suggesting Mode';
// Update editor's editable state to reflect mode
const container = this.shadow.getElementById('tiptap-container');
if (container) {
container.classList.toggle('suggesting-mode', this.suggestingMode);
}
}
/** Show comment panel for a specific thread. */
private showCommentPanel(threadId?: string) {
let panel = this.shadow.querySelector('notes-comment-panel') as any;
if (!panel) {
panel = document.createElement('notes-comment-panel');
this.metaZone.appendChild(panel);
}
panel.noteId = this.editorNoteId;
panel.doc = this.doc;
panel.subscribedDocId = this.subscribedDocId;
panel.activeThreadId = threadId || null;
panel.editor = this.editor;
}
private toggleDictation(btn: HTMLElement) {
if (this.dictation?.isRecording) {
this.dictation.stop();
@ -1814,6 +2073,58 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
else if (this.editor.isActive('heading', { level: 4 })) headingSelect.value = '4';
else headingSelect.value = 'paragraph';
}
// Update comment button state (active when text is selected)
const commentBtn = toolbar.querySelector('[data-cmd="comment"]') as HTMLElement;
if (commentBtn) {
commentBtn.classList.toggle('active', this.editor.isActive('comment'));
const { empty } = this.editor.state.selection;
commentBtn.style.opacity = empty ? '0.4' : '1';
}
// Update suggesting mode toggle
const suggestBtn = toolbar.querySelector('[data-cmd="toggleSuggesting"]') as HTMLElement;
if (suggestBtn) {
suggestBtn.classList.toggle('active', this.suggestingMode);
}
// Update peers count
this.updatePeersIndicator();
}
private updatePeersIndicator() {
const peersEl = this.shadow.getElementById('collab-peers');
if (!peersEl || !this.yjsProvider) {
if (peersEl) peersEl.style.display = 'none';
return;
}
const states = this.yjsProvider.awareness.getStates();
const peerCount = states.size - 1; // Exclude self
if (peerCount > 0) {
peersEl.style.display = 'inline-flex';
peersEl.innerHTML = '';
let shown = 0;
for (const [clientId, state] of states) {
if (clientId === this.ydoc?.clientID) continue;
if (shown >= 3) break;
const user = state.user || { name: '?', color: '#888' };
const dot = document.createElement('span');
dot.className = 'peer-dot';
dot.style.backgroundColor = user.color;
dot.title = user.name;
peersEl.appendChild(dot);
shown++;
}
if (peerCount > 3) {
const more = document.createElement('span');
more.className = 'peer-more';
more.textContent = `+${peerCount - 3}`;
peersEl.appendChild(more);
}
} else {
peersEl.style.display = 'none';
}
}
// ── Helpers ──
@ -2769,6 +3080,95 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
.tiptap-container .tiptap .hljs-attr { color: #ffcb6b; }
.tiptap-container .tiptap .hljs-tag { color: #f07178; }
.tiptap-container .tiptap .hljs-type { color: #ffcb6b; }
/* ── Collaboration: Remote Cursors ── */
.collab-cursor {
position: relative;
border-left: 2px solid;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
}
.collab-cursor-label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 10px;
font-weight: 600;
color: #fff;
padding: 1px 5px;
border-radius: 3px 3px 3px 0;
white-space: nowrap;
pointer-events: none;
user-select: none;
line-height: 1.4;
}
/* y-prosemirror remote selection highlight */
.ProseMirror .yRemoteSelection {
background-color: var(--user-color, rgba(99, 102, 241, 0.2));
}
.ProseMirror .yRemoteSelectionHead {
position: absolute;
border-left: 2px solid var(--user-color, #6366f1);
height: 1.2em;
}
/* ── Collaboration: Peers Indicator ── */
.collab-tools { display: flex; align-items: center; gap: 4px; }
.collab-peers {
display: none;
align-items: center;
gap: 2px;
margin-left: 4px;
}
.peer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
border: 1px solid var(--rs-bg-surface, #fff);
}
.peer-more {
font-size: 10px;
color: var(--rs-text-muted);
margin-left: 2px;
}
/* ── Collaboration: Comment Highlights ── */
.tiptap-container .tiptap .comment-highlight {
background: rgba(250, 204, 21, 0.25);
border-bottom: 2px solid rgba(250, 204, 21, 0.5);
cursor: pointer;
transition: background 0.15s;
}
.tiptap-container .tiptap .comment-highlight:hover {
background: rgba(250, 204, 21, 0.4);
}
.tiptap-container .tiptap .comment-highlight.resolved {
background: rgba(250, 204, 21, 0.08);
border-bottom-color: rgba(250, 204, 21, 0.15);
}
/* ── Collaboration: Suggestions ── */
.tiptap-container .tiptap .suggestion-insert {
color: #16a34a;
background: rgba(22, 163, 74, 0.08);
border-bottom: 1px dashed #16a34a;
text-decoration: none;
}
.tiptap-container .tiptap .suggestion-delete {
color: #dc2626;
background: rgba(220, 38, 38, 0.08);
text-decoration: line-through;
text-decoration-color: #dc2626;
}
.tiptap-container.suggesting-mode {
border-left: 3px solid #f59e0b;
}
.suggest-toggle.active {
background: #f59e0b !important;
color: #fff !important;
}
`;
}
}

View File

@ -0,0 +1,101 @@
/**
* TipTap mark extensions for track-changes suggestions.
*
* SuggestionInsert: wraps text that was inserted in suggesting mode (green underline).
* SuggestionDelete: wraps text that was marked for deletion in suggesting mode (red strikethrough).
*
* Both marks are stored in the Yjs document and sync in real-time.
* Accept/reject logic is handled by the suggestion-plugin.
*/
import { Mark, mergeAttributes } from '@tiptap/core';
export const SuggestionInsertMark = Mark.create({
name: 'suggestionInsert',
addAttributes() {
return {
suggestionId: { default: null },
authorId: { default: null },
authorName: { default: null },
createdAt: { default: null },
};
},
parseHTML() {
return [
{
tag: 'span[data-suggestion-insert]',
getAttrs: (el) => {
const element = el as HTMLElement;
return {
suggestionId: element.getAttribute('data-suggestion-id'),
authorId: element.getAttribute('data-author-id'),
authorName: element.getAttribute('data-author-name'),
createdAt: Number(element.getAttribute('data-created-at')) || null,
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes({
class: 'suggestion-insert',
'data-suggestion-insert': '',
'data-suggestion-id': HTMLAttributes.suggestionId,
'data-author-id': HTMLAttributes.authorId,
'data-author-name': HTMLAttributes.authorName,
'data-created-at': HTMLAttributes.createdAt,
}),
0,
];
},
});
export const SuggestionDeleteMark = Mark.create({
name: 'suggestionDelete',
addAttributes() {
return {
suggestionId: { default: null },
authorId: { default: null },
authorName: { default: null },
createdAt: { default: null },
};
},
parseHTML() {
return [
{
tag: 'span[data-suggestion-delete]',
getAttrs: (el) => {
const element = el as HTMLElement;
return {
suggestionId: element.getAttribute('data-suggestion-id'),
authorId: element.getAttribute('data-author-id'),
authorName: element.getAttribute('data-author-name'),
createdAt: Number(element.getAttribute('data-created-at')) || null,
};
},
},
];
},
renderHTML({ HTMLAttributes }) {
return [
'span',
mergeAttributes({
class: 'suggestion-delete',
'data-suggestion-delete': '',
'data-suggestion-id': HTMLAttributes.suggestionId,
'data-author-id': HTMLAttributes.authorId,
'data-author-name': HTMLAttributes.authorName,
'data-created-at': HTMLAttributes.createdAt,
}),
0,
];
},
});

View File

@ -0,0 +1,251 @@
/**
* ProseMirror plugin that intercepts transactions in "suggesting" mode
* and converts them into track-changes marks instead of direct edits.
*
* In suggesting mode:
* - Insertions wraps inserted text with `suggestionInsert` mark
* - Deletions converts to `suggestionDelete` mark instead of deleting
*
* Accept/reject operations remove marks (and optionally the text).
*/
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
import type { Editor } from '@tiptap/core';
const pluginKey = new PluginKey('suggestion-plugin');
interface SuggestionPluginState {
suggesting: boolean;
authorId: string;
authorName: string;
}
/**
* Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state
* @param getAuthor - callback that returns { authorId, authorName }
*/
export function createSuggestionPlugin(
getSuggesting: () => boolean,
getAuthor: () => { authorId: string; authorName: string },
): Plugin {
return new Plugin({
key: pluginKey,
filterTransaction(tr: Transaction, state) {
if (!getSuggesting()) return true;
if (!tr.docChanged) return true;
if (tr.getMeta('suggestion-accept') || tr.getMeta('suggestion-reject')) return true;
if (tr.getMeta('suggestion-applied')) return true;
// Intercept the transaction and convert it to suggestion marks
const { authorId, authorName } = getAuthor();
const suggestionId = `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// We need to rebuild the transaction with suggestion marks
const newTr = state.tr;
let blocked = false;
tr.steps.forEach((step, i) => {
const stepMap = step.getMap();
let hasInsert = false;
let hasDelete = false;
stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => {
if (newEnd > newStart) hasInsert = true;
if (oldEnd > oldStart) hasDelete = true;
});
if (hasInsert && !hasDelete) {
// Pure insertion — let it through but add the suggestionInsert mark
blocked = true;
const doc = tr.docs[i];
stepMap.forEach((_oldStart: number, _oldEnd: number, newStart: number, newEnd: number) => {
if (newEnd > newStart) {
const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || '';
if (insertedText) {
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
newTr.insertText(insertedText, newStart);
newTr.addMark(newStart, newStart + insertedText.length, insertMark);
newTr.setMeta('suggestion-applied', true);
}
}
});
} else if (hasDelete && !hasInsert) {
// Pure deletion — convert to suggestionDelete mark instead
blocked = true;
stepMap.forEach((oldStart: number, oldEnd: number) => {
if (oldEnd > oldStart) {
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
newTr.addMark(oldStart, oldEnd, deleteMark);
newTr.setMeta('suggestion-applied', true);
}
});
} else if (hasInsert && hasDelete) {
// Replacement (delete + insert) — mark old text as deleted, new text as inserted
blocked = true;
stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => {
if (oldEnd > oldStart) {
const deleteMark = state.schema.marks.suggestionDelete.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
newTr.addMark(oldStart, oldEnd, deleteMark);
}
if (newEnd > newStart) {
const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || '';
if (insertedText) {
const insertMark = state.schema.marks.suggestionInsert.create({
suggestionId,
authorId,
authorName,
createdAt: Date.now(),
});
// Insert after the "deleted" text
const insertPos = oldEnd;
newTr.insertText(insertedText, insertPos);
newTr.addMark(insertPos, insertPos + insertedText.length, insertMark);
}
}
});
newTr.setMeta('suggestion-applied', true);
}
});
if (blocked && newTr.docChanged) {
// Dispatch our modified transaction instead
// We need to use view.dispatch in the next tick
setTimeout(() => {
const view = (state as any).view;
if (view) view.dispatch(newTr);
}, 0);
return false; // Block the original transaction
}
return !blocked;
},
});
}
/**
* Accept a suggestion by removing the mark.
* - For insertions: remove the mark (text stays)
* - For deletions: remove the text and the mark
*/
export function acceptSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
const { doc, tr } = state;
// Find all marks with this suggestionId
doc.descendants((node, pos) => {
if (!node.isText) return;
// Check for suggestionDelete marks — accept = remove the text
const deleteMark = node.marks.find(
m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
tr.delete(pos, pos + node.nodeSize);
tr.setMeta('suggestion-accept', true);
return false;
}
// Check for suggestionInsert marks — accept = remove the mark, keep text
const insertMark = node.marks.find(
m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
tr.removeMark(pos, pos + node.nodeSize, insertMark);
tr.setMeta('suggestion-accept', true);
}
});
if (tr.docChanged) {
editor.view.dispatch(tr);
}
}
/**
* Reject a suggestion by reverting it.
* - For insertions: remove the text and the mark
* - For deletions: remove the mark (text stays)
*/
export function rejectSuggestion(editor: Editor, suggestionId: string) {
const { state } = editor;
const { doc, tr } = state;
doc.descendants((node, pos) => {
if (!node.isText) return;
// Check for suggestionInsert marks — reject = remove text + mark
const insertMark = node.marks.find(
m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId
);
if (insertMark) {
tr.delete(pos, pos + node.nodeSize);
tr.setMeta('suggestion-reject', true);
return false;
}
// Check for suggestionDelete marks — reject = remove the mark, keep text
const deleteMark = node.marks.find(
m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId
);
if (deleteMark) {
tr.removeMark(pos, pos + node.nodeSize, deleteMark);
tr.setMeta('suggestion-reject', true);
}
});
if (tr.docChanged) {
editor.view.dispatch(tr);
}
}
/**
* Accept all suggestions in the document.
*/
export function acceptAllSuggestions(editor: Editor) {
const ids = new Set<string>();
editor.state.doc.descendants((node) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
ids.add(mark.attrs.suggestionId);
}
}
});
for (const id of ids) {
acceptSuggestion(editor, id);
}
}
/**
* Reject all suggestions in the document.
*/
export function rejectAllSuggestions(editor: Editor) {
const ids = new Set<string>();
editor.state.doc.descendants((node) => {
if (!node.isText) return;
for (const mark of node.marks) {
if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') {
ids.add(mark.attrs.suggestionId);
}
}
});
for (const id of ids) {
rejectSuggestion(editor, id);
}
}

View File

@ -20,6 +20,22 @@ export interface SourceRef {
contentHash?: string; // For conflict detection on re-import
}
export interface CommentMessage {
id: string;
authorId: string;
authorName: string;
text: string;
createdAt: number;
}
export interface CommentThread {
id: string;
anchor: string; // serialized position info for the comment mark
resolved: boolean;
messages: CommentMessage[];
createdAt: number;
}
export interface NoteItem {
id: string;
notebookId: string;
@ -42,6 +58,8 @@ export interface NoteItem {
summaryModel?: string;
openNotebookSourceId?: string;
sourceRef?: SourceRef;
collabEnabled?: boolean;
comments?: Record<string, CommentThread>;
createdAt: number;
updatedAt: number;
}
@ -102,12 +120,12 @@ export function connectionsDocId(space: string) {
export const notebookSchema: DocSchema<NotebookDoc> = {
module: 'notes',
collection: 'notebooks',
version: 3,
version: 4,
init: (): NotebookDoc => ({
meta: {
module: 'notes',
collection: 'notebooks',
version: 3,
version: 4,
spaceSlug: '',
createdAt: Date.now(),
},
@ -130,6 +148,7 @@ export const notebookSchema: DocSchema<NotebookDoc> = {
}
}
// v2→v3: sourceRef field is optional, no migration needed
// v3→v4: collabEnabled + comments fields are optional, no migration needed
return doc;
},
};

View File

@ -0,0 +1,204 @@
/**
* Custom Yjs WebSocket provider that bridges over the existing rSpace WebSocket.
*
* Instead of opening a separate y-websocket connection, this provider sends
* Yjs sync/awareness messages as JSON payloads through the rSpace runtime's
* WebSocket, using `yjs-sync` and `yjs-awareness` message types.
*
* The server simply relays these messages to all other peers in the same space.
*/
import * as Y from 'yjs';
import { writeSyncStep1, writeUpdate, readSyncMessage } from 'y-protocols/sync';
import {
Awareness,
encodeAwarenessUpdate,
applyAwarenessUpdate,
removeAwarenessStates,
} from 'y-protocols/awareness';
import {
createEncoder,
toUint8Array,
length as encoderLength,
} from 'lib0/encoding';
import { createDecoder } from 'lib0/decoding';
/** Minimal interface for the rSpace runtime's custom message API. */
interface RuntimeBridge {
sendCustom(msg: Record<string, any>): void;
onCustomMessage(type: string, cb: (msg: any) => void): () => void;
onConnect(cb: () => void): () => void;
onDisconnect(cb: () => void): () => void;
isOnline: boolean;
}
export class RSpaceYjsProvider {
readonly doc: Y.Doc;
readonly awareness: Awareness;
readonly noteId: string;
private runtime: RuntimeBridge;
private unsubs: (() => void)[] = [];
private connected = false;
private synced = false;
constructor(noteId: string, ydoc: Y.Doc, runtime: RuntimeBridge) {
this.noteId = noteId;
this.doc = ydoc;
this.runtime = runtime;
this.awareness = new Awareness(ydoc);
this.setupListeners();
if (runtime.isOnline) {
this.onConnect();
}
}
private setupListeners(): void {
// Listen for Yjs sync messages from other peers
this.unsubs.push(
this.runtime.onCustomMessage('yjs-sync', (msg: any) => {
if (msg.noteId !== this.noteId) return;
this.handleSyncMessage(msg.data);
})
);
// Listen for awareness messages from other peers
this.unsubs.push(
this.runtime.onCustomMessage('yjs-awareness', (msg: any) => {
if (msg.noteId !== this.noteId) return;
this.handleAwarenessMessage(msg.data);
})
);
// Listen for connect/disconnect
this.unsubs.push(
this.runtime.onConnect(() => this.onConnect())
);
this.unsubs.push(
this.runtime.onDisconnect(() => this.onDisconnect())
);
// When local doc changes, send update to peers
const updateHandler = (update: Uint8Array, origin: any) => {
if (origin === 'remote') return; // Don't echo back remote updates
this.sendDocUpdate(update);
};
this.doc.on('update', updateHandler);
this.unsubs.push(() => this.doc.off('update', updateHandler));
// When local awareness changes, broadcast
const awarenessHandler = (changes: {
added: number[];
updated: number[];
removed: number[];
}, origin: string | null) => {
if (origin === 'remote') return;
const changedClients = changes.added.concat(changes.updated).concat(changes.removed);
const update = encodeAwarenessUpdate(this.awareness, changedClients);
this.runtime.sendCustom({
type: 'yjs-awareness',
noteId: this.noteId,
data: Array.from(update),
});
};
this.awareness.on('update', awarenessHandler);
this.unsubs.push(() => this.awareness.off('update', awarenessHandler));
}
private onConnect(): void {
if (this.connected) return;
this.connected = true;
// Send initial sync step 1 (state vector)
const encoder = createEncoder();
writeSyncStep1(encoder, this.doc);
this.runtime.sendCustom({
type: 'yjs-sync',
noteId: this.noteId,
data: Array.from(toUint8Array(encoder)),
});
// Send full awareness state
const awarenessUpdate = encodeAwarenessUpdate(
this.awareness,
[this.doc.clientID],
);
this.runtime.sendCustom({
type: 'yjs-awareness',
noteId: this.noteId,
data: Array.from(awarenessUpdate),
});
}
private onDisconnect(): void {
this.connected = false;
this.synced = false;
// Remove all remote awareness states on disconnect
const states = Array.from(this.awareness.getStates().keys())
.filter(client => client !== this.doc.clientID);
removeAwarenessStates(this.awareness, states, this);
}
private handleSyncMessage(data: number[]): void {
const decoder = createDecoder(new Uint8Array(data));
const encoder = createEncoder();
const messageType = readSyncMessage(
decoder, encoder, this.doc, 'remote'
);
// If the response encoder has content, send it back
if (encoderLength(encoder) > 0) {
this.runtime.sendCustom({
type: 'yjs-sync',
noteId: this.noteId,
data: Array.from(toUint8Array(encoder)),
});
}
// After receiving sync step 2, we're synced
if (messageType === 1) { // syncStep2
this.synced = true;
}
}
private handleAwarenessMessage(data: number[]): void {
applyAwarenessUpdate(
this.awareness,
new Uint8Array(data),
'remote',
);
}
private sendDocUpdate(update: Uint8Array): void {
if (!this.connected) return;
const encoder = createEncoder();
writeUpdate(encoder, update);
this.runtime.sendCustom({
type: 'yjs-sync',
noteId: this.noteId,
data: Array.from(toUint8Array(encoder)),
});
}
get isSynced(): boolean {
return this.synced;
}
destroy(): void {
// Remove local awareness state
removeAwarenessStates(
this.awareness,
[this.doc.clientID],
this,
);
// Clean up all listeners
for (const unsub of this.unsubs) {
try { unsub(); } catch { /* ignore */ }
}
this.unsubs = [];
this.connected = false;
}
}

View File

@ -38,6 +38,7 @@
"@tiptap/extension-underline": "^3.20.0",
"@tiptap/pm": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@tiptap/y-tiptap": "^3.0.2",
"@types/qrcode": "^1.5.6",
"@x402/core": "^2.3.1",
"@x402/evm": "^2.5.0",
@ -59,7 +60,10 @@
"qrcode": "^1.5.4",
"sharp": "^0.33.0",
"web-push": "^3.6.7",
"yaml": "^2.8.2"
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.3.7",
"yaml": "^2.8.2",
"yjs": "^13.6.30"
},
"devDependencies": {
"@playwright/test": "^1.58.2",

View File

@ -2838,7 +2838,21 @@ const server = Bun.serve<WSData>({
return;
}
// ── Legacy canvas protocol (no docId) ──
// ── Yjs collaboration relay (pure relay — server doesn't parse Yjs binary) ──
if (msg.type === "yjs-sync" || msg.type === "yjs-awareness") {
const clients = communityClients.get(communitySlug);
if (clients) {
const relay = JSON.stringify({ ...msg, peerId });
for (const [cid, client] of clients) {
if (cid !== peerId && client.readyState === WebSocket.OPEN) {
client.send(relay);
}
}
}
return;
}
// ── Legacy canvas protocol (no docId) ──
if (msg.type === "sync" && Array.isArray(msg.data)) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" }));

View File

@ -268,6 +268,34 @@ export class RSpaceOfflineRuntime {
return () => { this.#statusListeners.delete(cb); };
}
/**
* Send a custom message through the WebSocket (for Yjs, etc.).
*/
sendCustom(msg: Record<string, any>): void {
this.#sync.sendCustom(msg);
}
/**
* Register a handler for custom WS message types. Returns unsubscribe fn.
*/
onCustomMessage(type: string, cb: (msg: any) => void): () => void {
return this.#sync.onCustomMessage(type, cb);
}
/**
* Listen for WebSocket connect events.
*/
onConnect(cb: () => void): () => void {
return this.#sync.onConnect(cb);
}
/**
* Listen for WebSocket disconnect events.
*/
onDisconnect(cb: () => void): () => void {
return this.#sync.onDisconnect(cb);
}
/**
* Flush all pending writes to IndexedDB. Call on beforeunload.
*/

View File

@ -142,6 +142,9 @@ export class DocSyncManager {
// Pending doc-list requests
#docListCallbacks = new Map<string, DocListCallback>();
// Custom message handlers (for Yjs, etc.)
#customMessageListeners = new Map<string, Set<(msg: any) => void>>();
constructor(opts: DocSyncManagerOptions) {
this.#documents = opts.documents;
this.#store = opts.store ?? null;
@ -462,6 +465,30 @@ export class DocSyncManager {
});
}
/**
* Send an arbitrary JSON message through the WebSocket.
* Used by Yjs provider and other custom protocols.
*/
sendCustom(msg: Record<string, any>): void {
if (this.#ws?.readyState === WebSocket.OPEN) {
this.#ws.send(JSON.stringify(msg));
}
}
/**
* Register a handler for custom message types (e.g. 'yjs-sync', 'yjs-awareness').
* Returns an unsubscribe function.
*/
onCustomMessage(type: string, cb: (msg: any) => void): () => void {
let set = this.#customMessageListeners.get(type);
if (!set) {
set = new Set();
this.#customMessageListeners.set(type, set);
}
set.add(cb);
return () => { set!.delete(cb); };
}
#handleMessage(raw: ArrayBuffer | string): void {
try {
const data = typeof raw === 'string' ? raw : new TextDecoder().decode(raw);
@ -483,6 +510,16 @@ export class DocSyncManager {
case 'pong':
// Keep-alive acknowledged
break;
default: {
// Dispatch to custom message listeners
const listeners = this.#customMessageListeners.get(msg.type);
if (listeners) {
for (const cb of listeners) {
try { cb(msg); } catch { /* ignore */ }
}
}
break;
}
}
} catch (e) {
console.error('[DocSyncManager] Failed to handle message:', e);