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:
Danila Poyarkov 2026-03-01 16:44:37 +03:00
parent 9f8242bce5
commit 2add3ac4c0
14 changed files with 1153 additions and 6 deletions

139
bun.lock
View file

@ -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
View file

@ -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']

View file

@ -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": {

View 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"
}
}

View 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'
}
}

View 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"]
}

View 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
}
]
}

View file

@ -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
View file

@ -0,0 +1 @@
/share/* /index.html 200

View file

@ -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">

View 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>

View file

@ -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
})
}

View 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,
}
}

View file

@ -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,