Real-time collaboration with Yjs CRDT over Cloudflare Durable Objects
- Cloudflare Worker + Durable Object server (packages/collab/) - One DO per room with WebSocket Hibernation for near-zero idle cost - Yjs sync protocol for document state (CRDT merge) - Yjs awareness protocol for cursors, selection, presence - Persists doc state to DO storage - CORS headers for cross-origin WebSocket upgrade - Client composable (src/composables/use-collab.ts) - Y.Doc ↔ SceneGraph bidirectional binding - Awareness: broadcasts cursor position, selection, name, color - Auto-join from /share/:roomId URL - Suppresses echo loops between Yjs and graph updates - CollabPanel component (src/components/CollabPanel.vue) - Share button generates room link, copies to clipboard - Join flow: paste link or room ID - Avatar stack with colored initials and tooltips - Name input persisted to localStorage - Remote cursor rendering in CanvasKit - Colored arrow cursor + name label pill per remote peer - Remote selection outlines in peer's color - Only shows cursors on the same page - SPA redirect for /share/* routes (Cloudflare Pages _redirects)
This commit is contained in:
parent
9f8242bce5
commit
2add3ac4c0
139
bun.lock
139
bun.lock
|
|
@ -24,12 +24,15 @@
|
|||
"dedent": "^1.7.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"lib0": "^0.2.117",
|
||||
"prismjs": "^1.30.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"valibot": "^1.2.0",
|
||||
"vue": "^3.5.29",
|
||||
"vue-stream-markdown": "^0.6.0",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.29",
|
||||
"yoga-layout": "^3.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -68,6 +71,18 @@
|
|||
"@types/bun": "^1.2.9",
|
||||
},
|
||||
},
|
||||
"packages/collab": {
|
||||
"name": "@open-pencil/collab",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"y-protocols": "^1.0.6",
|
||||
"yjs": "^13.6.24",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250214.0",
|
||||
"wrangler": "^4",
|
||||
},
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@open-pencil/core",
|
||||
"version": "0.1.0",
|
||||
|
|
@ -150,14 +165,34 @@
|
|||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.14.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260218.0" }, "optionalPeers": ["workerd"] }, "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260305.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-chhKOpymo0Eh9J3nymrauMqKGboCc4uz/j0gA1G4gioMnKsN2ZDKJ+qjRZDnCoVGy8u2C4pxlmyIfsXCAfIzhQ=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260305.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-K9aG2OQk5bBfOP+fyGPqLcqZ9OR3ra6uwnxJ8f2mveq2A2LsCI7ZeGxQiAj75Ti80ytH/gJffZIx4Np2JtU3aQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260305.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tt7XUoIw/cYFeGbkPkcZ6XX1aZm26Aju/4ih+DXxOosbBeGshFSrNJDBfAKKOvkjsAZymJ+WWVDBU+hmNaGfwA=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260305.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-72QTkY5EzylmvCZ8ZTrnJ9DctmQsfSof1OKyOWqu/pv/B2yACfuPMikq8RpPxvVu7hhS0ztGP6ZvXz72Htq4Zg=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260305.0", "", { "os": "win32", "cpu": "x64" }, "sha512-BA0uaQPOaI2F6mJtBDqplGnQQhpXCzwEMI33p/TnDxtSk9u8CGIfBFuI6uqo8mJ6ijIaPjeBLGOn2CiRMET4qg=="],
|
||||
|
||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260301.1", "", {}, "sha512-klKnECMb5A4GtVF0P5NH6rCjtyjqIEKJaz6kEtx9YPHhfFO2HUEarO+MI4F8WPchgeZqpGlEpDhRapzrOTw51Q=="],
|
||||
|
||||
"@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="],
|
||||
|
||||
"@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="],
|
||||
|
||||
"@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||
|
|
@ -226,6 +261,56 @@
|
|||
|
||||
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="],
|
||||
|
||||
"@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="],
|
||||
|
|
@ -258,6 +343,8 @@
|
|||
|
||||
"@open-pencil/cli": ["@open-pencil/cli@workspace:packages/cli"],
|
||||
|
||||
"@open-pencil/collab": ["@open-pencil/collab@workspace:packages/collab"],
|
||||
|
||||
"@open-pencil/core": ["@open-pencil/core@workspace:packages/core"],
|
||||
|
||||
"@open-pencil/docs": ["@open-pencil/docs@workspace:packages/docs"],
|
||||
|
|
@ -344,6 +431,12 @@
|
|||
|
||||
"@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
|
@ -412,6 +505,10 @@
|
|||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||
|
|
@ -598,6 +695,8 @@
|
|||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"blamer": ["blamer@1.0.7", "", { "dependencies": { "execa": "^4.0.0", "which": "^2.0.2" } }, "sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
|
@ -640,6 +739,8 @@
|
|||
|
||||
"constantinople": ["constantinople@4.0.1", "", { "dependencies": { "@babel/parser": "^7.6.0", "@babel/types": "^7.6.1" } }, "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
|
@ -676,6 +777,8 @@
|
|||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
|
@ -774,6 +877,8 @@
|
|||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-stringify": ["js-stringify@1.0.2", "", {}, "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="],
|
||||
|
|
@ -790,6 +895,10 @@
|
|||
|
||||
"katex": ["katex@0.16.33", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
|
|
@ -930,6 +1039,8 @@
|
|||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20260305.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260305.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-jVhtKJtiwaZa3rI+WgoLvSJmEazDsoUmAPYRUmEe2VO6VSbvkhbnDRm+dsPbYRatgNIExwrpqG1rv96jHiSb0w=="],
|
||||
|
||||
"minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
|
@ -966,6 +1077,8 @@
|
|||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
|
@ -1046,6 +1159,10 @@
|
|||
|
||||
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
|
@ -1072,6 +1189,8 @@
|
|||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"swrv": ["swrv@1.1.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ=="],
|
||||
|
|
@ -1102,8 +1221,12 @@
|
|||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
|
||||
"undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
|
@ -1150,14 +1273,30 @@
|
|||
|
||||
"with": ["with@7.0.2", "", { "dependencies": { "@babel/parser": "^7.9.6", "@babel/types": "^7.9.6", "assert-never": "^1.2.1", "babel-walk": "3.0.0-canary-5" } }, "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w=="],
|
||||
|
||||
"workerd": ["workerd@1.20260305.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260305.0", "@cloudflare/workerd-darwin-arm64": "1.20260305.0", "@cloudflare/workerd-linux-64": "1.20260305.0", "@cloudflare/workerd-linux-arm64": "1.20260305.0", "@cloudflare/workerd-windows-64": "1.20260305.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-JkhfCLU+w+KbQmZ9k49IcDYc78GBo7eG8Mir8E2+KVjR7otQAmpcLlsous09YLh8WQ3Bt3Mi6/WMStvMAPukeA=="],
|
||||
|
||||
"wrangler": ["wrangler@4.69.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.14.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260305.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260305.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260305.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-EmVfIM65I5b4ITHe3Y9R7zQyf4NUBQ1leStakMlWiVR9n6VlDwuEltyQI2l3i0JciDnWyR3uqe+T6C08ivniTQ=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
|
||||
|
||||
"yjs": ["yjs@13.6.29", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
|
|
|||
7
components.d.ts
vendored
7
components.d.ts
vendored
|
|
@ -11,7 +11,6 @@ export {}
|
|||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ApiKeySetup: typeof import('./src/components/chat/ApiKeySetup.vue')['default']
|
||||
APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default']
|
||||
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
|
||||
AppMenu: typeof import('./src/components/AppMenu.vue')['default']
|
||||
|
|
@ -20,6 +19,7 @@ declare module 'vue' {
|
|||
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
|
||||
ChatPanel: typeof import('./src/components/ChatPanel.vue')['default']
|
||||
CodePanel: typeof import('./src/components/CodePanel.vue')['default']
|
||||
CollabPanel: typeof import('./src/components/CollabPanel.vue')['default']
|
||||
ColorInput: typeof import('./src/components/ColorInput.vue')['default']
|
||||
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
|
||||
DesignPanel: typeof import('./src/components/DesignPanel.vue')['default']
|
||||
|
|
@ -46,7 +46,6 @@ declare module 'vue' {
|
|||
IconLucideCheck: typeof import('~icons/lucide/check')['default']
|
||||
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||
IconLucideCircleDot: typeof import('~icons/lucide/circle-dot')['default']
|
||||
IconLucideCode: typeof import('~icons/lucide/code')['default']
|
||||
IconLucideCopy: typeof import('~icons/lucide/copy')['default']
|
||||
IconLucideEye: typeof import('~icons/lucide/eye')['default']
|
||||
|
|
@ -55,7 +54,6 @@ declare module 'vue' {
|
|||
IconLucideFlipHorizontal: typeof import('~icons/lucide/flip-horizontal')['default']
|
||||
IconLucideFlipVertical: typeof import('~icons/lucide/flip-vertical')['default']
|
||||
IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default']
|
||||
IconLucideHash: typeof import('~icons/lucide/hash')['default']
|
||||
IconLucideImage: typeof import('~icons/lucide/image')['default']
|
||||
IconLucideItalic: typeof import('~icons/lucide/italic')['default']
|
||||
IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default']
|
||||
|
|
@ -69,12 +67,11 @@ declare module 'vue' {
|
|||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideSend: typeof import('~icons/lucide/send')['default']
|
||||
IconLucideSettings2: typeof import('~icons/lucide/settings2')['default']
|
||||
IconLucideShare2: typeof import('~icons/lucide/share2')['default']
|
||||
IconLucideSparkles: typeof import('~icons/lucide/sparkles')['default']
|
||||
IconLucideSquare: typeof import('~icons/lucide/square')['default']
|
||||
IconLucideStrikethrough: typeof import('~icons/lucide/strikethrough')['default']
|
||||
IconLucideToggleLeft: typeof import('~icons/lucide/toggle-left')['default']
|
||||
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
|
||||
IconLucideType: typeof import('~icons/lucide/type')['default']
|
||||
IconLucideUnderline: typeof import('~icons/lucide/underline')['default']
|
||||
IconLucideUnlink: typeof import('~icons/lucide/unlink')['default']
|
||||
IconLucideX: typeof import('~icons/lucide/x')['default']
|
||||
|
|
|
|||
|
|
@ -47,12 +47,15 @@
|
|||
"dedent": "^1.7.1",
|
||||
"fflate": "^0.8.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"lib0": "^0.2.117",
|
||||
"prismjs": "^1.30.0",
|
||||
"reka-ui": "^2.8.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"valibot": "^1.2.0",
|
||||
"vue": "^3.5.29",
|
||||
"vue-stream-markdown": "^0.6.0",
|
||||
"y-protocols": "^1.0.7",
|
||||
"yjs": "^13.6.29",
|
||||
"yoga-layout": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
18
packages/collab/package.json
Normal file
18
packages/collab/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@open-pencil/collab",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"types": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20250214.0",
|
||||
"wrangler": "^4"
|
||||
},
|
||||
"dependencies": {
|
||||
"yjs": "^13.6.24",
|
||||
"y-protocols": "^1.0.6"
|
||||
}
|
||||
}
|
||||
204
packages/collab/src/index.ts
Normal file
204
packages/collab/src/index.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { DurableObject } from 'cloudflare:workers'
|
||||
import * as Y from 'yjs'
|
||||
import * as syncProtocol from 'y-protocols/sync'
|
||||
import * as awarenessProtocol from 'y-protocols/awareness'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
|
||||
const MSG_SYNC = 0
|
||||
const MSG_AWARENESS = 1
|
||||
|
||||
interface Env {
|
||||
ROOMS: DurableObjectNamespace<CollabRoom>
|
||||
}
|
||||
|
||||
export class CollabRoom extends DurableObject {
|
||||
doc: Y.Doc
|
||||
awareness: awarenessProtocol.Awareness
|
||||
|
||||
constructor(ctx: DurableObjectState, env: Env) {
|
||||
super(ctx, env)
|
||||
this.doc = new Y.Doc()
|
||||
this.awareness = new awarenessProtocol.Awareness(this.doc)
|
||||
|
||||
// Restore persisted doc state
|
||||
this.ctx.blockConcurrencyWhile(async () => {
|
||||
const stored = await this.ctx.storage.get<Uint8Array>('doc')
|
||||
if (stored) {
|
||||
Y.applyUpdate(this.doc, new Uint8Array(stored))
|
||||
}
|
||||
})
|
||||
|
||||
// Restore awareness from hibernated WebSockets
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
const tag = ws.deserializeAttachment() as { clientId: number } | null
|
||||
if (tag) {
|
||||
// Awareness state will be re-sent by the client on reconnect
|
||||
}
|
||||
}
|
||||
|
||||
this.doc.on('update', (_update: Uint8Array, origin: unknown) => {
|
||||
if (origin === 'remote') return
|
||||
// Persist on server-originated updates (rare, mostly initial)
|
||||
this.persistDoc()
|
||||
})
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === '/health') {
|
||||
return new Response('ok')
|
||||
}
|
||||
|
||||
const upgradeHeader = request.headers.get('Upgrade')
|
||||
if (!upgradeHeader || upgradeHeader !== 'websocket') {
|
||||
return new Response('Expected WebSocket', { status: 426 })
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair()
|
||||
const [client, server] = Object.values(pair)
|
||||
|
||||
this.ctx.acceptWebSocket(server)
|
||||
|
||||
const clientId = Math.floor(Math.random() * 0xFFFFFF)
|
||||
server.serializeAttachment({ clientId })
|
||||
|
||||
// Send sync step 1 to the new peer
|
||||
const syncEncoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(syncEncoder, MSG_SYNC)
|
||||
syncProtocol.writeSyncStep1(syncEncoder, this.doc)
|
||||
server.send(encoding.toUint8Array(syncEncoder))
|
||||
|
||||
// Send current awareness states
|
||||
const awarenessStates = this.awareness.getStates()
|
||||
if (awarenessStates.size > 0) {
|
||||
const awarenessEncoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(awarenessEncoder, MSG_AWARENESS)
|
||||
const update = awarenessProtocol.encodeAwarenessUpdate(
|
||||
this.awareness,
|
||||
Array.from(awarenessStates.keys())
|
||||
)
|
||||
encoding.writeVarUint8Array(awarenessEncoder, update)
|
||||
server.send(encoding.toUint8Array(awarenessEncoder))
|
||||
}
|
||||
|
||||
return new Response(null, { status: 101, webSocket: client })
|
||||
}
|
||||
|
||||
async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string) {
|
||||
if (typeof message === 'string') return
|
||||
|
||||
const data = new Uint8Array(message)
|
||||
const decoder = decoding.createDecoder(data)
|
||||
const msgType = decoding.readVarUint(decoder)
|
||||
|
||||
switch (msgType) {
|
||||
case MSG_SYNC: {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, MSG_SYNC)
|
||||
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, 'remote')
|
||||
|
||||
if (encoding.length(encoder) > 1) {
|
||||
ws.send(encoding.toUint8Array(encoder))
|
||||
}
|
||||
|
||||
// If we received an update (step 2 or update), broadcast to others
|
||||
if (syncMessageType === 1 || syncMessageType === 2) {
|
||||
this.broadcastExcept(ws, data)
|
||||
this.persistDoc()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case MSG_AWARENESS: {
|
||||
const update = decoding.readVarUint8Array(decoder)
|
||||
awarenessProtocol.applyAwarenessUpdate(this.awareness, update, ws)
|
||||
this.broadcastExcept(ws, data)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async webSocketClose(ws: WebSocket, code: number, reason: string) {
|
||||
const tag = ws.deserializeAttachment() as { clientId: number } | null
|
||||
if (tag) {
|
||||
awarenessProtocol.removeAwarenessStates(this.awareness, [tag.clientId], 'peer left')
|
||||
// Broadcast awareness removal
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, MSG_AWARENESS)
|
||||
const update = awarenessProtocol.encodeAwarenessUpdate(this.awareness, [tag.clientId])
|
||||
encoding.writeVarUint8Array(encoder, update)
|
||||
this.broadcastExcept(ws, encoding.toUint8Array(encoder))
|
||||
}
|
||||
ws.close(code, reason)
|
||||
}
|
||||
|
||||
async webSocketError(ws: WebSocket) {
|
||||
const tag = ws.deserializeAttachment() as { clientId: number } | null
|
||||
if (tag) {
|
||||
awarenessProtocol.removeAwarenessStates(this.awareness, [tag.clientId], 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastExcept(sender: WebSocket, data: Uint8Array) {
|
||||
for (const ws of this.ctx.getWebSockets()) {
|
||||
if (ws !== sender) {
|
||||
try {
|
||||
ws.send(data)
|
||||
} catch {
|
||||
// Peer disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async persistDoc() {
|
||||
const state = Y.encodeStateAsUpdate(this.doc)
|
||||
await this.ctx.storage.put('doc', state)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
// CORS preflight
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, {
|
||||
headers: corsHeaders()
|
||||
})
|
||||
}
|
||||
|
||||
// Route: /room/:roomId
|
||||
const match = url.pathname.match(/^\/room\/([a-zA-Z0-9_-]+)$/)
|
||||
if (!match) {
|
||||
return new Response('Not found', { status: 404, headers: corsHeaders() })
|
||||
}
|
||||
|
||||
const roomId = match[1]
|
||||
const id = env.ROOMS.idFromName(roomId)
|
||||
const stub = env.ROOMS.get(id)
|
||||
|
||||
const response = await stub.fetch(request)
|
||||
|
||||
// Add CORS headers for non-WebSocket responses
|
||||
if (response.status !== 101) {
|
||||
const headers = new Headers(response.headers)
|
||||
for (const [key, value] of Object.entries(corsHeaders())) {
|
||||
headers.set(key, value)
|
||||
}
|
||||
return new Response(response.body, { status: response.status, headers })
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
function corsHeaders(): Record<string, string> {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Upgrade, Content-Type'
|
||||
}
|
||||
}
|
||||
13
packages/collab/tsconfig.json
Normal file
13
packages/collab/tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["@cloudflare/workers-types/2023-07-01"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
26
packages/collab/wrangler.jsonc
Normal file
26
packages/collab/wrangler.jsonc
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "open-pencil-collab",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2025-02-26",
|
||||
"durable_objects": {
|
||||
"bindings": [
|
||||
{
|
||||
"name": "ROOMS",
|
||||
"class_name": "CollabRoom"
|
||||
}
|
||||
]
|
||||
},
|
||||
"migrations": [
|
||||
{
|
||||
"tag": "v1",
|
||||
"new_sqlite_classes": ["CollabRoom"]
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "collab.openpencil.dev",
|
||||
"custom_domain": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -104,6 +104,13 @@ export interface RenderOverlays {
|
|||
cursorX?: number
|
||||
cursorY?: number
|
||||
} | null
|
||||
remoteCursors?: Array<{
|
||||
name: string
|
||||
color: Color
|
||||
x: number
|
||||
y: number
|
||||
selection?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export class SkiaRenderer {
|
||||
|
|
@ -401,6 +408,7 @@ export class SkiaRenderer {
|
|||
this.drawMarquee(canvas, overlays.marquee)
|
||||
this.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator)
|
||||
this.drawPenOverlay(canvas, overlays.penState)
|
||||
this.drawRemoteCursors(canvas, graph, overlays.remoteCursors)
|
||||
if (this.showRulers) this.drawRulers(canvas, graph, selectedIds)
|
||||
|
||||
canvas.restore()
|
||||
|
|
@ -1974,6 +1982,93 @@ export class SkiaRenderer {
|
|||
vertexStroke.delete()
|
||||
}
|
||||
|
||||
// --- Remote Cursors ---
|
||||
|
||||
private drawRemoteCursors(
|
||||
canvas: Canvas,
|
||||
graph: SceneGraph,
|
||||
cursors?: RenderOverlays['remoteCursors']
|
||||
): void {
|
||||
if (!cursors || cursors.length === 0) return
|
||||
|
||||
const CURSOR_SIZE = 16
|
||||
const LABEL_PADDING_X = 6
|
||||
const LABEL_PADDING_Y = 3
|
||||
const LABEL_FONT_SIZE = 11
|
||||
const LABEL_OFFSET_X = 12
|
||||
const LABEL_OFFSET_Y = 18
|
||||
|
||||
for (const cursor of cursors) {
|
||||
const screenX = cursor.x * this.zoom + this.panX
|
||||
const screenY = cursor.y * this.zoom + this.panY
|
||||
const { r, g, b } = cursor.color
|
||||
|
||||
// Draw remote selections
|
||||
if (cursor.selection?.length) {
|
||||
this.auxStroke.setColor(this.ck.Color4f(r, g, b, 0.6))
|
||||
this.auxStroke.setStrokeWidth(1.5)
|
||||
this.auxStroke.setPathEffect(null)
|
||||
for (const nodeId of cursor.selection) {
|
||||
const node = graph.getNode(nodeId)
|
||||
if (!node) continue
|
||||
const abs = graph.getAbsolutePosition(nodeId)
|
||||
const sx = abs.x * this.zoom + this.panX
|
||||
const sy = abs.y * this.zoom + this.panY
|
||||
const sw = node.width * this.zoom
|
||||
const sh = node.height * this.zoom
|
||||
canvas.drawRect(this.ck.XYWHRect(sx, sy, sw, sh), this.auxStroke)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw cursor arrow (triangle pointing top-left)
|
||||
this.auxFill.setColor(this.ck.Color4f(r, g, b, 1))
|
||||
const path = new this.ck.Path()
|
||||
path.moveTo(screenX, screenY)
|
||||
path.lineTo(screenX, screenY + CURSOR_SIZE)
|
||||
path.lineTo(screenX + CURSOR_SIZE * 0.4, screenY + CURSOR_SIZE * 0.7)
|
||||
path.close()
|
||||
canvas.drawPath(path, this.auxFill)
|
||||
|
||||
// White outline for contrast
|
||||
this.auxStroke.setColor(this.ck.Color4f(1, 1, 1, 0.9))
|
||||
this.auxStroke.setStrokeWidth(1)
|
||||
this.auxStroke.setPathEffect(null)
|
||||
canvas.drawPath(path, this.auxStroke)
|
||||
path.delete()
|
||||
|
||||
// Draw name label
|
||||
if (cursor.name) {
|
||||
const font = this.labelFont
|
||||
if (font) {
|
||||
font.setSize(LABEL_FONT_SIZE)
|
||||
const labelX = screenX + LABEL_OFFSET_X
|
||||
const labelY = screenY + LABEL_OFFSET_Y
|
||||
const glyphIds = font.getGlyphIDs(cursor.name)
|
||||
const widths = font.getGlyphWidths(glyphIds)
|
||||
let textWidth = 0
|
||||
for (let i = 0; i < widths.length; i++) textWidth += widths[i]
|
||||
|
||||
// Label background (pill)
|
||||
this.auxFill.setColor(this.ck.Color4f(r, g, b, 1))
|
||||
const bgRect = this.ck.RRectXY(
|
||||
this.ck.XYWHRect(
|
||||
labelX - LABEL_PADDING_X,
|
||||
labelY - LABEL_FONT_SIZE - LABEL_PADDING_Y + 2,
|
||||
textWidth + LABEL_PADDING_X * 2,
|
||||
LABEL_FONT_SIZE + LABEL_PADDING_Y * 2
|
||||
),
|
||||
4, 4
|
||||
)
|
||||
canvas.drawRRect(bgRect, this.auxFill)
|
||||
|
||||
// Label text
|
||||
this.auxFill.setColor(this.ck.Color4f(1, 1, 1, 1))
|
||||
canvas.drawText(cursor.name, labelX, labelY, this.auxFill, font)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rulers ---
|
||||
|
||||
private drawRulers(canvas: Canvas, graph: SceneGraph, selectedIds: Set<string>): void {
|
||||
|
|
|
|||
1
public/_redirects
Normal file
1
public/_redirects
Normal file
|
|
@ -0,0 +1 @@
|
|||
/share/* /index.html 200
|
||||
28
src/App.vue
28
src/App.vue
|
|
@ -4,9 +4,11 @@ import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
|
|||
|
||||
import { useKeyboard } from './composables/use-keyboard'
|
||||
import { useMenu } from './composables/use-menu'
|
||||
import { useCollab } from './composables/use-collab'
|
||||
import { createDemoShapes } from './demo'
|
||||
import { provideEditorStore } from './stores/editor'
|
||||
|
||||
import CollabPanel from './components/CollabPanel.vue'
|
||||
import EditorCanvas from './components/EditorCanvas.vue'
|
||||
import LayersPanel from './components/LayersPanel.vue'
|
||||
import PropertiesPanel from './components/PropertiesPanel.vue'
|
||||
|
|
@ -16,6 +18,7 @@ import Toolbar from './components/Toolbar.vue'
|
|||
const store = provideEditorStore()
|
||||
useKeyboard(store)
|
||||
useMenu(store)
|
||||
const collab = useCollab(store)
|
||||
;(window as Window & { __OPEN_PENCIL_STORE__?: typeof store }).__OPEN_PENCIL_STORE__ = store
|
||||
|
||||
useEventListener(
|
||||
|
|
@ -32,6 +35,12 @@ const showChrome = !('no-chrome' in params)
|
|||
if (!('test' in params)) {
|
||||
createDemoShapes(store)
|
||||
}
|
||||
|
||||
// Auto-join room from /share/:roomId URL
|
||||
const shareMatch = window.location.pathname.match(/^\/share\/([a-zA-Z0-9_-]+)/)
|
||||
if (shareMatch) {
|
||||
collab.connect(shareMatch[1])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -53,6 +62,25 @@ if (!('test' in params)) {
|
|||
<div class="relative flex min-w-0 flex-1">
|
||||
<EditorCanvas />
|
||||
<Toolbar />
|
||||
<div class="absolute right-3 top-3 z-10">
|
||||
<CollabPanel
|
||||
:state="collab.state.value"
|
||||
:peers="collab.remotePeers.value"
|
||||
@share="() => {
|
||||
const roomId = collab.shareCurrentDoc()
|
||||
window.history.pushState({}, '', `/share/${roomId}`)
|
||||
}"
|
||||
@join="(roomId: string) => {
|
||||
collab.connect(roomId)
|
||||
window.history.pushState({}, '', `/share/${roomId}`)
|
||||
}"
|
||||
@disconnect="() => {
|
||||
collab.disconnect()
|
||||
window.history.pushState({}, '', '/')
|
||||
}"
|
||||
@update:name="collab.setLocalName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterResizeHandle class="group relative z-10 -mx-1 w-2 cursor-col-resize">
|
||||
|
|
|
|||
215
src/components/CollabPanel.vue
Normal file
215
src/components/CollabPanel.vue
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
PopoverRoot,
|
||||
PopoverTrigger,
|
||||
PopoverPortal,
|
||||
PopoverContent,
|
||||
TooltipRoot,
|
||||
TooltipTrigger,
|
||||
TooltipPortal,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { CollabState, RemotePeer } from '@/composables/use-collab'
|
||||
import type { Color } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
state: CollabState
|
||||
peers: RemotePeer[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
share: []
|
||||
join: [roomId: string]
|
||||
disconnect: []
|
||||
'update:name': [name: string]
|
||||
}>()
|
||||
|
||||
const joinInput = ref('')
|
||||
const nameDraft = ref(props.state.localName)
|
||||
const copied = ref(false)
|
||||
|
||||
const shareUrl = computed(() => {
|
||||
if (!props.state.roomId) return ''
|
||||
return `${window.location.origin}/share/${props.state.roomId}`
|
||||
})
|
||||
|
||||
function copyLink() {
|
||||
if (!shareUrl.value) return
|
||||
navigator.clipboard.writeText(shareUrl.value)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
|
||||
function onShare() {
|
||||
if (!nameDraft.value.trim()) return
|
||||
emit('update:name', nameDraft.value.trim())
|
||||
emit('share')
|
||||
}
|
||||
|
||||
function onJoin() {
|
||||
if (!joinInput.value.trim() || !nameDraft.value.trim()) return
|
||||
emit('update:name', nameDraft.value.trim())
|
||||
const roomId = joinInput.value.trim().replace(/.*\/share\//, '')
|
||||
emit('join', roomId)
|
||||
}
|
||||
|
||||
function colorToCSS(c: Color): string {
|
||||
return `rgb(${Math.round(c.r * 255)}, ${Math.round(c.g * 255)}, ${Math.round(c.b * 255)})`
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
return name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2) || '?'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Avatar stack for connected peers -->
|
||||
<TooltipProvider v-if="state.connected" :delay-duration="200">
|
||||
<div class="flex -space-x-1.5">
|
||||
<!-- Local user -->
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-full border-2 border-panel text-[10px] font-semibold text-white"
|
||||
:style="{ background: colorToCSS(state.localColor) }"
|
||||
>
|
||||
{{ initials(state.localName || 'You') }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
class="rounded bg-neutral-800 px-2 py-1 text-xs text-white shadow-lg"
|
||||
:side-offset="4"
|
||||
>
|
||||
{{ state.localName || 'You' }} (you)
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
|
||||
<!-- Remote peers -->
|
||||
<TooltipRoot v-for="peer in peers" :key="peer.clientId">
|
||||
<TooltipTrigger as-child>
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-full border-2 border-panel text-[10px] font-semibold text-white"
|
||||
:style="{ background: colorToCSS(peer.color) }"
|
||||
>
|
||||
{{ initials(peer.name) }}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
class="rounded bg-neutral-800 px-2 py-1 text-xs text-white shadow-lg"
|
||||
:side-offset="4"
|
||||
>
|
||||
{{ peer.name }}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</TooltipRoot>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<!-- Share button / popover -->
|
||||
<PopoverRoot>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
class="flex h-7 cursor-pointer items-center gap-1.5 rounded-md border-none px-3 text-xs font-medium transition-colors"
|
||||
:class="state.connected
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: 'bg-accent text-white hover:bg-accent/90'"
|
||||
>
|
||||
<icon-lucide-share-2 class="size-3.5" />
|
||||
{{ state.connected ? 'Connected' : 'Share' }}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
class="z-50 w-72 rounded-lg border border-border bg-panel p-3 shadow-xl"
|
||||
:side-offset="8"
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<!-- Connected state -->
|
||||
<template v-if="state.connected">
|
||||
<div class="mb-3 text-xs font-medium text-surface">Room link</div>
|
||||
<div class="mb-3 flex items-center gap-1.5">
|
||||
<input
|
||||
:value="shareUrl"
|
||||
readonly
|
||||
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
|
||||
@focus="($event.target as HTMLInputElement).select()"
|
||||
/>
|
||||
<button
|
||||
class="flex h-7 cursor-pointer items-center gap-1 rounded border-none bg-accent px-2 text-xs text-white hover:bg-accent/90"
|
||||
@click="copyLink"
|
||||
>
|
||||
<icon-lucide-check v-if="copied" class="size-3" />
|
||||
<icon-lucide-copy v-else class="size-3" />
|
||||
{{ copied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-2 text-xs font-medium text-surface">
|
||||
{{ peers.length + 1 }} {{ peers.length === 0 ? 'person' : 'people' }} in this room
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex h-7 w-full cursor-pointer items-center justify-center rounded border border-border bg-transparent text-xs text-muted hover:bg-hover hover:text-surface"
|
||||
@click="emit('disconnect')"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Not connected: share or join -->
|
||||
<template v-else>
|
||||
<div class="mb-3">
|
||||
<label class="mb-1 block text-xs text-muted">Your name</label>
|
||||
<input
|
||||
v-model="nameDraft"
|
||||
class="w-full rounded border border-border bg-input px-2 py-1 text-xs text-surface"
|
||||
placeholder="Enter your name"
|
||||
@keydown.enter="onShare"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="mb-3 flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border-none bg-accent text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
:disabled="!nameDraft.trim()"
|
||||
@click="onShare"
|
||||
>
|
||||
<icon-lucide-share-2 class="size-3.5" />
|
||||
Share this file
|
||||
</button>
|
||||
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
<div class="h-px flex-1 bg-border" />
|
||||
<span class="text-[11px] text-muted">or join a room</span>
|
||||
<div class="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input
|
||||
v-model="joinInput"
|
||||
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface"
|
||||
placeholder="Paste room link or ID"
|
||||
@keydown.enter="onJoin"
|
||||
/>
|
||||
<button
|
||||
class="flex h-7 cursor-pointer items-center rounded border-none bg-accent px-3 text-xs text-white hover:bg-accent/90 disabled:opacity-50"
|
||||
:disabled="!joinInput.trim() || !nameDraft.trim()"
|
||||
@click="onJoin"
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -82,7 +82,10 @@ export function useCanvas(canvasRef: Ref<HTMLCanvasElement | null>, store: Edito
|
|||
cursorX: store.state.penCursorX ?? undefined,
|
||||
cursorY: store.state.penCursorY ?? undefined
|
||||
}
|
||||
: null
|
||||
: null,
|
||||
remoteCursors: store.state.remoteCursors.length > 0
|
||||
? store.state.remoteCursors
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
398
src/composables/use-collab.ts
Normal file
398
src/composables/use-collab.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import { ref, onUnmounted, computed } from 'vue'
|
||||
import * as Y from 'yjs'
|
||||
import * as syncProtocol from 'y-protocols/sync'
|
||||
import * as awarenessProtocol from 'y-protocols/awareness'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import * as decoding from 'lib0/decoding'
|
||||
|
||||
import type { EditorStore } from '@/stores/editor'
|
||||
import type { SceneNode } from '@/engine/scene-graph'
|
||||
import type { Color } from '@/types'
|
||||
|
||||
const MSG_SYNC = 0
|
||||
const MSG_AWARENESS = 1
|
||||
|
||||
const COLLAB_URL = import.meta.env.VITE_COLLAB_URL || 'wss://collab.openpencil.dev'
|
||||
|
||||
const PEER_COLORS: Color[] = [
|
||||
{ r: 0.96, g: 0.26, b: 0.21, a: 1 },
|
||||
{ r: 0.13, g: 0.59, b: 0.95, a: 1 },
|
||||
{ r: 0.30, g: 0.69, b: 0.31, a: 1 },
|
||||
{ r: 1.00, g: 0.76, b: 0.03, a: 1 },
|
||||
{ r: 0.61, g: 0.15, b: 0.69, a: 1 },
|
||||
{ r: 1.00, g: 0.34, b: 0.13, a: 1 },
|
||||
{ r: 0.00, g: 0.74, b: 0.83, a: 1 },
|
||||
{ r: 0.91, g: 0.12, b: 0.39, a: 1 },
|
||||
]
|
||||
|
||||
export interface RemotePeer {
|
||||
clientId: number
|
||||
name: string
|
||||
color: Color
|
||||
cursor?: { x: number; y: number; pageId: string }
|
||||
selection?: string[]
|
||||
}
|
||||
|
||||
export interface CollabState {
|
||||
connected: boolean
|
||||
roomId: string | null
|
||||
peers: RemotePeer[]
|
||||
localName: string
|
||||
localColor: Color
|
||||
}
|
||||
|
||||
export function useCollab(store: EditorStore) {
|
||||
const state = ref<CollabState>({
|
||||
connected: false,
|
||||
roomId: null,
|
||||
peers: [],
|
||||
localName: localStorage.getItem('op-collab-name') || '',
|
||||
localColor: PEER_COLORS[Math.floor(Math.random() * PEER_COLORS.length)],
|
||||
})
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let ydoc: Y.Doc | null = null
|
||||
let awareness: awarenessProtocol.Awareness | null = null
|
||||
let ynodes: Y.Map<Y.Map<unknown>> | null = null
|
||||
let ymeta: Y.Map<unknown> | null = null
|
||||
let suppressGraphEvents = false
|
||||
let suppressYjsEvents = false
|
||||
|
||||
const remotePeers = computed(() => state.value.peers)
|
||||
|
||||
function connect(roomId: string) {
|
||||
if (ws) disconnect()
|
||||
|
||||
state.value.roomId = roomId
|
||||
ydoc = new Y.Doc()
|
||||
awareness = new awarenessProtocol.Awareness(ydoc)
|
||||
ynodes = ydoc.getMap('nodes')
|
||||
ymeta = ydoc.getMap('meta')
|
||||
|
||||
// Listen for awareness changes → update peers list
|
||||
awareness.on('change', () => {
|
||||
updatePeersList()
|
||||
})
|
||||
|
||||
// Listen for remote Yjs changes → apply to SceneGraph
|
||||
ynodes.observeDeep((events) => {
|
||||
if (suppressYjsEvents) return
|
||||
suppressGraphEvents = true
|
||||
try {
|
||||
applyYjsToGraph(events)
|
||||
} finally {
|
||||
suppressGraphEvents = false
|
||||
}
|
||||
store.requestRender()
|
||||
})
|
||||
|
||||
// WebSocket connection
|
||||
const url = `${COLLAB_URL}/room/${roomId}`
|
||||
ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
|
||||
ws.onopen = () => {
|
||||
state.value.connected = true
|
||||
broadcastAwareness()
|
||||
if (ymeta) ymeta.set('roomId', roomId)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) return
|
||||
handleMessage(new Uint8Array(event.data))
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
state.value.connected = false
|
||||
// TODO: reconnect logic
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
state.value.connected = false
|
||||
}
|
||||
|
||||
// Sync local SceneGraph → Yjs on graph mutations
|
||||
const origUpdateNode = store.graph.updateNode.bind(store.graph)
|
||||
store.graph.updateNode = (id: string, changes: Partial<SceneNode>) => {
|
||||
origUpdateNode(id, changes)
|
||||
if (!suppressGraphEvents && ydoc && ynodes) {
|
||||
syncNodeToYjs(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
if (awareness) {
|
||||
awareness.destroy()
|
||||
awareness = null
|
||||
}
|
||||
if (ydoc) {
|
||||
ydoc.destroy()
|
||||
ydoc = null
|
||||
}
|
||||
ynodes = null
|
||||
ymeta = null
|
||||
state.value.connected = false
|
||||
state.value.roomId = null
|
||||
state.value.peers = []
|
||||
}
|
||||
|
||||
function handleMessage(data: Uint8Array) {
|
||||
if (!ydoc || !awareness) return
|
||||
|
||||
const decoder = decoding.createDecoder(data)
|
||||
const msgType = decoding.readVarUint(decoder)
|
||||
|
||||
switch (msgType) {
|
||||
case MSG_SYNC: {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, MSG_SYNC)
|
||||
syncProtocol.readSyncMessage(decoder, encoder, ydoc, null)
|
||||
|
||||
if (encoding.length(encoder) > 1) {
|
||||
sendBinary(encoding.toUint8Array(encoder))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case MSG_AWARENESS: {
|
||||
const update = decoding.readVarUint8Array(decoder)
|
||||
awarenessProtocol.applyAwarenessUpdate(awareness, update, null)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendBinary(data: Uint8Array) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync doc updates to server
|
||||
function setupDocSync() {
|
||||
if (!ydoc) return
|
||||
ydoc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin === 'remote') return
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarUint(encoder, MSG_SYNC)
|
||||
syncProtocol.writeUpdate(encoder, update)
|
||||
sendBinary(encoding.toUint8Array(encoder))
|
||||
})
|
||||
}
|
||||
|
||||
function syncNodeToYjs(nodeId: string) {
|
||||
if (!ydoc || !ynodes) return
|
||||
const node = store.graph.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
suppressYjsEvents = true
|
||||
ydoc.transact(() => {
|
||||
let ynode = ynodes!.get(nodeId)
|
||||
if (!ynode) {
|
||||
ynode = new Y.Map()
|
||||
ynodes!.set(nodeId, ynode)
|
||||
}
|
||||
// Sync all scalar properties
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === 'childIds') {
|
||||
ynode.set(key, JSON.stringify(value))
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
ynode.set(key, JSON.stringify(value))
|
||||
} else {
|
||||
ynode.set(key, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
suppressYjsEvents = false
|
||||
}
|
||||
|
||||
function syncAllNodesToYjs() {
|
||||
if (!ydoc || !ynodes) return
|
||||
suppressYjsEvents = true
|
||||
ydoc.transact(() => {
|
||||
for (const node of store.graph.getAllNodes()) {
|
||||
let ynode = ynodes!.get(node.id)
|
||||
if (!ynode) {
|
||||
ynode = new Y.Map()
|
||||
ynodes!.set(node.id, ynode)
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (key === 'childIds') {
|
||||
ynode.set(key, JSON.stringify(value))
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
ynode.set(key, JSON.stringify(value))
|
||||
} else {
|
||||
ynode.set(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
suppressYjsEvents = false
|
||||
}
|
||||
|
||||
function applyYjsToGraph(events: Y.YEvent<Y.Map<unknown>>[]) {
|
||||
for (const event of events) {
|
||||
if (event.target === ynodes) {
|
||||
// Top-level additions/deletions of nodes
|
||||
for (const [key, change] of event.changes.keys) {
|
||||
if (change.action === 'add') {
|
||||
const ynode = ynodes!.get(key)
|
||||
if (ynode) applyYnodeToGraph(key, ynode)
|
||||
} else if (change.action === 'delete') {
|
||||
store.graph.deleteNode(key)
|
||||
}
|
||||
}
|
||||
} else if (event.target.parent === ynodes) {
|
||||
// Property changes within a node's Y.Map
|
||||
const nodeId = findNodeIdForYMap(event.target as Y.Map<unknown>)
|
||||
if (nodeId) {
|
||||
const ynode = ynodes!.get(nodeId)
|
||||
if (ynode) applyYnodeToGraph(nodeId, ynode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findNodeIdForYMap(ymap: Y.Map<unknown>): string | null {
|
||||
if (!ynodes) return null
|
||||
for (const [key, value] of ynodes.entries()) {
|
||||
if (value === ymap) return key
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function applyYnodeToGraph(nodeId: string, ynode: Y.Map<unknown>) {
|
||||
const existing = store.graph.getNode(nodeId)
|
||||
const props: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of ynode.entries()) {
|
||||
if (key === 'childIds' || key === 'fills' || key === 'strokes' || key === 'effects' ||
|
||||
key === 'vectorNetwork' || key === 'boundVariables' || key === 'styleRuns') {
|
||||
try {
|
||||
props[key] = typeof value === 'string' ? JSON.parse(value) : value
|
||||
} catch {
|
||||
props[key] = value
|
||||
}
|
||||
} else {
|
||||
props[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
store.graph.updateNode(nodeId, props as Partial<SceneNode>)
|
||||
} else {
|
||||
// New node from remote — create it
|
||||
const parentId = props.parentId as string
|
||||
if (parentId && store.graph.getNode(parentId)) {
|
||||
const type = props.type as SceneNode['type']
|
||||
const node = store.graph.createNode(type, parentId, props as Partial<SceneNode>)
|
||||
// Override the auto-generated id with the actual id
|
||||
store.graph.nodes.delete(node.id)
|
||||
node.id = nodeId
|
||||
store.graph.nodes.set(nodeId, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Awareness: broadcast local cursor/selection
|
||||
function broadcastAwareness() {
|
||||
if (!awareness) return
|
||||
awareness.setLocalStateField('user', {
|
||||
name: state.value.localName,
|
||||
color: state.value.localColor,
|
||||
})
|
||||
}
|
||||
|
||||
function updateCursor(x: number, y: number, pageId: string) {
|
||||
if (!awareness) return
|
||||
awareness.setLocalStateField('cursor', { x, y, pageId })
|
||||
}
|
||||
|
||||
function updateSelection(ids: string[]) {
|
||||
if (!awareness) return
|
||||
awareness.setLocalStateField('selection', ids)
|
||||
}
|
||||
|
||||
function updatePeersList() {
|
||||
if (!awareness) return
|
||||
const states = awareness.getStates()
|
||||
const peers: RemotePeer[] = []
|
||||
const localClientId = awareness.clientID
|
||||
const currentPageId = store.state.currentPageId
|
||||
|
||||
states.forEach((peerState, clientId) => {
|
||||
if (clientId === localClientId) return
|
||||
const user = peerState.user as { name?: string; color?: Color } | undefined
|
||||
if (!user) return
|
||||
peers.push({
|
||||
clientId,
|
||||
name: user.name || 'Anonymous',
|
||||
color: user.color || PEER_COLORS[clientId % PEER_COLORS.length],
|
||||
cursor: peerState.cursor as RemotePeer['cursor'],
|
||||
selection: peerState.selection as string[],
|
||||
})
|
||||
})
|
||||
|
||||
state.value.peers = peers
|
||||
|
||||
// Update store's remoteCursors for renderer
|
||||
store.state.remoteCursors = peers
|
||||
.filter(p => p.cursor && p.cursor.pageId === currentPageId)
|
||||
.map(p => ({
|
||||
name: p.name,
|
||||
color: p.color,
|
||||
x: p.cursor!.x,
|
||||
y: p.cursor!.y,
|
||||
selection: p.selection,
|
||||
}))
|
||||
store.requestRender()
|
||||
}
|
||||
|
||||
function setLocalName(name: string) {
|
||||
state.value.localName = name
|
||||
localStorage.setItem('op-collab-name', name)
|
||||
broadcastAwareness()
|
||||
}
|
||||
|
||||
function generateRoomId(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function shareCurrentDoc(): string {
|
||||
const roomId = generateRoomId()
|
||||
connect(roomId)
|
||||
setupDocSync()
|
||||
syncAllNodesToYjs()
|
||||
return roomId
|
||||
}
|
||||
|
||||
function joinRoom(roomId: string) {
|
||||
connect(roomId)
|
||||
setupDocSync()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
return {
|
||||
state,
|
||||
remotePeers,
|
||||
connect: joinRoom,
|
||||
disconnect,
|
||||
shareCurrentDoc,
|
||||
updateCursor,
|
||||
updateSelection,
|
||||
setLocalName,
|
||||
}
|
||||
}
|
||||
|
|
@ -167,6 +167,13 @@ export function createEditorStore() {
|
|||
} | null,
|
||||
penCursorX: null as number | null,
|
||||
penCursorY: null as number | null,
|
||||
remoteCursors: [] as Array<{
|
||||
name: string
|
||||
color: Color
|
||||
x: number
|
||||
y: number
|
||||
selection?: string[]
|
||||
}>,
|
||||
panX: 0,
|
||||
pageColor: { ...CANVAS_BG_COLOR } as Color,
|
||||
panY: 0,
|
||||
|
|
|
|||
Loading…
Reference in a new issue