diff --git a/bun.lock b/bun.lock index 3c56205..9f84c12 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/modules/rnotes/components/comment-mark.ts b/modules/rnotes/components/comment-mark.ts new file mode 100644 index 0000000..c27eec8 --- /dev/null +++ b/modules/rnotes/components/comment-mark.ts @@ -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, + ]; + }, +}); diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts new file mode 100644 index 0000000..30b234f --- /dev/null +++ b/modules/rnotes/components/comment-panel.ts @@ -0,0 +1,308 @@ +/** + * — 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; + [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) + .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 = ` + +
+
+ Comments (${threads.filter(t => !t.resolved).length}) +
+ ${threads.map(thread => ` +
+
+ ${esc(thread.messages[0]?.authorName || 'Anonymous')} + ${timeAgo(thread.createdAt)} +
+ ${thread.messages.map(msg => ` +
+
${esc(msg.authorName)}
+
${esc(msg.text)}
+
+ `).join('')} + ${thread.messages.length === 0 ? '
Click to add a comment...
' : ''} +
+ + +
+
+ + +
+
+ `).join('')} +
+ `; + + 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); diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index c79b4b7..69f50b2 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -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 | 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 | 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%)

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%)

Maya is tracking expenses in rF } private mountTiptapEditor(note: Note, isEditable: boolean, isDemo: boolean) { + const useYjs = !isDemo && isEditable; + this.contentZone.innerHTML = `

@@ -1021,6 +1043,134 @@ Gear: EUR 400 (10%)

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%)

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%)

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%)

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%)

Maya is tracking expenses in rF

${btn('summarize', 'Summarize Note')}
+
+
+ + + +
`; } @@ -1714,6 +1906,8 @@ Gear: EUR 400 (10%)

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%)

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%)

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%)

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; + } `; } } diff --git a/modules/rnotes/components/suggestion-marks.ts b/modules/rnotes/components/suggestion-marks.ts new file mode 100644 index 0000000..b4a3678 --- /dev/null +++ b/modules/rnotes/components/suggestion-marks.ts @@ -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, + ]; + }, +}); diff --git a/modules/rnotes/components/suggestion-plugin.ts b/modules/rnotes/components/suggestion-plugin.ts new file mode 100644 index 0000000..67fcc11 --- /dev/null +++ b/modules/rnotes/components/suggestion-plugin.ts @@ -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(); + 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(); + 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); + } +} diff --git a/modules/rnotes/schemas.ts b/modules/rnotes/schemas.ts index 8c21b80..1d73a54 100644 --- a/modules/rnotes/schemas.ts +++ b/modules/rnotes/schemas.ts @@ -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; createdAt: number; updatedAt: number; } @@ -102,12 +120,12 @@ export function connectionsDocId(space: string) { export const notebookSchema: DocSchema = { 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 = { } } // v2→v3: sourceRef field is optional, no migration needed + // v3→v4: collabEnabled + comments fields are optional, no migration needed return doc; }, }; diff --git a/modules/rnotes/yjs-ws-provider.ts b/modules/rnotes/yjs-ws-provider.ts new file mode 100644 index 0000000..cc38095 --- /dev/null +++ b/modules/rnotes/yjs-ws-provider.ts @@ -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): 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; + } +} diff --git a/package.json b/package.json index 53c164d..5406a22 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/index.ts b/server/index.ts index 03e43b5..daec693 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2838,7 +2838,21 @@ const server = Bun.serve({ 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" })); diff --git a/shared/local-first/runtime.ts b/shared/local-first/runtime.ts index 6ed6908..f1d7681 100644 --- a/shared/local-first/runtime.ts +++ b/shared/local-first/runtime.ts @@ -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): 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. */ diff --git a/shared/local-first/sync.ts b/shared/local-first/sync.ts index e616603..515f774 100644 --- a/shared/local-first/sync.ts +++ b/shared/local-first/sync.ts @@ -142,6 +142,9 @@ export class DocSyncManager { // Pending doc-list requests #docListCallbacks = new Map(); + // Custom message handlers (for Yjs, etc.) + #customMessageListeners = new Map 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): 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);