diff --git a/.env.example b/.env.example index 95dedac6..5963edfa 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,2 @@ PUBLIC_JAZZ_SYNC_SERVER=wss://alkalye-sync.localhost +PUBLIC_TLDRAW_LICENSE_KEY= diff --git a/astro.config.ts b/astro.config.ts index 44465760..4ebb8bd0 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -15,6 +15,18 @@ export default defineConfig({ routing: { prefixDefaultLocale: false }, }, vite: { + build: { + rollupOptions: { + output: { + onlyExplicitManualChunks: true, + manualChunks(id) { + let match = id.match(/node_modules\/@tldraw\/([^/]+)/) + if (match?.[1]) return `tldraw-${match[1]}` + if (id.includes("/node_modules/tldraw/")) return "tldraw" + }, + }, + }, + }, plugins: [ tanstackRouter({ target: "react", @@ -75,7 +87,7 @@ export default defineConfig({ globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2,woff,ttf}"], navigateFallback: "app", navigateFallbackAllowlist: [/^\/app(?:\/.*)?(?:\?.*)?$/], - maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, + maximumFileSizeToCacheInBytes: 5.5 * 1024 * 1024, }, }), ], @@ -85,6 +97,11 @@ export default defineConfig({ context: "client", access: "public", }), + PUBLIC_TLDRAW_LICENSE_KEY: envField.string({ + context: "client", + access: "public", + optional: true, + }), }, }, }) diff --git a/bun.lock b/bun.lock index 8f77e4bd..58eadfcc 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", + "tldraw": "^5.2.2", "tw-animate-css": "^1.4.0", "vite-plugin-pwa": "^1.2.0", "zod": "^4.3.6", @@ -748,6 +749,126 @@ "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@radix-ui/number": ["@radix-ui/number@1.1.2", "", {}, "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.4", "", {}, "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.11", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collapsible": "1.1.15", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dialog": "1.1.18", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6c2cXpNlAgHDhKguK24XcWHHayMpK+lk7/WwBXBco+ZJ4Dv7xP++GBM280KgTD/HCRu3jSdfe8WQiZssonYaIA=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.2.1", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+8PWoLLZv3AVb5m0pvoiOca/bQGzc9vPVb+982HB2x3Un0DpYEPM3zLMl4oqRpBsocJuNqLkiv/HXTnTrlwr4g=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.11", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-menu": "2.1.19", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qzsA/ZPhF6yMxBOTIk1nlCkoy2mswSbwYL+ErBa2iP0s4WWrlxmczArYqMcpVfEjmM7KJj/ADPXky0yZfbSxtQ=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-effect-event": "0.0.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.19", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.11", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-label": "2.1.11", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0mTMJHv1gQAuEQoq5VDpTD3MRgmfUFdXAVFhpqR7wBeUr+tyRsof0wv/4XdPHLwQrefhoH2FiGHCggrCJhalIw=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rt+Fx4HoCeEwFL2IdoV2QaPltqDLlzxN77i9nwB3Y70scFlfAHh1QCdE2TXKuFJtA1TNygb0oivnFBZifgtZOw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.19", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-menu": "2.1.19", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Glt6mebxcgQvLeVkH3HiqV5bgQubE+31ELxLs7q0GlYI5k0XYkOkeuPrhXoylxK8eufvIt9CJjzY1TfFMXK3qw=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.17", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.11", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Rsgab65u73E5kPVh8OS6PgPwJgPyf08GFfJDGAbMdF4DL7CgDhFOaDnXuk/DiMEVF6kgQwl0oJmFklvipmiOLg=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-is-hydrated": "0.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pQ3xGp/uemomASPH97Eb3shfXX8QlG11bBJyEvRBV+vwtO4HvQlS06Yj9f31Ao7XepvF98SFrRgVDQ7jv+2xjQ=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.3.2", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-rect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.13", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.6", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.7", "", { "dependencies": { "@radix-ui/react-slot": "1.3.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-KqiGJcFaZDc+BvveAgU3ZhACg2MvSUDrCBx4lRR/ZVRNal0bvt8lBpvnSkep9heeOuF8Qfw3fszLDX4OpQ2NVw=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.4.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-W8Uo9riHnlzLLWy+r2mVHUyuEWqD/+be4PZzbEvaGoFSBDHkm+GYWjtcE6u3AmPKNyfanWpnVfpZ2GqPCdzzsw=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.13", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.3.2", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.4.2", "", { "dependencies": { "@radix-ui/number": "1.1.2", "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-previous": "1.1.2", "@radix-ui/react-use-size": "1.1.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.18", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YNEnTHV47hPep+U0QvVM02OJNka9uygREc+k4Nh5VSZBg4MmE+myI442x3hCGfRpX7N2WSSYSJKws4gE+Z8lgg=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bI2ILJrzwgmAsH05TsJ9pVrzqQwAip7OM2/krqAdYn0R16bl86UPWbe5VPHsALat0EnqpV01cGtkleaUKPNdNg=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-toggle": "1.1.13", "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TK1vusNKb8IRhF23FTbRgUNZ9zfs5rGIyI7LfR3h26p9LrQ060i0uW9QWeD8baZMddaaP0DBGlIa6pbZG+mitg=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-separator": "1.1.11", "@radix-ui/react-toggle-group": "1.1.14" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-L/EkWVqlnj3lL2toHh4C7PwH2jxfa7OCq6lGfXSCii99ve2S4Ux5rc9HnOa7LN9exHa/Nl9kmCAmP9BuDPy5UA=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.3", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.3", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.3", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.2", "", { "dependencies": { "@radix-ui/rect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="], "@rollup/plugin-babel": ["@rollup/plugin-babel@5.3.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.10.4", "@rollup/pluginutils": "^3.1.0" }, "peerDependencies": { "@babel/core": "^7.0.0", "@types/babel__core": "^7.1.9", "rollup": "^1.20.0||^2.0.0" }, "optionalPeers": ["@types/babel__core"] }, "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q=="], @@ -906,8 +1027,78 @@ "@tiptap/core": ["@tiptap/core@3.27.1", "", { "peerDependencies": { "@tiptap/pm": "3.27.1" } }, "sha512-rV6Qn4wmC6BxfF+4mu6bqGWj9vA4oXXhsrpXaJL2uhjxeHAGofjwcHof2X84VYzeyXgdlsGmqKie4TAppVXZUQ=="], + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-VMF7xJx6qEGiX6DTKNiL31NLqypOcd/4sNjFSe8rb41PwejBJh/nOqVIbBvWkiT6NMGFLxMhj7zJ8/zPo1hXeg=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-TlC5bsS+pqETTrlz4CZz9RO/cKBYtELGIxwtKeivUn3eNfnOxQbbu4WDsiwIfzRFyd0OMnKl6BPM2KnYEehoEQ=="], + + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@3.27.1", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-j/j8Qp9Z5nViade2m7zjrO/CYH/Ca80Qj7aqo0eUaei6FZQ5izlF9o4XQU5EFMAutV6mwynsPUp8FVo5sCuYfw=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@3.27.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.27.1" } }, "sha512-faCUHnRP47o9Zh9VZZX6EX/569udw9Vopm2PgEKPWuKLE2qaS5WBuUVU0iItdJmKUqaWiOZkpoW4jvnDmj0dfg=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-epOUpFfEmBzjvnqvjv2qHX7NAuLo5dlOGV690lWu+sAYMjibuJBeVvAiKPyFCfRCCTUxdbDB3jbaOA1yEcEJ7w=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.27.1", "", { "peerDependencies": { "@tiptap/extensions": "3.27.1" } }, "sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@3.27.1", "", { "peerDependencies": { "@floating-ui/dom": "^1.0.0", "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-BmJF1VqB7dSJkgAalrpVFj88WLhxKjcWPuWHOqf2ITrUU2832BhKLXKmxjWUy1gqV8PfNNVWtGfIERy7I0y0+Q=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@3.27.1", "", { "peerDependencies": { "@tiptap/extensions": "3.27.1" } }, "sha512-QoezN0wdvXIwLQ4ee2ccWDaX3RG0lzgQpIMpMz55oPDhpUVax1+19ApsS53LkcktpS4EbnPL4xO4DaJk0Vp7PQ=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-iv/m9hzl6jfSj9Q8UEjAxONvCoUDaP7M9SRCPx3PaLNxA230TTD6RE0Ye4zFJ8ze7ZVoJJMAqg9Qpq1iYg2JOQ=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-SrC4l1kEIyv9ZXFaI/8LQqU2MyMmjczw7XXsWUQOTN4YXv0JyVgMNR3cI/wz0d2xsTfBdZ1N85Tdng+Ga1t0Sg=="], + + "@tiptap/extension-highlight": ["@tiptap/extension-highlight@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-IeMIUKbW4oyRkgn92BPYQ0fifkbTwVigKwa107nGDHMokz+pHQPFHy8xG3U7gtUOra/8OUo57bFgNGuP0TaH4A=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-QlKE7qn5qMnIGVGhXQlvYedvLtNJ9z0dmit5w8vPb8tKzW4Spk6M7N2kruprrDA8GBwHfeR5wmF+njfUm34qxg=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-jGGeyn9uRUnNjSTHpbqhiGsp6KaYTSbV09jDXPJI9cDwfV9hpugLvpaCZd0BMBbhU1B1W6kOfX0BE15qX/HQfA=="], + + "@tiptap/extension-link": ["@tiptap/extension-link@3.27.1", "", { "dependencies": { "linkifyjs": "^4.3.3" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-/2jBfsxBZUDGJmpZifqRQPz7f1E5qpS1BckTZ39TADzUJX+feKy7RJ3DtQ02+8y6SSMzvP9loGVjrk6zEMTk4g=="], + + "@tiptap/extension-list": ["@tiptap/extension-list@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-c2Upru7lj0/ZV/Ibww6cNz6sUS8m6Dp/9uygFhYcZOd3X8M0xBIEk42c6m6SQehkPziVA8QOgNJz7sMqsbz1OQ=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@3.27.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.27.1" } }, "sha512-zwRl01ETfCkWUvtvK5fw9bXtAajMPkvlkE3Cq6JvH3LF7XXJwDtNj5Tj7exacMpCaSZmlNc43vFb2rAYnrnwMA=="], + + "@tiptap/extension-list-keymap": ["@tiptap/extension-list-keymap@3.27.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.27.1" } }, "sha512-OIMZNlzPSO8WRd4ic73Fxckzl4N1tesjjLL2XApaNA/uMpO0LoF6WSRPAWv+Z24Wp92ARRJAnRP7iZoI5+Jxig=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@3.27.1", "", { "peerDependencies": { "@tiptap/extension-list": "3.27.1" } }, "sha512-GYrKqD//9nHJ2r80uXqbDMzRnFpGzbaEQRTSGaO/SH7DvXWFMow8evkOdjQ7PCQO07jNjJo75+A85Jwu3Ov3AA=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-7K7eo1gruOgAsnbK+GCV23AUVUI0cL1bTig8HaPneoFMVbig7vddk8jNLKBWO8TXVbG7TuHdnDN4F98vdtwh5Q=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-Y3DW1jlSlCNCyMGHP3+3qBNNPS83wuFz4RTYGjZtvRRTCRh7apZme9XRWMq1rN5mJ2Cr7fKocA2/5Bs13KgN6Q=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-6ZwaZwSrDh+KFFv6V1J79oO37yPs7y1bFxvk1/9Ih2rn3Xr5AWz+eMS+n8RpH3djBVVAQpdIAeYQgcn+VCSsTg=="], + + "@tiptap/extension-underline": ["@tiptap/extension-underline@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-N889J4nXN/TPfVt8uF9N1A0SY82E90zwc1y26lqOcw6KWNLmQrlhMh/9OD4ikLDbekmFpOBq/UicpHf/6S8hbQ=="], + + "@tiptap/extensions": ["@tiptap/extensions@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-1Tdx9faw8k0/83V6X+xCDVhV8yElGt95JxeW3YMkKQJI56QdlPz0xOdJPlMiSGJKinPyVier+x9LJD/YZUZIaw=="], + "@tiptap/pm": ["@tiptap/pm@3.27.1", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.7", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.0", "prosemirror-transform": "^1.12.0", "prosemirror-view": "^1.41.8" } }, "sha512-Ffjx+vimmBU7zH/KrpXzJid3+pziCe/VL2aexSTP63cyQwKQ65LkFkCKaIsSpFdQQuakVZBGWjCA5RoBV852pw=="], + "@tiptap/react": ["@tiptap/react@3.27.1", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.27.1", "@tiptap/extension-floating-menu": "^3.27.1" }, "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-/Wn2fc9zMtX08MXYScDFsm4wJ8lzfhfPEdbtls7WCDlbtrop48PWlkHDBBJrywARfAQTB2mFs9KiFy9yrQm5Lg=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@3.27.1", "", { "dependencies": { "@tiptap/core": "^3.27.1", "@tiptap/extension-blockquote": "^3.27.1", "@tiptap/extension-bold": "^3.27.1", "@tiptap/extension-bullet-list": "^3.27.1", "@tiptap/extension-code": "^3.27.1", "@tiptap/extension-code-block": "^3.27.1", "@tiptap/extension-document": "^3.27.1", "@tiptap/extension-dropcursor": "^3.27.1", "@tiptap/extension-gapcursor": "^3.27.1", "@tiptap/extension-hard-break": "^3.27.1", "@tiptap/extension-heading": "^3.27.1", "@tiptap/extension-horizontal-rule": "^3.27.1", "@tiptap/extension-italic": "^3.27.1", "@tiptap/extension-link": "^3.27.1", "@tiptap/extension-list": "^3.27.1", "@tiptap/extension-list-item": "^3.27.1", "@tiptap/extension-list-keymap": "^3.27.1", "@tiptap/extension-ordered-list": "^3.27.1", "@tiptap/extension-paragraph": "^3.27.1", "@tiptap/extension-strike": "^3.27.1", "@tiptap/extension-text": "^3.27.1", "@tiptap/extension-underline": "^3.27.1", "@tiptap/extensions": "^3.27.1", "@tiptap/pm": "^3.27.1" } }, "sha512-vfxRsqW8rCc0k4pzo0ilU3wobVi2wqVj88VZI2SlgZlNnUAkrDGDIAph7CTa9k9fshV+O1ivpEgPC5yC046jow=="], + + "@tldraw/driver": ["@tldraw/driver@5.2.2", "", { "dependencies": { "@tldraw/editor": "5.2.2", "@tldraw/utils": "5.2.2" } }, "sha512-tl3Lw2XO9qXkhh2QivKEOp5MjYhnlWrMjx7X3BMMB4RrlhPU/2Z+PTJTdOKP+hQ/YgqUVICm239VDmRdrMNgSQ=="], + + "@tldraw/editor": ["@tldraw/editor@5.2.2", "", { "dependencies": { "@tiptap/core": "^3.12.1", "@tiptap/pm": "^3.12.1", "@tiptap/react": "^3.12.1", "@tldraw/state": "5.2.2", "@tldraw/state-react": "5.2.2", "@tldraw/store": "5.2.2", "@tldraw/tlschema": "5.2.2", "@tldraw/utils": "5.2.2", "@tldraw/validate": "5.2.2", "classnames": "^2.5.1", "eventemitter3": "^4.0.7", "idb": "^7.1.1", "is-plain-object": "^5.0.0", "rbush": "^3.0.1" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-JJF5WA+kPmXfTFaNVcJeq6dx8dE7TEIozcyNo4ejYgMrsm4I5U5djAimgZwKFT18hPTqdgWOgmbwYAV5Ej8GMw=="], + + "@tldraw/state": ["@tldraw/state@5.2.2", "", { "dependencies": { "@tldraw/utils": "5.2.2" } }, "sha512-BBii1HNspHnrUG3QARuFhHejRDKID/CtYHnrlGHD/x0PPb40xud+5hW0wkuzrdZOZvd/VEMpz6kXufIdqyVBYQ=="], + + "@tldraw/state-react": ["@tldraw/state-react@5.2.2", "", { "dependencies": { "@tldraw/state": "5.2.2", "@tldraw/utils": "5.2.2" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-OIXMeE/3sr8jXaQWwFNH4cGyfwm7djJczbGxJXpGXWDNR6mdZHc8JxMxXIF9ZHGGjQ9tuaMA2sTq/SPGSLrH4g=="], + + "@tldraw/store": ["@tldraw/store@5.2.2", "", { "dependencies": { "@tldraw/state": "5.2.2", "@tldraw/utils": "5.2.2" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1" } }, "sha512-loVLoMZrx844qDAns+Z/ypEc69vM+RiLt/Lhh3hvysTRbBnKffr8COB4Xj0ocl3KdCmFTiRLP9H5fAYJpKijqQ=="], + + "@tldraw/tlschema": ["@tldraw/tlschema@5.2.2", "", { "dependencies": { "@tldraw/state": "5.2.2", "@tldraw/store": "5.2.2", "@tldraw/utils": "5.2.2", "@tldraw/validate": "5.2.2" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-FvlslRXI8DaUyT2l1O0Qa5FknBV2fQ6EoZS5wJs32pQuZ8wMk58GugyLCeJ3XfszfTiY7VPYD920PH8Fc6ldRw=="], + + "@tldraw/utils": ["@tldraw/utils@5.2.2", "", { "dependencies": { "lodash.isequal": "^4.5.0", "lodash.isequalwith": "^4.4.0", "lodash.throttle": "^4.1.1", "lodash.uniq": "^4.5.0" } }, "sha512-TifV6LePp5eGWbT65Jo0RJHjABRnv6ZzJg/BBOEr0Km98UJl15uvuOwmO1zKeII3SQkUP6+n0VEP4x8O174UfA=="], + + "@tldraw/validate": ["@tldraw/validate@5.2.2", "", { "dependencies": { "@tldraw/utils": "5.2.2" } }, "sha512-pQuvfYsu2w3gPexVx+LaUw9d5nlkxu7e+2P0+x/uItoBEc2rn0FT6Usf4fUkvJo6WPiNlIEGC6wsMidsK5tcTA=="], + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], @@ -960,6 +1151,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], @@ -1062,6 +1255,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -1176,6 +1371,8 @@ "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -1318,6 +1515,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], @@ -1452,6 +1651,8 @@ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -1532,6 +1733,8 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], "get-own-enumerable-property-symbols": ["get-own-enumerable-property-symbols@3.0.2", "", {}, "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g=="], @@ -1712,6 +1915,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-plain-object": ["is-plain-object@5.0.0", "", {}, "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], @@ -1842,16 +2047,26 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "linkifyjs": ["linkifyjs@4.3.3", "", {}, "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], + + "lodash.isequalwith": ["lodash.isequalwith@4.4.0", "", {}, "sha512-dcZON0IalGBpRmJBmMkaoV7d3I80R2O+FrzsZyHdNSFrANq/cgDqKQNmAHE8UEj4+QYWwwhkQOVdLHiAopzlsQ=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.sortby": ["lodash.sortby@4.7.0", "", {}, "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA=="], + "lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ=="], + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], @@ -1862,6 +2077,8 @@ "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], @@ -2226,6 +2443,10 @@ "queueueue": ["queueueue@4.1.2", "", { "dependencies": { "fast-list": "^1.0.3" } }, "sha512-YNOihQyk72FRrGQP+fVjUDVCSgxtHS5EdfgXjCYwPCTyhVWXriHfUt0XK0eT7vcBX0m91g0e0KVO8CxoNLh60g=="], + "quickselect": ["quickselect@2.0.0", "", {}, "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="], + + "radix-ui": ["radix-ui@1.6.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.4", "@radix-ui/react-accessible-icon": "1.1.11", "@radix-ui/react-accordion": "1.2.15", "@radix-ui/react-alert-dialog": "1.1.18", "@radix-ui/react-arrow": "1.1.11", "@radix-ui/react-aspect-ratio": "1.1.11", "@radix-ui/react-avatar": "1.2.1", "@radix-ui/react-checkbox": "1.3.6", "@radix-ui/react-collapsible": "1.1.15", "@radix-ui/react-collection": "1.1.11", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-context": "1.1.4", "@radix-ui/react-context-menu": "2.3.2", "@radix-ui/react-dialog": "1.1.18", "@radix-ui/react-direction": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.19", "@radix-ui/react-focus-guards": "1.1.4", "@radix-ui/react-focus-scope": "1.1.11", "@radix-ui/react-form": "0.1.11", "@radix-ui/react-hover-card": "1.1.18", "@radix-ui/react-label": "2.1.11", "@radix-ui/react-menu": "2.1.19", "@radix-ui/react-menubar": "1.1.19", "@radix-ui/react-navigation-menu": "1.2.17", "@radix-ui/react-one-time-password-field": "0.1.11", "@radix-ui/react-password-toggle-field": "0.1.6", "@radix-ui/react-popover": "1.1.18", "@radix-ui/react-popper": "1.3.2", "@radix-ui/react-portal": "1.1.13", "@radix-ui/react-presence": "1.1.6", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-progress": "1.1.11", "@radix-ui/react-radio-group": "1.4.2", "@radix-ui/react-roving-focus": "1.1.14", "@radix-ui/react-scroll-area": "1.2.13", "@radix-ui/react-select": "2.3.2", "@radix-ui/react-separator": "1.1.11", "@radix-ui/react-slider": "1.4.2", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-switch": "1.3.2", "@radix-ui/react-tabs": "1.1.16", "@radix-ui/react-toast": "1.2.18", "@radix-ui/react-toggle": "1.1.13", "@radix-ui/react-toggle-group": "1.1.14", "@radix-ui/react-toolbar": "1.1.14", "@radix-ui/react-tooltip": "1.2.11", "@radix-ui/react-use-callback-ref": "1.1.2", "@radix-ui/react-use-controllable-state": "1.2.3", "@radix-ui/react-use-effect-event": "0.0.3", "@radix-ui/react-use-escape-keydown": "1.1.3", "@radix-ui/react-use-is-hydrated": "0.1.1", "@radix-ui/react-use-layout-effect": "1.1.2", "@radix-ui/react-use-size": "1.1.2", "@radix-ui/react-visually-hidden": "1.2.7" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QXDXJtB6sK83mLASONYUZCauatcWb+knFviFpN1EhtdbbmlsRmzCLrbZSKztnNiem2KOHIBbiDbauVB7SORXMw=="], + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "^5.1.0" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], @@ -2234,6 +2455,8 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rbush": ["rbush@3.0.1", "", { "dependencies": { "quickselect": "^2.0.0" } }, "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w=="], + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], @@ -2244,6 +2467,12 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -2500,6 +2729,8 @@ "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "tldraw": ["tldraw@5.2.2", "", { "dependencies": { "@tiptap/core": "^3.12.1", "@tiptap/extension-code": "^3.12.1", "@tiptap/extension-highlight": "^3.12.1", "@tiptap/extension-list": "^3.12.1", "@tiptap/pm": "^3.12.1", "@tiptap/react": "^3.12.1", "@tiptap/starter-kit": "^3.12.1", "@tldraw/driver": "5.2.2", "@tldraw/editor": "5.2.2", "@tldraw/store": "5.2.2", "classnames": "^2.5.1", "idb": "^7.1.1", "lz-string": "^1.5.0", "radix-ui": "^1.4.2" }, "peerDependencies": { "react": "^18.2.0 || ^19.2.1", "react-dom": "^18.2.0 || ^19.2.1" } }, "sha512-1UZm9PSpYU8vPD5Y1Nai/cyRrgAZ3Z4FB0ic9X6k21gQXZ21tcilVjtn+/+T6cYQi8hNqRAcA4tIhk1yZ15RDw=="], + "tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="], "tldts-core": ["tldts-core@7.0.19", "", {}, "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A=="], @@ -2616,12 +2847,16 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -2888,6 +3123,8 @@ "@tiptap/pm/prosemirror-transform": ["prosemirror-transform@1.12.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w=="], + "@tldraw/editor/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "@ts-morph/common/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], diff --git a/e2e/space-helpers.ts b/e2e/space-helpers.ts index 47ff36fc..aaf158a8 100644 --- a/e2e/space-helpers.ts +++ b/e2e/space-helpers.ts @@ -173,7 +173,6 @@ async function createSpaceInvite(page: Page, args: CreateSpaceInviteArgs) { await expect(input).toBeVisible({ timeout: 10_000 }) let link = await input.inputValue() let inviteGroupId = parseInviteGroupId(link) - await page.waitForTimeout(3_000) return { ok: true, @@ -266,8 +265,9 @@ async function acceptSpaceInvite(page: Page, args: AcceptSpaceInviteArgs) { if (spaceId) { await expect - .poll(() => invitePage.url()) - .toContain(`/app/spaces/${spaceId}`) + .poll(() => invitePage.url(), { timeout: 30_000 }) + .toMatch(new RegExp(`/app/spaces/${spaceId}/doc/`)) + await expect(invitePage.getByText("Space not found")).toBeHidden() } let url = invitePage.url() diff --git a/package.json b/package.json index d8dc3daa..6aa47e71 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", + "tldraw": "^5.2.2", "tw-animate-css": "^1.4.0", "vite-plugin-pwa": "^1.2.0", "zod": "^4.3.6", diff --git a/public/tldraw/icons/0_merged.svg b/public/tldraw/icons/0_merged.svg new file mode 100644 index 00000000..6d35a32d --- /dev/null +++ b/public/tldraw/icons/0_merged.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/tldraw/translations/de.json b/public/tldraw/translations/de.json new file mode 100644 index 00000000..f42279c0 --- /dev/null +++ b/public/tldraw/translations/de.json @@ -0,0 +1,495 @@ +{ + "a11y.adjust-shape-styles": "Formstile anpassen", + "a11y.enlarge-shape": "Form vergrößern", + "a11y.enter-leave-container": "Container betreten/verlassen", + "a11y.move-shape": "Form verschieben", + "a11y.move-shape-faster": "Form schneller bewegen", + "a11y.multiple-shapes": "{num} Formen ausgewählt", + "a11y.open-context-menu": "Kontextmenü öffnen", + "a11y.open-keyboard-shortcuts": "Tastaturkürzel öffnen", + "a11y.pan-camera": "Kamera schwenken", + "a11y.repeat-shape": "Form wiederholen", + "a11y.rotate-shape-ccw": "Form gegen den Uhrzeigersinn drehen", + "a11y.rotate-shape-ccw-fine": "Form gegen den Uhrzeigersinn drehen (fein)", + "a11y.rotate-shape-cw": "Form im Uhrzeigersinn drehen", + "a11y.rotate-shape-cw-fine": "Form im Uhrzeigersinn drehen (fein)", + "a11y.select-shape": "Nächste Form auswählen", + "a11y.select-shape-direction": "Form in Richtung auswählen", + "a11y.shape-image": "Bild", + "a11y.shape-index": "{num} von {total}", + "a11y.shape-video": "Video", + "a11y.shrink-shape": "Form verkleinern", + "a11y.skip-to-main-content": "Fokus auf die Leinwand verschieben", + "a11y.status": "Status", + "action.align-bottom": "Unten ausrichten", + "action.align-center-horizontal": "Horizontal ausrichten", + "action.align-center-horizontal.short": "Horizontal ausrichten", + "action.align-center-vertical": "Vertikal ausrichten", + "action.align-center-vertical.short": "Vertikal ausrichten", + "action.align-left": "Links ausrichten", + "action.align-right": "Rechts ausrichten", + "action.align-top": "Oben ausrichten", + "action.back-to-content": "Zurück zum Inhalt", + "action.bring-forward": "Vorziehen", + "action.bring-to-front": "Nach vorne bringen", + "action.convert-to-bookmark": "In Lesezeichen umwandeln", + "action.convert-to-embed": "In Einbettung umwandeln", + "action.copy": "Kopieren", + "action.copy-as-json": "Als JSON kopieren", + "action.copy-as-json.short": "JSON", + "action.copy-as-png": "Als PNG kopieren", + "action.copy-as-png.short": "PNG", + "action.copy-as-svg": "Als SVG kopieren", + "action.copy-as-svg.short": "SVG", + "action.cut": "Ausschneiden", + "action.delete": "Löschen", + "action.distribute-horizontal": "Horizontal verteilen", + "action.distribute-horizontal.short": "Horizontal verteilen", + "action.distribute-vertical": "Vertikal verteilen", + "action.distribute-vertical.short": "Vertikal verteilen", + "action.download-original": "Original herunterladen", + "action.duplicate": "Duplizieren", + "action.edit-link": "Link bearbeiten", + "action.enhanced-a11y-mode": "Erweiterten Zugänglichkeitsmodus umschalten", + "action.enhanced-a11y-mode.menu": "Erweiterter Zugänglichkeitsmodus", + "action.exit-pen-mode": "Stiftmodus beenden", + "action.export-all-as-png": "Alle als PNG exportieren", + "action.export-all-as-png.short": "PNG", + "action.export-all-as-svg": "Alle als SVG exportieren", + "action.export-all-as-svg.short": "SVG", + "action.export-as-png": "Als PNG exportieren", + "action.export-as-png.short": "PNG", + "action.export-as-svg": "Exportieren als SVG", + "action.export-as-svg.short": "SVG", + "action.fit-frame-to-content": "Dem Inhalt anpassen", + "action.flatten-to-image": "Glätten", + "action.flip-horizontal": "Horizontal flippen", + "action.flip-horizontal.short": "Horizontal flippen", + "action.flip-vertical": "Vertikal flippen", + "action.flip-vertical.short": "Vertikal flippen", + "action.fork-project": "Dieses Projekt kopieren", + "action.fork-project-on-tldraw": "Projekt auf tldraw kopieren", + "action.group": "Gruppe", + "action.insert-embed": "Einbettung einfügen", + "action.insert-media": "Medien hochladen", + "action.leave-shared-project": "Gemeinsames Projekt verlassen", + "action.new-project": "Neues Projekt", + "action.new-shared-project": "Neues gemeinsames Projekt", + "action.open-cursor-chat": "Cursor-Chat", + "action.open-embed-link": "Link öffnen", + "action.open-file": "Datei öffnen", + "action.open-kbd-shortcuts": "Tastaturkürzel öffnen", + "action.pack": "Kompakt anordnen", + "action.paste": "Einfügen", + "action.paste-error-description": "Einfügen nicht möglich, da keine Berechtigungen für die Zwischenablage vorliegen. Bitte aktivieren Sie die Berechtigungen und versuchen Sie es erneut.", + "action.paste-error-title": "Einfügen fehlgeschlagen", + "action.print": "Drucken", + "action.redo": "Wiederholen", + "action.remove-frame": "Rahmen entfernen", + "action.rename": "Umbenennen", + "action.rotate-ccw": "Drehen gegen den Uhrzeigersinn", + "action.rotate-cw": "Drehen im Uhrzeigersinn", + "action.save-copy": "Eine Kopie speichern", + "action.select-all": "Alle auswählen", + "action.select-none": "Keine auswählen", + "action.select-zoom-tool": "Zoom", + "action.send-backward": "Rückwärts senden", + "action.send-to-back": "Zurücksenden", + "action.share-project": "Dieses Projekt teilen", + "action.stack-horizontal": "Horizontal stapeln", + "action.stack-horizontal.short": "Horizontal stapeln", + "action.stack-vertical": "Vertikal stapeln", + "action.stack-vertical.short": "Vertikal stapeln", + "action.stop-following": "Nicht mehr folgen", + "action.stretch-horizontal": "Horizontal auseinanderziehen", + "action.stretch-horizontal.short": "Horizontal auseinanderziehen", + "action.stretch-vertical": "Vertikal auseinanderziehen", + "action.stretch-vertical.short": "Vertikal auseinanderziehen", + "action.toggle-auto-none": "Automatisch", + "action.toggle-auto-pan": "Automatisch (Trackpad)", + "action.toggle-auto-size": "Automatische Größe umschalten", + "action.toggle-auto-zoom": "Automatisch (Maus)", + "action.toggle-dark-mode": "Dunkelmodus umschalten", + "action.toggle-dark-mode.menu": "Dunkelmodus", + "action.toggle-debug-mode": "Debug-Modus umschalten", + "action.toggle-debug-mode.menu": "Debug-Modus", + "action.toggle-dynamic-size-mode": "Schalter – Dynamische Größe", + "action.toggle-dynamic-size-mode.menu": "Dynamische Größe", + "action.toggle-edge-scrolling": "Schalter – Seitenscrollen", + "action.toggle-edge-scrolling.menu": "Seitenscrollen", + "action.toggle-focus-mode": "Fokus-Modus umschalten", + "action.toggle-focus-mode.menu": "Fokus-Modus", + "action.toggle-grid": "Gitter umschalten", + "action.toggle-grid.menu": "Gitter anzeigen", + "action.toggle-invert-zoom": "Mauszoom-Umkehrung umschalten", + "action.toggle-invert-zoom.menu": "Mauszoom umkehren", + "action.toggle-keyboard-shortcuts": "Tastaturkürzel umschalten", + "action.toggle-keyboard-shortcuts.menu": "Tastaturkürzel", + "action.toggle-lock": "Sperren / Entsperren", + "action.toggle-mouse": "Maus", + "action.toggle-paste-at-cursor": "Schalter – Einfügen an Cursorposition", + "action.toggle-paste-at-cursor.menu": "Einfügen an Cursorposition", + "action.toggle-reduce-motion": "Bewegung reduzieren ein / aus", + "action.toggle-reduce-motion.menu": "Bewegung reduzieren ", + "action.toggle-snap-mode": "Immer einrasten umschalten", + "action.toggle-snap-mode.menu": "Immer an anderen Elementen ausrichten", + "action.toggle-tool-lock": "Tool-Sperre umschalten", + "action.toggle-tool-lock.menu": "Tool-Sperre", + "action.toggle-trackpad": "Trackpad", + "action.toggle-transparent": "Transparenten Hintergrund ein- und ausschalten", + "action.toggle-transparent.context-menu": "Transparent", + "action.toggle-transparent.menu": "Transparent", + "action.toggle-wrap-mode": "Schalter – Beim Umbruch auswählen", + "action.toggle-wrap-mode.menu": "Beim Umbruch auswählen", + "action.undo": "Rückgängig", + "action.ungroup": "Gruppierung auflösen", + "action.unlock-all": "Alle entsperren", + "action.zoom-in": "Vergrößern", + "action.zoom-out": "Verkleinern", + "action.zoom-quick": "Schnellzoom", + "action.zoom-to-100": "Auf 100 % zoomen", + "action.zoom-to-fit": "Zoom auf Inhalt anpassen", + "action.zoom-to-selection": "Zoom auf Auswahl", + "actions-menu.title": "Aktionen", + "align-style.end": "Ende", + "align-style.justify": "Blocksatz", + "align-style.middle": "Mitte", + "align-style.start": "Start", + "app.loading": "tldraw wird geladen ...", + "arrow-kind-style.arc": "Bogen", + "arrow-kind-style.elbow": "Ellbogen", + "arrowheadEnd-style.arrow": "Pfeil", + "arrowheadEnd-style.bar": "Leiste", + "arrowheadEnd-style.diamond": "Raute", + "arrowheadEnd-style.dot": "Punkt", + "arrowheadEnd-style.inverted": "Umgekehrt", + "arrowheadEnd-style.none": "Keine", + "arrowheadEnd-style.pipe": "Rohr", + "arrowheadEnd-style.square": "Quadrat", + "arrowheadEnd-style.triangle": "Dreieck", + "arrowheadStart-style.arrow": "Pfeil", + "arrowheadStart-style.bar": "Leiste", + "arrowheadStart-style.diamond": "Raute", + "arrowheadStart-style.dot": "Punkt", + "arrowheadStart-style.inverted": "Umgekehrt", + "arrowheadStart-style.none": "Keine", + "arrowheadStart-style.pipe": "Rohr", + "arrowheadStart-style.square": "Quadrat", + "arrowheadStart-style.triangle": "Dreieck", + "assets.files.amount-too-many": "Zu viele Dateien", + "assets.files.maximum-size": "Maximale Dateigröße beträgt {size}", + "assets.files.size-too-big": "Dateigröße zu groß", + "assets.files.type-not-allowed": "Dateityp ist nicht erlaubt", + "assets.files.upload-failed": "Upload fehlgeschlagen", + "assets.url.failed": "URL-Vorschau konnte nicht geladen werden", + "color-style.black": "Schwarz", + "color-style.blue": "Blau", + "color-style.green": "Grün", + "color-style.grey": "Grau", + "color-style.light-blue": "Hellblau", + "color-style.light-green": "Hellgrün", + "color-style.light-red": "Hellrot", + "color-style.light-violet": "Hellviolett", + "color-style.orange": "Orange", + "color-style.red": "Rot", + "color-style.violet": "Lila", + "color-style.white": "Weiß", + "color-style.yellow": "Gelb", + "context-menu.arrange": "Anordnen", + "context-menu.copy-as": "Kopieren als", + "context-menu.edit": "Bearbeiten", + "context-menu.export-all-as": "Alle exportieren als", + "context-menu.export-as": "Exportieren als", + "context-menu.move-to-page": "Weiter zur Seite", + "context-menu.reorder": "Neu anordnen", + "context-menu.title": "Kontextmenü", + "context.pages.new-page": "Neue Seite", + "cursor-chat.type-to-chat": "Tippen, um zu chatten...", + "dash-style.dashed": "Gestrichelt", + "dash-style.dotted": "Gepunkted", + "dash-style.draw": "Zeichnen", + "dash-style.solid": "Solide", + "document-name-menu.copy-link": "Link kopieren", + "document.default-name": "Ohne Titel", + "edit-link-dialog.cancel": "Abbrechen", + "edit-link-dialog.clear": "Löschen", + "edit-link-dialog.detail": "Die Links werden in einer neuen Registerkarte geöffnet.", + "edit-link-dialog.external-link": "Externer Link", + "edit-link-dialog.invalid-url": "Ein Link muss eine gültige URL sein.", + "edit-link-dialog.save": "Weiter", + "edit-link-dialog.title": "Link bearbeiten", + "edit-link-dialog.url": "URL", + "embed-dialog.back": "Zurück", + "embed-dialog.cancel": "Abbrechen", + "embed-dialog.create": "Erstellen", + "embed-dialog.instruction": "Fügen Sie die URL der Website ein, um die Einbettung einzufügen.", + "embed-dialog.invalid-url": "Wir konnten keine Einbettung von dieser URL erstellen.", + "embed-dialog.title": "Einbettung erstellen", + "embed-dialog.url": "URL", + "file-system.confirm-clear.cancel": "Abbrechen", + "file-system.confirm-clear.continue": "Weiter", + "file-system.confirm-clear.description": "Wenn Sie ein neues Projekt erstellen, wird Ihr aktuelles Projekt gelöscht und alle nicht gespeicherten Änderungen gehen verloren. Möchten Sie wirklich fortfahren?", + "file-system.confirm-clear.dont-show-again": "Nicht erneut fragen", + "file-system.confirm-clear.title": "Aktuelles Projekt löschen?", + "file-system.confirm-open.cancel": "Abbrechen", + "file-system.confirm-open.description": "Wenn Sie eine Datei öffnen, wird Ihr aktuelles Projekt ersetzt und alle nicht gespeicherten Änderungen gehen verloren. Sind Sie sicher, dass Sie fortfahren möchten?", + "file-system.confirm-open.dont-show-again": "Nicht nochmal fragen", + "file-system.confirm-open.open": "Datei öffnen", + "file-system.confirm-open.title": "Aktuelles Projekt überschreiben?", + "file-system.file-open-error.file-format-version-too-new": "Die Datei, die Sie zu öffnen versucht haben, ist von einer neueren Version von tldraw. Bitte laden Sie die Seite neu und versuchen Sie es erneut.", + "file-system.file-open-error.generic-corrupted-file": "Die Datei, die Sie zu öffnen versucht haben, ist beschädigt.", + "file-system.file-open-error.not-a-tldraw-file": "Die Datei, die Sie zu öffnen versucht haben, sieht nicht wie eine tldraw-Datei aus.", + "file-system.file-open-error.title": "Datei konnte nicht geöffnet werden", + "file-system.shared-document-file-open-error.description": "Das Öffnen von Dateien aus gemeinsamen Projekten wird nicht unterstützt.", + "file-system.shared-document-file-open-error.title": "Konnte die Datei nicht öffnen", + "fill-style.fill": "Ausfüllen", + "fill-style.lined-fill": "Linierte Füllung", + "fill-style.none": "Keine", + "fill-style.pattern": "Muster", + "fill-style.semi": "Semi", + "fill-style.solid": "Solide", + "focus-mode.toggle-focus-mode": "Fokus-Modus umschalten", + "font-style.draw": "Zeichnen", + "font-style.mono": "Mono", + "font-style.sans": "Sans", + "font-style.serif": "Serif", + "geo-style.arrow-down": "Pfeil nach unten", + "geo-style.arrow-left": "Pfeil nach links", + "geo-style.arrow-right": "Pfeil nach rechts", + "geo-style.arrow-up": "Pfeil nach oben", + "geo-style.check-box": "Haken Box", + "geo-style.cloud": "Wolke", + "geo-style.diamond": "Raute", + "geo-style.ellipse": "Ellipse", + "geo-style.heart": "Herz", + "geo-style.hexagon": "Sechseck", + "geo-style.octagon": "Achteck", + "geo-style.oval": "Oval", + "geo-style.pentagon": "Fünfeck", + "geo-style.rectangle": "Rechteck", + "geo-style.rhombus": "Rhombus", + "geo-style.rhombus-2": "Rhombus links", + "geo-style.star": "Stern", + "geo-style.trapezoid": "Trapez", + "geo-style.triangle": "Dreieck", + "geo-style.x-box": "X Box", + "handle.crop.bottom": "Unten zuschneiden", + "handle.crop.bottom-left": "Unten links zuschneiden", + "handle.crop.bottom-right": "Unten rechts zuschneiden", + "handle.crop.left": "Links zuschneiden", + "handle.crop.right": "Rechts zuschneiden", + "handle.crop.top": "Oben zuschneiden", + "handle.crop.top-left": "Oben links zuschneiden", + "handle.crop.top-right": "Oben rechts zuschneiden", + "handle.resize-bottom": "Größe unten ändern", + "handle.resize-bottom-left": "Größe unten links ändern", + "handle.resize-bottom-right": "Größe unten rechts ändern", + "handle.resize-left": "Größe links ändern", + "handle.resize-right": "Größe rechts ändern", + "handle.resize-top": "Größe oben ändern", + "handle.resize-top-left": "Größe oben links ändern", + "handle.resize-top-right": "Größe oben rechts ändern", + "handle.rotate.bottom_left_rotate": "Unten links drehen", + "handle.rotate.bottom_right_rotate": "Unten rechts drehen", + "handle.rotate.mobile_rotate": "Drehen", + "handle.rotate.top_left_rotate": "Oben links drehen", + "handle.rotate.top_right_rotate": "Oben rechts drehen", + "help-menu.about": "Über", + "help-menu.discord": "Discord", + "help-menu.github": "GitHub", + "help-menu.import-tldr-file": "Datei importieren …", + "help-menu.keyboard-shortcuts": "Tastaturkürzel", + "help-menu.privacy": "Datenschutzrichtlinie", + "help-menu.terms": "Nutzungsbedingungen", + "help-menu.title": "Hilfe und Ressourcen", + "help-menu.twitter": "Twitter", + "menu.accessibility": "Zugänglichkeit", + "menu.color-theme": "Farbthema", + "menu.copy-as": "Kopieren als", + "menu.edit": "Bearbeiten", + "menu.export-as": "Exportieren als", + "menu.file": "Datei", + "menu.input-device": "Eingabegerät", + "menu.language": "Sprache", + "menu.preferences": "Einstellungen", + "menu.theme": "Design", + "menu.title": "Menü", + "menu.view": "Anzeigen", + "navigation-zone.minimap": "Minimap", + "navigation-zone.title": "Navigation", + "navigation-zone.toggle-minimap": "Minimap ein- und ausblenden", + "navigation-zone.zoom": "Zoomen", + "opacity-style.0.1": "10 %", + "opacity-style.0.25": "25 %", + "opacity-style.0.5": "50 %", + "opacity-style.0.75": "75 %", + "opacity-style.1": "100 %", + "page-menu.create-new-page": "Neue Seite erstellen", + "page-menu.edit-done": "Fertig", + "page-menu.edit-start": "Bearbeiten", + "page-menu.go-to-page": "Gehe zu Seite", + "page-menu.new-page-initial-name": "Seite 1", + "page-menu.submenu.delete": "Löschen", + "page-menu.submenu.duplicate-page": "Duplizieren", + "page-menu.submenu.move-down": "Nach unten", + "page-menu.submenu.move-up": "Nach oben", + "page-menu.submenu.rename": "Umbenennen", + "page-menu.submenu.title": "Menü", + "page-menu.title": "Seiten", + "people-menu.anonymous-user": "Neuer Benutzer", + "people-menu.avatar-color": "Avatarfarbe", + "people-menu.change-color": "Farbe ändern", + "people-menu.change-name": "Name ändern", + "people-menu.follow": "Folgen", + "people-menu.following": "Gefolgt", + "people-menu.invite": "Andere einladen", + "people-menu.leading": "Folgt Ihnen", + "people-menu.title": "Leute", + "people-menu.user": "(Sie)", + "share-menu.copied": "Link kopiert!", + "share-menu.copy-link": "Link kopieren", + "share-menu.copy-link-note": "Jeder, der den Link hat, kann dieses Projekt ansehen und bearbeiten.", + "share-menu.copy-readonly-link": "Schreibgeschützten Link kopieren", + "share-menu.copy-readonly-link-note": "Jeder, der den Link hat, kann dieses Projekt sehen (aber nicht bearbeiten).", + "share-menu.create-snapshot-link": "Snapshot-Link erstellen", + "share-menu.creating-project": "Das neue Projekt wird erstellt …", + "share-menu.fork-note": "Erstellen Sie ein neues gemeinsames Projekt auf Grundlage dieses Snapshots.", + "share-menu.offline-note": "Wenn Sie dieses Projekt freigeben, wird eine gehostete Live-Kopie unter einer neuen URL erstellt. Sie können die URL mit bis zu dreißig anderen Personen teilen, um das Projekt gemeinsam anzuzeigen und zu bearbeiten.", + "share-menu.project-too-large": "Dieses Projekt kann leider nicht geteilt werden, da es zu groß ist. Wir arbeiten daran!", + "share-menu.save-note": "Laden Sie dieses Projekt als .tldr-Datei auf Ihren Computer herunter.", + "share-menu.share-project": "Dieses Projekt teilen", + "share-menu.snapshot-link-note": "Erfassen und teilen Sie dieses Projekt als schreibgeschützten Snapshot-Link.", + "share-menu.title": "Teilen", + "share-menu.upload-failed": "Ihr Projekt kann im Moment leider nicht hochgeladen werden. Versuchen Sie es bitte erneut oder lassen Sie es uns wissen, falls das Problem weiterhin besteht.", + "sharing.confirm-leave.cancel": "Abbrechen", + "sharing.confirm-leave.description": "Möchten Sie dieses gemeinsame Projekt wirklich verlassen? Über die URL können Sie jederzeit darauf zurückgreifen.", + "sharing.confirm-leave.dont-show-again": "Nicht erneut fragen", + "sharing.confirm-leave.leave": "Verlassen", + "sharing.confirm-leave.title": "Aktuelles Projekt verlassen?", + "shortcuts-dialog.a11y": "Zugänglichkeit", + "shortcuts-dialog.collaboration": "Zusammenarbeit", + "shortcuts-dialog.edit": "Bearbeiten", + "shortcuts-dialog.file": "Datei", + "shortcuts-dialog.preferences": "Einstellungen", + "shortcuts-dialog.text-formatting": "Textformatierung", + "shortcuts-dialog.title": "Tastaturkürzel", + "shortcuts-dialog.tools": "Tools", + "shortcuts-dialog.transform": "Transformieren", + "shortcuts-dialog.view": "Anzeigen", + "size-style.l": "Groß", + "size-style.m": "Mittel", + "size-style.s": "Klein", + "size-style.xl": "Extra groß", + "spline-style.cubic": "Kubisch", + "spline-style.line": "Linie", + "status.offline": "Offline", + "style-panel.align": "Ausrichten", + "style-panel.arrow-kind": "Linie", + "style-panel.arrowhead-end": "Ende", + "style-panel.arrowhead-start": "Anfang", + "style-panel.arrowheads": "Pfeilspitzen", + "style-panel.color": "Farbe", + "style-panel.dash": "Dash", + "style-panel.fill": "Füllen", + "style-panel.font": "Schriftart", + "style-panel.geo": "Form", + "style-panel.label-align": "Labelausrichtung", + "style-panel.mixed": "Gemischt", + "style-panel.opacity": "Deckkraft", + "style-panel.position": "Position", + "style-panel.selected": "ausgewählt", + "style-panel.size": "Größe", + "style-panel.spline": "Spline", + "style-panel.title": "Styles", + "style-panel.vertical-align": "Vertikale Ausrichtung", + "theme.dark": "Dunkel", + "theme.light": "Hell", + "theme.system": "System", + "toast.close": "Schließen", + "toast.error": "Fehler", + "toast.error.copy-fail.desc": "Bild kann nicht kopiert werden", + "toast.error.copy-fail.title": "Kopieren fehlgeschlagen", + "toast.error.export-fail.desc": "Bild kann nicht exportiert werden", + "toast.error.export-fail.title": "Exportieren fehlgeschlagen", + "toast.info": "Info", + "toast.success": "Es hat geklappt", + "toast.warning": "Warnung", + "tool-panel.more": "Mehr", + "tool-panel.title": "Tools", + "tool.arrow": "Pfeil", + "tool.arrow-down": "Pfeil nach unten", + "tool.arrow-left": "Pfeil nach links", + "tool.arrow-right": "Pfeil nach rechts", + "tool.arrow-up": "Pfeil nach oben", + "tool.aspect-ratio": "Seitenverhältnis", + "tool.aspect-ratio.circle": "Kreis (1:1)", + "tool.aspect-ratio.landscape": "Quer (4:3)", + "tool.aspect-ratio.original": "Original", + "tool.aspect-ratio.portrait": "Hoch (3:4)", + "tool.aspect-ratio.square": "Quadrat (1:1)", + "tool.aspect-ratio.wide": "Breit (16:9)", + "tool.bookmark": "Lesezeichen", + "tool.check-box": "Haken Box", + "tool.cloud": "Wolke", + "tool.diamond": "Raute", + "tool.draw": "Zeichnen", + "tool.ellipse": "Ellipse", + "tool.embed": "Einbetten", + "tool.eraser": "Radiergummi", + "tool.flip-horz": "Horizontal flippen", + "tool.flip-vert": "Vertikal flippen", + "tool.frame": "Rahmen", + "tool.hand": "Hand", + "tool.heart": "Herz", + "tool.hexagon": "Sechseck", + "tool.highlight": "Markieren", + "tool.image-crop": "Bild zuschneiden", + "tool.image-crop-confirm": "Bestätigen", + "tool.image-toolbar-title": "Bild-Tools", + "tool.image-zoom": "Zoomen", + "tool.laser": "Laser", + "tool.line": "Linie", + "tool.media": "Medien", + "tool.media-alt-text": "Alternativtext", + "tool.media-alt-text-confirm": "Bestätigen", + "tool.media-alt-text-desc": "Beschreibung angeben ...", + "tool.note": "Hinweis", + "tool.octagon": "Achteck", + "tool.oval": "Oval", + "tool.pentagon": "Fünfeck", + "tool.pointer-down": "Zeiger nach unten", + "tool.rectangle": "Rechteck", + "tool.replace-media": "Medien ersetzen", + "tool.rhombus": "Rhombus", + "tool.rich-text-bold": "Fett", + "tool.rich-text-bulletList": "Aufzählungsliste", + "tool.rich-text-code": "Code", + "tool.rich-text-header": "Kopfzeile", + "tool.rich-text-highlight": "Highlight", + "tool.rich-text-italic": "Kursiv", + "tool.rich-text-link": "Link", + "tool.rich-text-link-remove": "Link entfernen", + "tool.rich-text-link-visit": "Besuchen Sie den Link", + "tool.rich-text-orderedList": "Geordnete Liste", + "tool.rich-text-strikethrough": "Durchgestrichen", + "tool.rich-text-toolbar-title": "Textformatierung", + "tool.rotate-cw": "Drehen", + "tool.select": "Auswählen", + "tool.star": "Stern", + "tool.text": "Text", + "tool.trapezoid": "Trapez", + "tool.triangle": "Dreieck", + "tool.x-box": "X Box", + "ui.checked": "Angekreuzt", + "ui.close": "Schließen", + "ui.unchecked": "Nicht angekreuzt", + "verticalAlign-style.end": "Unten", + "verticalAlign-style.middle": "Mitte", + "verticalAlign-style.start": "Oben", + "vscode.file-open.backup": "Backup", + "vscode.file-open.backup-failed": "Backup fehlgeschlagen: Dies ist keine .tldr-Datei.", + "vscode.file-open.backup-saved": "Backup gespeichert", + "vscode.file-open.desc": "Diese Datei wurde mit einer früheren Version von tldraw erstellt. Möchten Sie sie aktualisieren, damit sie auch mit der neuen Version funktioniert?", + "vscode.file-open.dont-show-again": "Nicht erneut fragen", + "vscode.file-open.open": "Weiter" +} diff --git a/public/tldraw/translations/en.json b/public/tldraw/translations/en.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/public/tldraw/translations/en.json @@ -0,0 +1 @@ +{} diff --git a/src/app/components/ui/dialog.tsx b/src/app/components/ui/dialog.tsx index 9bf7f201..388fda6e 100644 --- a/src/app/components/ui/dialog.tsx +++ b/src/app/components/ui/dialog.tsx @@ -54,9 +54,11 @@ function DialogContent({ className, children, showCloseButton = true, + animated = true, ...props }: DialogPrimitive.Popup.Props & { showCloseButton?: boolean + animated?: boolean }) { return ( @@ -64,7 +66,9 @@ function DialogContent({ +} + +async function copyAsset( + asset: co.loaded, + owner: Group, + options: CopyAssetOptions = {}, +) { + let name = options.name ?? asset.name + let createdAt = new Date() + switch (asset.type) { + case "image": { + let loaded = await asset.$jazz.ensureLoaded({ + resolve: { image: { original: true } }, + }) + let blob = loaded.image.original.toBlob() + if (!blob) throw new Error("Image asset is incomplete") + let image = await createImage(blob, { owner, maxSize: 2048 }) + return ImageAsset.create({ type: "image", name, image, createdAt }, owner) + } + case "video": { + let loaded = await asset.$jazz.ensureLoaded({ resolve: { video: true } }) + let blob = loaded.video.toBlob() + if (!blob) throw new Error("Video asset is incomplete") + let video = await co.fileStream().createFromBlob(blob, { owner }) + return VideoAsset.create( + { + type: "video", + name, + video, + mimeType: asset.mimeType, + muteAudio: asset.muteAudio, + createdAt, + }, + owner, + ) + } + case "tldraw": { + let revision = + options.tldrawRevision ?? + (await asset.$jazz.ensureLoaded({ resolve: { revision: true } })) + .revision + let copied = await copyTldrawRevision(revision, owner) + return createTldrawAssetFromRevision(name, copied, owner, createdAt) + } + default: + return unsupportedAsset(asset) + } +} + +function unsupportedAsset(asset: never): never { + throw new Error(`Unsupported asset: ${asset}`) +} diff --git a/src/app/features/assets/lib/asset-handlers.ts b/src/app/features/assets/lib/asset-handlers.ts index 839e96b0..9b65fce0 100644 --- a/src/app/features/assets/lib/asset-handlers.ts +++ b/src/app/features/assets/lib/asset-handlers.ts @@ -209,6 +209,11 @@ function makeDownloadAsset(doc: LoadedDocument) { } } else if (asset.type === "video" && asset.video?.$isLoaded) { blob = asset.video.toBlob() + } else if (asset.type === "tldraw" && asset.revision?.$isLoaded) { + let revision = await asset.revision.$jazz.ensureLoaded({ + resolve: { snapshot: true }, + }) + blob = revision.snapshot.toBlob() } if (!blob) return @@ -216,7 +221,10 @@ function makeDownloadAsset(doc: LoadedDocument) { let url = URL.createObjectURL(blob) let a = document.createElement("a") a.href = url - a.download = `${name}.${blob.type.split("/")[1] || "png"}` + a.download = + asset.type === "tldraw" + ? `${name}.tldr` + : `${name}.${blob.type.split("/")[1] || "png"}` document.body.appendChild(a) a.click() document.body.removeChild(a) diff --git a/src/app/features/assets/lib/asset-resolve.ts b/src/app/features/assets/lib/asset-resolve.ts new file mode 100644 index 00000000..17361476 --- /dev/null +++ b/src/app/features/assets/lib/asset-resolve.ts @@ -0,0 +1,23 @@ +import type { ResolveQuery } from "jazz-tools" +import { Asset } from "./schema" + +export { assetPreviewResolve, assetContentResolve } + +let assetPreviewResolve = { + image: true, + video: true, + revision: { + lightPreview: true, + darkPreview: true, + }, +} as const satisfies ResolveQuery + +let assetContentResolve = { + image: true, + video: true, + revision: { + snapshot: true, + lightPreview: true, + darkPreview: true, + }, +} as const satisfies ResolveQuery diff --git a/src/app/features/assets/lib/asset-transfer.test.ts b/src/app/features/assets/lib/asset-transfer.test.ts new file mode 100644 index 00000000..43eb0308 --- /dev/null +++ b/src/app/features/assets/lib/asset-transfer.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest" +import { + TLDRAW_BACKUP_MIME_TYPE, + assetExtensionFromMimeType, + assetMimeTypeFromFileName, + classifyAssetFile, + isAssetFileName, +} from "./asset-transfer" + +describe("asset file transfer", () => { + test.each([ + ["photo.png", "", "image"], + ["recording", "video/webm", "video"], + ["board.alkalye-tldraw", "", "tldraw"], + ["board", TLDRAW_BACKUP_MIME_TYPE, "tldraw"], + ] as const)("classifies %s", (name, type, expected) => { + expect(classifyAssetFile({ name, type })).toBe(expected) + }) + + test("rejects unsupported files", () => { + expect(classifyAssetFile({ name: "notes.pdf", type: "" })).toBeNull() + expect(isAssetFileName("notes.pdf")).toBe(false) + }) + + test("owns asset mime and extension mappings", () => { + expect(assetMimeTypeFromFileName("clip.mov")).toBe("video/quicktime") + expect(assetMimeTypeFromFileName("board.alkalye-tldraw")).toBe( + TLDRAW_BACKUP_MIME_TYPE, + ) + expect(assetExtensionFromMimeType(TLDRAW_BACKUP_MIME_TYPE)).toBe( + ".alkalye-tldraw", + ) + }) +}) diff --git a/src/app/features/assets/lib/asset-transfer.ts b/src/app/features/assets/lib/asset-transfer.ts new file mode 100644 index 00000000..2d00c970 --- /dev/null +++ b/src/app/features/assets/lib/asset-transfer.ts @@ -0,0 +1,211 @@ +import { co, type Group } from "jazz-tools" +import { createImage } from "jazz-tools/media" +import { Asset, ImageAsset, VideoAsset } from "./schema" +import { + TLDRAW_BACKUP_EXTENSION, + TLDRAW_BACKUP_MIME_TYPE, + TldrawBackupContentError, + TldrawBackupSizeError, + createTldrawAssetFromRevision, + createTldrawBackupBundle, + createTldrawRevisionFromBackup, +} from "./tldraw" + +export { + TLDRAW_BACKUP_EXTENSION, + TLDRAW_BACKUP_MIME_TYPE, + AssetSerializationError, + classifyAssetFile, + isAssetFileName, + assetMimeTypeFromFileName, + assetExtensionFromMimeType, + serializeAsset, + createAssetFromFile, + updateAssetFromFile, +} +export type { AssetFileKind } + +type AssetFileKind = "image" | "video" | "tldraw" + +class AssetSerializationError extends Error {} + +interface AssetFileInput { + blob: Blob + fileName: string + name?: string + createdAt?: Date +} + +function classifyAssetFile(file: { + name: string + type: string +}): AssetFileKind | null { + let name = file.name.toLowerCase() + if ( + file.type === TLDRAW_BACKUP_MIME_TYPE || + name.endsWith(TLDRAW_BACKUP_EXTENSION) + ) { + return "tldraw" + } + if (file.type.startsWith("image/") || isImageFileName(name)) return "image" + if (file.type.startsWith("video/") || isVideoFileName(name)) return "video" + return null +} + +function isAssetFileName(name: string) { + return classifyAssetFile({ name, type: "" }) !== null +} + +function assetMimeTypeFromFileName(name: string) { + let extension = name.toLowerCase().split(".").pop() ?? "" + return mimeTypes[extension] ?? "application/octet-stream" +} + +function assetExtensionFromMimeType(mimeType: string) { + return extensionsByMimeType[mimeType] +} + +async function serializeAsset( + asset: co.loaded, +): Promise { + switch (asset.type) { + case "image": { + let loaded = await asset.$jazz.ensureLoaded({ + resolve: { image: { original: true } }, + }) + return loaded.image.original.toBlob() + } + case "video": { + let loaded = await asset.$jazz.ensureLoaded({ resolve: { video: true } }) + return loaded.video.toBlob() + } + case "tldraw": { + let loaded = await asset.$jazz.ensureLoaded({ + resolve: { revision: true }, + }) + try { + return await createTldrawBackupBundle(loaded.revision) + } catch (error) { + if ( + !(error instanceof TldrawBackupSizeError) && + !(error instanceof TldrawBackupContentError) + ) + throw error + throw new AssetSerializationError( + `Could not export asset "${asset.name}": ${error.message}`, + ) + } + } + default: + return unsupportedAsset(asset) + } +} + +async function createAssetFromFile(input: AssetFileInput, owner: Group) { + let kind = classifyAssetFile({ name: input.fileName, type: input.blob.type }) + if (!kind) throw new Error(`Unsupported asset file: ${input.fileName}`) + + let name = input.name ?? removeExtension(input.fileName) + let createdAt = input.createdAt ?? new Date() + switch (kind) { + case "image": { + let image = await createImage(input.blob, { owner, maxSize: 2048 }) + return ImageAsset.create({ type: "image", name, image, createdAt }, owner) + } + case "video": { + let video = await co.fileStream().createFromBlob(input.blob, { owner }) + let mimeType = + input.blob.type || assetMimeTypeFromFileName(input.fileName) + return VideoAsset.create( + { type: "video", name, video, mimeType, createdAt }, + owner, + ) + } + case "tldraw": { + let revision = await createTldrawRevisionFromBackup(input.blob, owner) + return createTldrawAssetFromRevision(name, revision, owner, createdAt) + } + default: + return unsupportedAssetKind(kind) + } +} + +async function updateAssetFromFile( + asset: co.loaded, + input: AssetFileInput, + owner: Group, +) { + let kind = classifyAssetFile({ name: input.fileName, type: input.blob.type }) + if (kind !== asset.type) throw new Error("Asset file type changed") + let name = input.name ?? removeExtension(input.fileName) + + switch (asset.type) { + case "image": { + let image = await createImage(input.blob, { owner, maxSize: 2048 }) + asset.$jazz.applyDiff({ name, image }) + return + } + case "video": { + let video = await co.fileStream().createFromBlob(input.blob, { owner }) + let mimeType = + input.blob.type || assetMimeTypeFromFileName(input.fileName) + asset.$jazz.applyDiff({ name, video, mimeType }) + return + } + case "tldraw": { + let revision = await createTldrawRevisionFromBackup(input.blob, owner) + asset.$jazz.applyDiff({ name, revision }) + return + } + default: + return unsupportedAsset(asset) + } +} + +function unsupportedAsset(asset: never): never { + throw new Error(`Unsupported asset: ${asset}`) +} + +function unsupportedAssetKind(kind: never): never { + throw new Error(`Unsupported asset kind: ${kind}`) +} + +function isImageFileName(name: string) { + return imageExtensions.some(extension => name.endsWith(`.${extension}`)) +} + +function isVideoFileName(name: string) { + return videoExtensions.some(extension => name.endsWith(`.${extension}`)) +} + +function removeExtension(name: string) { + return name.replace(/\.[^.]+$/, "") +} + +let imageExtensions = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] +let videoExtensions = ["mp4", "webm", "mov"] +let mimeTypes: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + bmp: "image/bmp", + mp4: "video/mp4", + webm: "video/webm", + mov: "video/quicktime", + "alkalye-tldraw": TLDRAW_BACKUP_MIME_TYPE, +} +let extensionsByMimeType: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/svg+xml": ".svg", + "image/bmp": ".bmp", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/quicktime": ".mov", + [TLDRAW_BACKUP_MIME_TYPE]: TLDRAW_BACKUP_EXTENSION, +} diff --git a/src/app/features/assets/lib/asset-view-models.ts b/src/app/features/assets/lib/asset-view-models.ts new file mode 100644 index 00000000..705656b5 --- /dev/null +++ b/src/app/features/assets/lib/asset-view-models.ts @@ -0,0 +1,69 @@ +import type { co } from "jazz-tools" +import { Asset } from "./schema" + +export { toEditorAsset, toSidebarAsset } +export type { EditorAsset, SidebarAsset } + +interface EditorAsset { + id: string + name: string + type: "image" | "video" | "tldraw" + previewId?: string + video?: { $isLoaded?: boolean; toBlob?: () => Blob | undefined } + muteAudio?: boolean +} + +interface SidebarAsset { + id: string + name: string + type: "image" | "video" | "tldraw" + imageId?: string + lightPreviewId?: string + darkPreviewId?: string + tldrawRevisionId?: string + getVideoBlob?: () => Blob | undefined + muteAudio?: boolean +} + +function toEditorAsset( + asset: co.loaded, + theme: "light" | "dark", +): EditorAsset { + let revision = + asset.type === "tldraw" && asset.revision?.$isLoaded + ? asset.revision + : undefined + return { + id: asset.$jazz.id, + name: asset.name, + type: asset.type, + previewId: + asset.type === "image" + ? asset.image?.$jazz.id + : theme === "dark" + ? revision?.darkPreview?.$jazz.id + : revision?.lightPreview?.$jazz.id, + video: asset.type === "video" ? asset.video : undefined, + muteAudio: asset.type === "video" ? asset.muteAudio : undefined, + } +} + +function toSidebarAsset(asset: co.loaded): SidebarAsset { + let video = + asset.type === "video" && asset.video?.$isLoaded ? asset.video : null + let revision = + asset.type === "tldraw" && asset.revision?.$isLoaded + ? asset.revision + : undefined + return { + id: asset.$jazz.id, + name: asset.name, + type: asset.type, + imageId: asset.type === "image" ? asset.image?.$jazz.id : undefined, + lightPreviewId: revision?.lightPreview?.$jazz.id, + darkPreviewId: revision?.darkPreview?.$jazz.id, + tldrawRevisionId: revision?.$jazz.id, + getVideoBlob: video ? () => video.toBlob() : undefined, + muteAudio: asset.type === "video" ? asset.muteAudio : undefined, + } +} diff --git a/src/app/features/assets/lib/image-decorations.ts b/src/app/features/assets/lib/image-decorations.ts index 330c6565..f700275a 100644 --- a/src/app/features/assets/lib/image-decorations.ts +++ b/src/app/features/assets/lib/image-decorations.ts @@ -12,7 +12,7 @@ import { syntaxTree } from "@codemirror/language" export { createImageDecorations } export type { ImageResolver } -type ResolvedAsset = { url: string; type: "image" | "video" } +type ResolvedAsset = { url: string; type: "image" | "video" | "tldraw" } type ImageResolver = (assetId: string) => ResolvedAsset | undefined function createImageDecorations( @@ -99,13 +99,13 @@ function createImageDecorations( class ImageWidget extends WidgetType { text: string url: string - assetType: "image" | "video" + assetType: "image" | "video" | "tldraw" onPreview: (url: string, alt: string) => void constructor( text: string, url: string, - assetType: "image" | "video", + assetType: "image" | "video" | "tldraw", onPreview: (url: string, alt: string) => void, ) { super() @@ -133,6 +133,8 @@ class ImageWidget extends WidgetType { if (this.assetType === "video") { // Film icon from lucide icon.innerHTML = `` + } else if (this.assetType === "tldraw") { + icon.innerHTML = `` } else { // Image icon from lucide icon.innerHTML = `` @@ -173,7 +175,7 @@ interface ImageMatch { to: number alt: string url: string - assetType: "image" | "video" + assetType: "image" | "video" | "tldraw" } function parseImages(view: EditorView, resolver: ImageResolver): ImageMatch[] { @@ -196,7 +198,7 @@ function parseImages(view: EditorView, resolver: ImageResolver): ImageMatch[] { // Resolve asset: URLs let url = rawUrl - let assetType: "image" | "video" = "image" + let assetType: "image" | "video" | "tldraw" = "image" if (rawUrl.startsWith("asset:")) { let assetId = rawUrl.slice(6) let resolved = resolver(assetId) diff --git a/src/app/features/assets/lib/schema.ts b/src/app/features/assets/lib/schema.ts index 20f55493..19c998d8 100644 --- a/src/app/features/assets/lib/schema.ts +++ b/src/app/features/assets/lib/schema.ts @@ -1,6 +1,6 @@ import { co, z } from "jazz-tools" -export { ImageAsset, VideoAsset, Asset } +export { ImageAsset, VideoAsset, TldrawRevision, TldrawAsset, Asset } let ImageAsset = co.map({ type: z.literal("image"), @@ -18,4 +18,19 @@ let VideoAsset = co.map({ createdAt: z.date(), }) -let Asset = co.discriminatedUnion("type", [ImageAsset, VideoAsset]) +let TldrawRevision = co.map({ + snapshot: co.fileStream(), + lightPreview: co.image(), + darkPreview: co.image(), + createdAt: z.date(), +}) + +let TldrawAsset = co.map({ + version: z.literal(1).optional(), + type: z.literal("tldraw"), + name: z.string(), + revision: TldrawRevision, + createdAt: z.date(), +}) + +let Asset = co.discriminatedUnion("type", [ImageAsset, VideoAsset, TldrawAsset]) diff --git a/src/app/features/assets/lib/tldraw-file.test.ts b/src/app/features/assets/lib/tldraw-file.test.ts new file mode 100644 index 00000000..f6da3c80 --- /dev/null +++ b/src/app/features/assets/lib/tldraw-file.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "vitest" +import { AssetRecordType, createShapeId, createTLStore } from "tldraw" +import { + createTldrawStore, + TldrawFileError, + validateTldrawFile, +} from "./tldraw-file" + +describe("tldraw files", () => { + test("accepts a single empty page", () => { + let json = createFile() + + expect(() => validateTldrawFile(json)).not.toThrow() + expect(createTldrawStore(json).query.ids("page").get().size).toBe(1) + }) + + test("rejects multiple pages", () => { + let json = createFile([ + createPage("page:page", "a1"), + createPage("page:second", "a2"), + ]) + + expectErrorCode(json, "multiple-pages") + }) + + test("rejects embedded media", () => { + let image = AssetRecordType.create({ + id: AssetRecordType.createId("test"), + type: "image", + props: { + name: "test.png", + src: "data:image/png;base64,", + w: 1, + h: 1, + mimeType: "image/png", + isAnimated: false, + }, + }) + + let imageShape = { + id: createShapeId("image"), + typeName: "shape" as const, + type: "image", + x: 0, + y: 0, + rotation: 0, + index: "a1", + parentId: "page:page", + isLocked: false, + opacity: 1, + props: { + w: 1, + h: 1, + playing: false, + url: "", + assetId: image.id, + crop: null, + flipX: false, + flipY: false, + altText: "", + }, + meta: {}, + } + + expectErrorCode( + createFile([createPage("page:page", "a1"), image, imageShape]), + "embedded-media", + ) + }) + + test.each([ + ["bookmark", { w: 300, h: 320, url: "", assetId: null }], + ["embed", { w: 300, h: 300, url: "" }], + [ + "video", + { + w: 100, + h: 100, + assetId: null, + autoplay: true, + url: "", + altText: "", + time: 0, + playing: true, + }, + ], + ])("rejects %s shapes", (type, props) => { + expectErrorCode( + createFile([createPage("page:page", "a1"), createShape(type, props)]), + "embedded-media", + ) + }) + + test("rejects invalid JSON", () => { + expectErrorCode("not json", "invalid") + }) +}) + +function createFile(records: unknown[] = [createPage("page:page", "a1")]) { + return JSON.stringify({ + tldrawFileFormatVersion: 1, + schema: createTLStore().schema.serialize(), + records: [ + ...records, + { + gridSize: 10, + name: "", + meta: {}, + id: "document:document", + typeName: "document", + }, + ], + }) +} + +function createPage(id: `page:${string}`, index: string) { + return { meta: {}, id, name: "Page", index, typeName: "page" as const } +} + +function createShape(type: string, props: object) { + return { + id: createShapeId(type), + typeName: "shape" as const, + type, + x: 0, + y: 0, + rotation: 0, + index: "a1", + parentId: "page:page", + isLocked: false, + opacity: 1, + props, + meta: {}, + } +} + +function expectErrorCode(json: string, code: TldrawFileError["code"]) { + try { + validateTldrawFile(json) + expect.fail("Expected validation to fail") + } catch (error) { + expect(error).toBeInstanceOf(TldrawFileError) + if (error instanceof TldrawFileError) expect(error.code).toBe(code) + } +} diff --git a/src/app/features/assets/lib/tldraw-file.ts b/src/app/features/assets/lib/tldraw-file.ts new file mode 100644 index 00000000..cf7fcd62 --- /dev/null +++ b/src/app/features/assets/lib/tldraw-file.ts @@ -0,0 +1,44 @@ +import { createTLStore, parseTldrawJsonFile, type TLStore } from "tldraw" + +export { createTldrawStore, validateTldrawFile, TldrawFileError } + +type TldrawFileErrorCode = "invalid" | "multiple-pages" | "embedded-media" + +class TldrawFileError extends Error { + readonly code: TldrawFileErrorCode + + constructor(code: TldrawFileErrorCode) { + super(code) + this.name = "TldrawFileError" + this.code = code + } +} + +let unsupportedShapeTypes = new Set(["bookmark", "embed", "image", "video"]) + +function validateTldrawFile(json: string) { + createTldrawStore(json) +} + +function createTldrawStore(initialJson?: string): TLStore { + let emptyStore = createTLStore() + if (!initialJson) return emptyStore + + let result = parseTldrawJsonFile({ + json: initialJson, + schema: emptyStore.schema, + }) + if (!result.ok) throw new TldrawFileError("invalid") + if (result.value.query.ids("page").get().size !== 1) { + throw new TldrawFileError("multiple-pages") + } + for (let record of result.value.allRecords()) { + if (record.typeName === "asset") { + throw new TldrawFileError("embedded-media") + } + if (record.typeName === "shape" && unsupportedShapeTypes.has(record.type)) { + throw new TldrawFileError("embedded-media") + } + } + return result.value +} diff --git a/src/app/features/assets/lib/tldraw-static-assets.test.ts b/src/app/features/assets/lib/tldraw-static-assets.test.ts new file mode 100644 index 00000000..fbeab01e --- /dev/null +++ b/src/app/features/assets/lib/tldraw-static-assets.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "vitest" +import { iconTypes } from "tldraw" +import { localTldrawAssetUrls } from "./tldraw-static-assets" + +describe("local tldraw assets", () => { + test("maps every icon and translation to an offline URL", () => { + expect(Object.keys(localTldrawAssetUrls.icons ?? {})).toHaveLength( + iconTypes.length, + ) + for (let icon of iconTypes) { + expect(localTldrawAssetUrls.icons?.[icon]).toBe( + `/tldraw/icons/0_merged.svg#${icon}`, + ) + } + expect(localTldrawAssetUrls.translations).toEqual({ + en: "/tldraw/translations/en.json", + de: "/tldraw/translations/de.json", + }) + }) + + test("bundles every mapped icon in the offline sprite", () => { + let sprite = readFileSync("public/tldraw/icons/0_merged.svg", "utf8") + + for (let icon of iconTypes) { + expect(sprite).toContain(`id="${icon}"`) + } + }) +}) diff --git a/src/app/features/assets/lib/tldraw-static-assets.ts b/src/app/features/assets/lib/tldraw-static-assets.ts new file mode 100644 index 00000000..c3b298c7 --- /dev/null +++ b/src/app/features/assets/lib/tldraw-static-assets.ts @@ -0,0 +1,33 @@ +import geistFontUrl from "@fontsource-variable/geist/files/geist-latin-wght-normal.woff2?url" +import geistMonoFontUrl from "@fontsource-variable/geist-mono/files/geist-mono-latin-wght-normal.woff2?url" +import { iconTypes, type TLUiAssetUrlOverrides } from "tldraw" + +export { localTldrawAssetUrls } + +let localTldrawAssetUrls = { + fonts: { + tldraw_mono: geistMonoFontUrl, + tldraw_mono_italic: geistMonoFontUrl, + tldraw_mono_bold: geistMonoFontUrl, + tldraw_mono_italic_bold: geistMonoFontUrl, + tldraw_serif: geistFontUrl, + tldraw_serif_italic: geistFontUrl, + tldraw_serif_bold: geistFontUrl, + tldraw_serif_italic_bold: geistFontUrl, + tldraw_sans: geistFontUrl, + tldraw_sans_italic: geistFontUrl, + tldraw_sans_bold: geistFontUrl, + tldraw_sans_italic_bold: geistFontUrl, + tldraw_draw: geistFontUrl, + tldraw_draw_italic: geistFontUrl, + tldraw_draw_bold: geistFontUrl, + tldraw_draw_italic_bold: geistFontUrl, + }, + icons: Object.fromEntries( + iconTypes.map(icon => [icon, `/tldraw/icons/0_merged.svg#${icon}`]), + ), + translations: { + en: "/tldraw/translations/en.json", + de: "/tldraw/translations/de.json", + }, +} satisfies TLUiAssetUrlOverrides diff --git a/src/app/features/assets/lib/tldraw.test.ts b/src/app/features/assets/lib/tldraw.test.ts new file mode 100644 index 00000000..bf35f15a --- /dev/null +++ b/src/app/features/assets/lib/tldraw.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "vitest" +import { createJazzTestAccount, setupJazzTestSync } from "jazz-tools/testing" +import { createTLStore } from "tldraw" +import { + TldrawBackupContentError, + createTldrawBackupBundleFromSave, + decodeTldrawBackupBundle, + updateTldrawAsset, +} from "./tldraw" +import { Document, UserAccount } from "@/schema" +import { createPersonalDocument } from "@/app/features/documents/lib/documents" + +describe("tldraw backups", () => { + test("decodes a bounded local backup", async () => { + let save = await decodeTldrawBackupBundle(createBackup()) + + expect(save.json).toBe(createSnapshot()) + expect(save.lightPreview.type).toBe("image/png") + expect(save.darkPreview.size).toBeGreaterThan(0) + }) + + test("round-trips snapshots and previews", async () => { + let lightPreview = new Blob([Uint8Array.from([1, 2, 3])], { + type: "image/png", + }) + let darkPreview = new Blob([Uint8Array.from([4, 5, 6])], { + type: "image/png", + }) + let backup = await createTldrawBackupBundleFromSave({ + json: createSnapshot(), + lightPreview, + darkPreview, + }) + + let save = await decodeBlobBackup(backup) + + expect(save.json).toBe(createSnapshot()) + expect(await blobBytes(save.lightPreview)).toEqual([1, 2, 3]) + expect(await blobBytes(save.darkPreview)).toEqual([4, 5, 6]) + }) + + test("refuses to create a backup that cannot be imported", async () => { + let preview = new Blob([Uint8Array.from([1])], { type: "image/png" }) + let snapshot = createSnapshot() + let oversizedSnapshot = + snapshot + " ".repeat(8 * 1024 * 1024 + 1 - snapshot.length) + + await expect( + createTldrawBackupBundleFromSave({ + json: oversizedSnapshot, + lightPreview: preview, + darkPreview: preview, + }), + ).rejects.toThrow("Whiteboard snapshot is too large") + }) + + test("rejects external preview URLs", async () => { + let backup = createBackup({ lightPreview: "https://example.com/image.png" }) + + await expect(decodeTldrawBackupBundle(backup)).rejects.toThrow( + "PNG data URL", + ) + }) + + test("rejects invalid snapshots", async () => { + let backup = createBackup({ snapshot: "invalid" }) + + await expect(decodeTldrawBackupBundle(backup)).rejects.toBeInstanceOf(Error) + }) + + test("classifies invalid stored snapshots as deterministic export errors", async () => { + let preview = new Blob([Uint8Array.from([1])], { type: "image/png" }) + + await expect( + createTldrawBackupBundleFromSave({ + json: "invalid", + lightPreview: preview, + darkPreview: preview, + }), + ).rejects.toBeInstanceOf(TldrawBackupContentError) + }) + + test("rejects an update after its asset disappears", async () => { + await setupJazzTestSync() + let account = await createJazzTestAccount({ + isCurrentActiveAccount: true, + AccountSchema: UserAccount, + }) + let doc = await createPersonalDocument(account, "Whiteboard") + let loaded = await Document.load(doc.$jazz.id, { + resolve: { content: true, assets: true }, + }) + if (!loaded.$isLoaded) throw new Error("Document did not load") + let preview = new Blob([Uint8Array.from([1])], { type: "image/png" }) + + await expect( + updateTldrawAsset(loaded, "missing", { + json: createSnapshot(), + lightPreview: preview, + darkPreview: preview, + }), + ).rejects.toThrow("Whiteboard asset is no longer available") + }) +}) + +function createBackup(overrides: Partial = {}) { + let png = "data:image/png;base64,iVBORw0KGgo=" + let backup: Backup = { + format: "alkalye-tldraw-v1", + snapshot: createSnapshot(), + lightPreview: png, + darkPreview: png, + ...overrides, + } + let text = JSON.stringify(backup) + return { size: text.length, text: async () => text } +} + +interface Backup { + format: "alkalye-tldraw-v1" + snapshot: string + lightPreview: string + darkPreview: string +} + +function createSnapshot() { + return JSON.stringify({ + tldrawFileFormatVersion: 1, + schema: createTLStore().schema.serialize(), + records: [ + { + meta: {}, + id: "page:page", + name: "Page", + index: "a1", + typeName: "page", + }, + { + gridSize: 10, + name: "", + meta: {}, + id: "document:document", + typeName: "document", + }, + ], + }) +} + +async function blobBytes(blob: Blob) { + return [...new Uint8Array(await readBlobAsArrayBuffer(blob))] +} + +async function decodeBlobBackup(blob: Blob) { + let text = await readBlobAsText(blob) + return decodeTldrawBackupBundle({ size: blob.size, text: async () => text }) +} + +function readBlobAsArrayBuffer(blob: Blob) { + return new Promise((resolve, reject) => { + let reader = new FileReader() + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) resolve(reader.result) + else reject(new Error("Could not read blob")) + } + reader.onerror = () => reject(reader.error) + reader.readAsArrayBuffer(blob) + }) +} + +function readBlobAsText(blob: Blob) { + return new Promise((resolve, reject) => { + let reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === "string") resolve(reader.result) + else reject(new Error("Could not read blob")) + } + reader.onerror = () => reject(reader.error) + reader.readAsText(blob) + }) +} diff --git a/src/app/features/assets/lib/tldraw.ts b/src/app/features/assets/lib/tldraw.ts new file mode 100644 index 00000000..b5d58616 --- /dev/null +++ b/src/app/features/assets/lib/tldraw.ts @@ -0,0 +1,313 @@ +import { co, type Group } from "jazz-tools" +import { createImage } from "jazz-tools/media" +import { z } from "zod" +import { Asset, TldrawAsset, TldrawRevision } from "./schema" +import { Document } from "@/app/features/documents/lib/schema" +import { syncDocumentMetadata } from "@/app/features/documents/lib/metadata" + +export { + TLDRAW_BACKUP_EXTENSION, + TLDRAW_BACKUP_MIME_TYPE, + TldrawBackupContentError, + TldrawBackupSizeError, + createTldrawAsset, + createTldrawAssetFromRevision, + updateTldrawAsset, + tldrawNameFromFile, + copyTldrawRevision, + createTldrawBackupBundle, + createTldrawBackupBundleFromSave, + createTldrawRevisionFromBackup, + decodeTldrawBackupBundle, +} +export type { TldrawSave } + +let TLDRAW_BACKUP_EXTENSION = ".alkalye-tldraw" +let TLDRAW_BACKUP_MIME_TYPE = "application/vnd.alkalye.tldraw+json" +let MAX_TLDRAW_BACKUP_BYTES = 32 * 1024 * 1024 +let MAX_TLDRAW_SNAPSHOT_LENGTH = 8 * 1024 * 1024 +let MAX_TLDRAW_PREVIEW_BYTES = 8 * 1024 * 1024 +let PNG_DATA_URL_PREFIX = "data:image/png;base64," +let MAX_TLDRAW_PREVIEW_DATA_URL_LENGTH = + PNG_DATA_URL_PREFIX.length + 4 * Math.ceil(MAX_TLDRAW_PREVIEW_BYTES / 3) + +class TldrawBackupSizeError extends Error {} +class TldrawBackupContentError extends Error {} + +type LoadedDocument = co.loaded< + typeof Document, + { + content: true + assets: true + } +> + +interface TldrawSave { + json: string + lightPreview: Blob + darkPreview: Blob +} + +let backupBundleSchema = z.object({ + format: z.literal("alkalye-tldraw-v1"), + snapshot: z.string().max(MAX_TLDRAW_SNAPSHOT_LENGTH), + lightPreview: z.string().max(MAX_TLDRAW_PREVIEW_DATA_URL_LENGTH), + darkPreview: z.string().max(MAX_TLDRAW_PREVIEW_DATA_URL_LENGTH), +}) + +async function createTldrawAsset( + doc: LoadedDocument, + name: string, + save: TldrawSave, +) { + let revision = await createRevision(doc.$jazz.owner, save) + let asset = createTldrawAssetFromRevision(name, revision, doc.$jazz.owner) + + let assets = doc.assets + if (!assets) { + assets = co.list(Asset).create([], doc.$jazz.owner) + doc.$jazz.set("assets", assets) + } + assets.$jazz.push(asset) + markDocumentUpdated(doc) + return asset +} + +function createTldrawAssetFromRevision( + name: string, + revision: co.loaded, + owner: Group, + createdAt = new Date(), +) { + return TldrawAsset.create( + { + version: 1, + type: "tldraw", + name, + revision, + createdAt, + }, + owner, + ) +} + +async function updateTldrawAsset( + doc: LoadedDocument, + assetId: string, + save: TldrawSave, +) { + let asset = doc.assets?.find(candidate => candidate?.$jazz.id === assetId) + if (!asset?.$isLoaded || asset.type !== "tldraw") { + throw new Error("Whiteboard asset is no longer available") + } + + let revision = await createRevision(doc.$jazz.owner, save) + asset.$jazz.set("revision", revision) + markDocumentUpdated(doc) +} + +function tldrawNameFromFile(file: File) { + return file.name.replace(/\.tldr$/i, "") || "Whiteboard" +} + +async function copyTldrawRevision( + revision: co.loaded, + owner: Group, +) { + let { snapshotBlob, lightBlob, darkBlob } = await loadRevisionBlobs(revision) + + let [snapshot, lightPreview, darkPreview] = await Promise.all([ + co.fileStream().createFromBlob(snapshotBlob, { owner }), + createImage(lightBlob, { owner, maxSize: 2048 }), + createImage(darkBlob, { owner, maxSize: 2048 }), + ]) + return TldrawRevision.create( + { snapshot, lightPreview, darkPreview, createdAt: new Date() }, + owner, + ) +} + +async function createTldrawBackupBundle( + revision: co.loaded, +) { + let { snapshotBlob, lightBlob, darkBlob } = await loadRevisionBlobs(revision) + return createTldrawBackupBundleFromSave({ + json: await blobToText(snapshotBlob), + lightPreview: lightBlob, + darkPreview: darkBlob, + }) +} + +async function createTldrawBackupBundleFromSave(save: TldrawSave) { + assertSnapshotSize(save.json.length) + assertPreviewSize(save.lightPreview.size) + assertPreviewSize(save.darkPreview.size) + let [lightPreview, darkPreview] = await Promise.all([ + blobToDataUrl(save.lightPreview), + blobToDataUrl(save.darkPreview), + ]) + let bundle = { + format: "alkalye-tldraw-v1" as const, + snapshot: save.json, + lightPreview, + darkPreview, + } + await decodeTldrawBackupObject(bundle, true) + + let blob = new Blob([JSON.stringify(bundle)], { + type: TLDRAW_BACKUP_MIME_TYPE, + }) + assertBackupSize(blob.size) + return blob +} + +async function createTldrawRevisionFromBackup(blob: Blob, owner: Group) { + return createRevision(owner, await decodeTldrawBackupBundle(blob)) +} + +async function decodeTldrawBackupBundle(blob: { + size: number + text: () => Promise +}): Promise { + assertBackupSize(blob.size) + return decodeTldrawBackupObject(JSON.parse(await blob.text())) +} + +async function decodeTldrawBackupObject( + value: unknown, + wrapContentErrors = false, +): Promise { + let parsed = parseBackupBundle(value, wrapContentErrors) + let { validateTldrawFile } = await import("./tldraw-file") + try { + validateTldrawFile(parsed.snapshot) + return { + json: parsed.snapshot, + lightPreview: decodePngDataUrl(parsed.lightPreview), + darkPreview: decodePngDataUrl(parsed.darkPreview), + } + } catch (error) { + throwContentError(error, wrapContentErrors) + } +} + +function parseBackupBundle(value: unknown, wrapContentErrors: boolean) { + try { + return backupBundleSchema.parse(value) + } catch (error) { + throwContentError(error, wrapContentErrors) + } +} + +function throwContentError(error: unknown, wrap: boolean): never { + if (!wrap || error instanceof TldrawBackupSizeError) throw error + throw new TldrawBackupContentError( + "Whiteboard data is invalid or unsupported", + ) +} + +function assertBackupSize(size: number) { + if (size > MAX_TLDRAW_BACKUP_BYTES) { + throw new TldrawBackupSizeError("Whiteboard backup is too large") + } +} + +function assertSnapshotSize(length: number) { + if (length > MAX_TLDRAW_SNAPSHOT_LENGTH) { + throw new TldrawBackupSizeError("Whiteboard snapshot is too large") + } +} + +function assertPreviewSize(size: number) { + if (size > MAX_TLDRAW_PREVIEW_BYTES) { + throw new TldrawBackupSizeError("Whiteboard preview is too large") + } +} + +async function loadRevisionBlobs(revision: co.loaded) { + let loaded = await revision.$jazz.ensureLoaded({ + resolve: { + snapshot: true, + lightPreview: { original: true }, + darkPreview: { original: true }, + }, + }) + let snapshotBlob = loaded.snapshot.toBlob() + let lightOriginal = loaded.lightPreview.original + let darkOriginal = loaded.darkPreview.original + if (!snapshotBlob || !lightOriginal?.$isLoaded || !darkOriginal?.$isLoaded) { + throw new Error("Whiteboard revision is incomplete") + } + let lightBlob = lightOriginal.toBlob() + let darkBlob = darkOriginal.toBlob() + if (!lightBlob || !darkBlob) + throw new Error("Whiteboard previews are incomplete") + return { snapshotBlob, lightBlob, darkBlob } +} + +function decodePngDataUrl(dataUrl: string) { + if (!dataUrl.startsWith(PNG_DATA_URL_PREFIX)) { + throw new Error("Whiteboard preview must be a PNG data URL") + } + let encoded = dataUrl.slice(PNG_DATA_URL_PREFIX.length) + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + throw new Error("Whiteboard preview is not valid base64") + } + let binary = atob(encoded) + assertPreviewSize(binary.length) + let bytes = Uint8Array.from(binary, character => character.charCodeAt(0)) + return new Blob([bytes], { type: "image/png" }) +} + +function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + let reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === "string") resolve(reader.result) + else reject(new Error("Could not encode whiteboard preview")) + } + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(blob) + }) +} + +function blobToText(blob: Blob): Promise { + if (typeof blob.text === "function") return blob.text() + return new Promise((resolve, reject) => { + let reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === "string") resolve(reader.result) + else reject(new Error("Could not read whiteboard snapshot")) + } + reader.onerror = () => reject(reader.error) + reader.readAsText(blob) + }) +} + +function markDocumentUpdated(doc: LoadedDocument) { + doc.$jazz.set("updatedAt", new Date()) + syncDocumentMetadata(doc, { contentChanged: false }) +} + +async function createRevision(owner: Group, save: TldrawSave) { + let snapshot = await co + .fileStream() + .createFromBlob( + new Blob([save.json], { type: "application/vnd.tldraw+json" }), + { owner }, + ) + let [lightPreview, darkPreview] = await Promise.all([ + createImage(save.lightPreview, { owner, maxSize: 2048 }), + createImage(save.darkPreview, { owner, maxSize: 2048 }), + ]) + + return TldrawRevision.create( + { + snapshot, + lightPreview, + darkPreview, + createdAt: new Date(), + }, + owner, + ) +} diff --git a/src/app/features/assets/widgets/sidebar-assets.tsx b/src/app/features/assets/widgets/sidebar-assets.tsx index 646d6257..7c4ed7bb 100644 --- a/src/app/features/assets/widgets/sidebar-assets.tsx +++ b/src/app/features/assets/widgets/sidebar-assets.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useEffect } from "react" import { useForm } from "@tanstack/react-form" import { z } from "zod" import { Image as JazzImage } from "jazz-tools/react" +import { toast } from "sonner" import { SidebarGroupLabel, SidebarGroupContent, @@ -46,21 +47,15 @@ import { Film, VolumeX, Volume2, + PenTool, } from "lucide-react" import { useIntl, T } from "@/shared/intl/setup" +import { useResolvedTheme } from "@/app/components/appearance" +import type { SidebarAsset } from "../lib/asset-view-models" export { SidebarAssets } export type { SidebarAsset } -interface SidebarAsset { - id: string - name: string - type: "image" | "video" - imageId?: string - getVideoBlob?: () => Blob | undefined - muteAudio?: boolean -} - interface VideoUploadState { fileName: string phase: UploadPhase @@ -84,6 +79,9 @@ interface SidebarAssetsProps { onDownload?: (assetId: string, name: string) => void onInsert?: (assetId: string, name: string) => void onToggleMute?: (assetId: string) => void + onImportTldraw?: (file: File) => void + onCreateTldraw?: () => void + onEditTldraw?: (assetId: string) => void isAssetUsed?: (assetId: string) => boolean canUploadVideo?: boolean } @@ -98,10 +96,14 @@ function SidebarAssets({ onDownload, onInsert, onToggleMute, + onCreateTldraw, + onImportTldraw, + onEditTldraw, isAssetUsed, canUploadVideo, }: SidebarAssetsProps) { let t = useIntl() + let colorScheme = useResolvedTheme() let { isMobile } = useSidebar() let fileInputRef = useRef(null) let [renameOpen, setRenameOpen] = useState(false) @@ -121,9 +123,17 @@ function SidebarAssets({ } async function handleFiles(files: File[]) { + let tldrawFiles = files.filter(file => + file.name.toLowerCase().endsWith(".tldr"), + ) let imageFiles = files.filter(f => f.type.startsWith("image/")) let videoFiles = files.filter(f => f.type.startsWith("video/")) + if (tldrawFiles[0]) onImportTldraw?.(tldrawFiles[0]) + if (tldrawFiles.length > 1) { + toast.info(t("assets.importOneWhiteboard")) + } + if (imageFiles.length > 0 && onUploadImages) { let dt = new DataTransfer() imageFiles.forEach(f => dt.items.add(f)) @@ -217,33 +227,53 @@ function SidebarAssets({ - - fileInputRef.current?.click()} - disabled={readOnly} - className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent flex size-5 items-center justify-center rounded disabled:pointer-events-none disabled:opacity-50" - > - - - } - /> - - - - + + + + + + } + /> + } + /> + + + + + + {onCreateTldraw && ( + + + {t("assets.newWhiteboard")} + + )} + fileInputRef.current?.click()}> + + {t("assets.uploadOrImport")} + + + { if (e.target.files) { handleFiles(Array.from(e.target.files)) } + e.target.value = "" }} /> @@ -269,6 +299,18 @@ function SidebarAssets({ imageId={asset.imageId} className="size-full object-cover" /> + ) : asset.type === "tldraw" && + (colorScheme === "dark" + ? asset.darkPreviewId + : asset.lightPreviewId) ? ( + ) : asset.type === "video" && asset.getVideoBlob ? ( {asset.type === "video" ? ( + ) : asset.type === "tldraw" ? ( + ) : ( )} @@ -292,6 +336,12 @@ function SidebarAssets({ side={isMobile ? "bottom" : "left"} align={isMobile ? "center" : "start"} > + {asset.type === "tldraw" && onEditTldraw && ( + onEditTldraw(asset.id)}> + + {t("assets.editWhiteboard")} + + )} {onInsert && ( onInsert(asset.id, asset.name)} diff --git a/src/app/features/assets/widgets/tldraw-canvas.tsx b/src/app/features/assets/widgets/tldraw-canvas.tsx new file mode 100644 index 00000000..2940dc51 --- /dev/null +++ b/src/app/features/assets/widgets/tldraw-canvas.tsx @@ -0,0 +1,140 @@ +import { useEffect, useRef, useState } from "react" +import { + Tldraw, + serializeTldrawJson, + type Editor, + type TLUiOverrides, + type TldrawOptions, +} from "tldraw" +import "tldraw/tldraw.css" +import { PUBLIC_TLDRAW_LICENSE_KEY } from "astro:env/client" +import { useResolvedTheme } from "@/app/components/appearance" +import { useLocale } from "@/shared/intl/setup" +import type { TldrawSave } from "../lib/tldraw" +import { localTldrawAssetUrls } from "../lib/tldraw-static-assets" +import { createTldrawStore } from "../lib/tldraw-file" + +export { TldrawCanvas } +export type { TldrawCanvasHandle } + +interface TldrawCanvasHandle { + save: () => Promise + hasShapes: () => boolean +} + +interface TldrawCanvasProps { + initialJson?: string + onReady: (handle: TldrawCanvasHandle) => void + onDirty: () => void +} + +let components = { + MainMenu: null, + PageMenu: null, + SharePanel: null, +} + +let options: Partial = { + maxPages: 1, + onBeforePasteFromClipboard({ content }) { + if (content.type === "files") return false + if (content.type === "tldraw" && content.content.assets.length > 0) + return false + }, + experimental__onDropOnCanvas({ event }) { + return event.dataTransfer.files.length > 0 + }, +} + +let overrides: TLUiOverrides = { + tools(_editor, tools) { + let drawingTools = { ...tools } + delete drawingTools.asset + delete drawingTools.embed + return drawingTools + }, + actions(_editor, actions) { + let drawingActions = { ...actions } + delete drawingActions["insert-media"] + delete drawingActions["toggle-dark-mode"] + return drawingActions + }, +} + +function TldrawCanvas({ initialJson, onReady, onDirty }: TldrawCanvasProps) { + let [store] = useState(() => createTldrawStore(initialJson)) + let locale = useLocale() + let colorScheme = useResolvedTheme() + let editorRef = useRef(null) + let onDirtyRef = useRef(onDirty) + useEffect(() => { + onDirtyRef.current = onDirty + }, [onDirty]) + useEffect(() => { + editorRef.current?.setColorMode(colorScheme) + }, [colorScheme]) + + function handleMount(editor: Editor) { + editorRef.current = editor + editor.setColorMode(colorScheme) + editor.registerExternalAssetHandler("file", null) + editor.registerExternalAssetHandler("url", null) + editor.registerExternalContentHandler("files", null) + editor.registerExternalContentHandler("file-replace", null) + editor.registerExternalContentHandler("embed", null) + editor.registerExternalContentHandler("url", null) + let stopListening = editor.store.listen(() => onDirtyRef.current(), { + source: "user", + scope: "document", + }) + onReady({ + save: () => saveEditor(editor), + hasShapes: () => editor.getCurrentPageShapeIds().size > 0, + }) + return () => { + if (editorRef.current === editor) editorRef.current = null + stopListening() + } + } + + return ( + + ) +} + +async function saveEditor(editor: Editor): Promise { + let shapeIds = [...editor.getCurrentPageShapeIds()] + if (shapeIds.length === 0) throw new Error("Add something before saving") + + let [json, light, dark] = await Promise.all([ + serializeTldrawJson(editor), + editor.toImage(shapeIds, { + format: "png", + background: true, + darkMode: false, + pixelRatio: 2, + }), + editor.toImage(shapeIds, { + format: "png", + background: true, + darkMode: true, + pixelRatio: 2, + }), + ]) + + return { + json, + lightPreview: light.blob, + darkPreview: dark.blob, + } +} diff --git a/src/app/features/assets/widgets/tldraw-editor-dialog.tsx b/src/app/features/assets/widgets/tldraw-editor-dialog.tsx new file mode 100644 index 00000000..8fbcb855 --- /dev/null +++ b/src/app/features/assets/widgets/tldraw-editor-dialog.tsx @@ -0,0 +1,229 @@ +import { + Component, + lazy, + Suspense, + useEffect, + useRef, + useState, + type ErrorInfo, + type ReactNode, +} from "react" +import { Loader2, Save, X } from "lucide-react" +import { toast } from "sonner" +import { Button } from "@/app/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/app/components/ui/dialog" +import { ConfirmDialog } from "@/app/components/ui/confirm-dialog" +import { T, useIntl } from "@/shared/intl/setup" +import type { TldrawSave } from "../lib/tldraw" +import type { TldrawCanvasHandle } from "./tldraw-canvas" + +export { TldrawEditorDialog } + +let LazyTldrawCanvas = lazy(() => + import("./tldraw-canvas").then(module => ({ + default: module.TldrawCanvas, + })), +) + +interface TldrawEditorDialogProps { + open: boolean + name: string + initialJson?: string + mode: "create" | "edit" | "import" + onOpenChange: (open: boolean) => void + onSave: (save: TldrawSave) => Promise +} + +function TldrawEditorDialog({ + open, + name, + initialJson, + mode, + onOpenChange, + onSave, +}: TldrawEditorDialogProps) { + let t = useIntl() + let canvas = useRef(null) + let [dirty, setDirty] = useState(false) + let [confirmClose, setConfirmClose] = useState(false) + let [saving, setSaving] = useState(false) + let [ready, setReady] = useState(false) + let handleSaveRef = useRef(handleSave) + handleSaveRef.current = handleSave + + useEffect(() => { + if (!open) return + function handleKeyDown(event: KeyboardEvent) { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") + return + event.preventDefault() + void handleSaveRef.current() + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [open]) + + function requestClose() { + if (dirty) { + setConfirmClose(true) + return + } + onOpenChange(false) + } + + async function handleSave() { + if (!canvas.current || saving) return + if (!canvas.current.hasShapes()) { + toast.error(t("assets.addBeforeSaving")) + return + } + setSaving(true) + try { + let save = await canvas.current.save() + await onSave(save) + setDirty(false) + onOpenChange(false) + toast.success( + mode === "edit" + ? t("assets.whiteboardSaved") + : t("assets.whiteboardAdded"), + ) + } catch (error) { + console.error("Failed to save whiteboard:", error) + toast.error(t("assets.whiteboardSaveFailed")) + } finally { + setSaving(false) + } + } + + return ( + <> + !next && requestClose()}> + + +
+ + {name} + + + {t("assets.whiteboardEditorDescription")} + +
+
+ + +
+
+
+ + {t("assets.invalidTldraw")} +
+ } + > + + + + } + > + setDirty(true)} + onReady={handle => { + canvas.current = handle + setReady(true) + }} + /> + + + +
+
+ { + setDirty(false) + onOpenChange(false) + }} + /> + + ) +} + +interface CanvasErrorBoundaryProps { + children: ReactNode + fallback: ReactNode +} + +interface CanvasErrorBoundaryState { + failed: boolean +} + +class CanvasErrorBoundary extends Component< + CanvasErrorBoundaryProps, + CanvasErrorBoundaryState +> { + state = { failed: false } + + static getDerivedStateFromError() { + return { failed: true } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("Failed to open whiteboard:", error, info) + } + + render() { + return this.state.failed ? this.props.fallback : this.props.children + } +} diff --git a/src/app/features/assets/widgets/use-tldraw-editor.tsx b/src/app/features/assets/widgets/use-tldraw-editor.tsx new file mode 100644 index 00000000..05990071 --- /dev/null +++ b/src/app/features/assets/widgets/use-tldraw-editor.tsx @@ -0,0 +1,138 @@ +import { useState, type ReactNode } from "react" +import { toast } from "sonner" +import { TldrawRevision } from "../lib/schema" +import { tldrawNameFromFile, type TldrawSave } from "../lib/tldraw" +import { useIntl } from "@/shared/intl/setup" +import type { SidebarAsset } from "./sidebar-assets" +import { TldrawEditorDialog } from "./tldraw-editor-dialog" + +export { useTldrawEditor } + +interface CreatedTldrawAsset { + id: string + name: string +} + +interface TldrawEditorState { + assetId?: string + name: string + initialJson?: string + mode: "create" | "edit" | "import" + onCreated?: (asset: CreatedTldrawAsset) => void +} + +interface TldrawEditorOptions { + assets: SidebarAsset[] + readOnly: boolean + createAsset: (name: string, save: TldrawSave) => Promise + updateAsset: (assetId: string, save: TldrawSave) => Promise +} + +interface TldrawEditorController { + dialog: ReactNode + create: (onCreated?: (asset: CreatedTldrawAsset) => void) => void + edit: (assetId: string) => void + importFile: (file: File) => void +} + +function useTldrawEditor({ + assets, + readOnly, + createAsset, + updateAsset, +}: TldrawEditorOptions): TldrawEditorController { + let t = useIntl() + let [editor, setEditor] = useState(null) + + function create(onCreated?: (asset: CreatedTldrawAsset) => void) { + if (readOnly) return + setEditor({ + name: getNextWhiteboardName(assets, t("assets.whiteboard")), + mode: "create", + onCreated, + }) + } + + function importFile(file: File) { + if (readOnly) return + void openFile(file) + } + + async function openFile(file: File) { + let tldrawFile = await import("../lib/tldraw-file") + try { + let json = await file.text() + tldrawFile.validateTldrawFile(json) + setEditor({ + name: tldrawNameFromFile(file), + initialJson: json, + mode: "import", + }) + } catch (error) { + console.error("Failed to open tldraw file:", error) + let message = + error instanceof tldrawFile.TldrawFileError && + error.code === "multiple-pages" + ? t("assets.multiplePagesUnsupported") + : error instanceof tldrawFile.TldrawFileError && + error.code === "embedded-media" + ? t("assets.embeddedMediaUnsupported") + : t("assets.invalidTldraw") + toast.error(message) + } + } + + function edit(assetId: string) { + if (readOnly) return + void openAsset(assetId) + } + + async function openAsset(assetId: string) { + let asset = assets.find(candidate => candidate.id === assetId) + if (asset?.type !== "tldraw" || !asset.tldrawRevisionId) return + let revision = await TldrawRevision.load(asset.tldrawRevisionId, { + resolve: { snapshot: true }, + }) + let blob = revision?.$isLoaded ? revision.snapshot.toBlob() : undefined + if (!blob) { + toast.error(t("assets.whiteboardLoading")) + return + } + setEditor({ + assetId, + name: asset.name, + initialJson: await blob.text(), + mode: "edit", + }) + } + + let dialog = editor ? ( + { + if (!open) setEditor(null) + }} + onSave={async save => { + if (editor.assetId) { + await updateAsset(editor.assetId, save) + return + } + let created = await createAsset(editor.name, save) + editor.onCreated?.(created) + }} + /> + ) : null + + return { dialog, create, edit, importFile } +} + +function getNextWhiteboardName(assets: SidebarAsset[], baseName: string) { + let names = new Set(assets.map(asset => asset.name.toLocaleLowerCase())) + if (!names.has(baseName.toLocaleLowerCase())) return baseName + let suffix = 2 + while (names.has(`${baseName} ${suffix}`.toLocaleLowerCase())) suffix++ + return `${baseName} ${suffix}` +} diff --git a/src/app/features/backup/lib/engine.test.ts b/src/app/features/backup/lib/engine.test.ts index 72d247af..09bb0116 100644 --- a/src/app/features/backup/lib/engine.test.ts +++ b/src/app/features/backup/lib/engine.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect } from "vitest" -import { hashContent, prepareBackupSelection, syncFromBackup } from "./engine" +import { co } from "jazz-tools" +import { createJazzTestAccount, setupJazzTestSync } from "jazz-tools/testing" +import { Asset, TldrawAsset, TldrawRevision, UserAccount } from "@/schema" +import { createPersonalDocument } from "@/app/features/documents/lib/documents" +import { + hashContent, + prepareBackupDocs, + prepareBackupSelection, + syncFromBackup, +} from "./engine" import { selectActiveBackupDocuments } from "./subscriber-state" describe("hashContent", () => { @@ -79,6 +88,55 @@ describe("backup snapshot preparation", () => { }), ).rejects.toThrow("selection changed") }) + + it("backs up healthy documents when stored assets cannot export", async () => { + await setupJazzTestSync() + let account = await createJazzTestAccount({ + isCurrentActiveAccount: true, + AccountSchema: UserAccount, + }) + let oversized = await createPersonalDocument(account, "# Oversized") + let corrupt = await createPersonalDocument(account, "# Corrupt") + let healthy = await createPersonalDocument(account, "# Healthy") + let oversizedRevision = await createTestRevision( + account, + "x".repeat(8 * 1024 * 1024 + 1), + ) + let oversizedAsset = TldrawAsset.create( + { + type: "tldraw", + name: "Huge diagram", + revision: oversizedRevision, + createdAt: new Date(), + }, + account, + ) + oversized.$jazz.set( + "assets", + co.list(Asset).create([oversizedAsset], account), + ) + let corruptRevision = await createTestRevision(account, "invalid") + let corruptAsset = TldrawAsset.create( + { + type: "tldraw", + name: "Old diagram", + revision: corruptRevision, + createdAt: new Date(), + }, + account, + ) + corrupt.$jazz.set("assets", co.list(Asset).create([corruptAsset], account)) + + let prepared = await prepareBackupDocs( + selectActiveBackupDocuments([oversized, corrupt, healthy]), + ) + + expect(prepared.documents.map(doc => doc.id)).toEqual([healthy.$jazz.id]) + expect(prepared.errors).toEqual([ + 'Could not back up "Oversized": Could not export asset "Huge diagram": Whiteboard snapshot is too large', + 'Could not back up "Corrupt": Could not export asset "Old diagram": Whiteboard data is invalid or unsupported', + ]) + }) }) function backupDocument( @@ -96,3 +154,48 @@ function backupDocument( updatedAt: new Date(updatedAt), } } + +async function createTestRevision( + owner: co.loaded, + content: string, +) { + let snapshot = await co + .fileStream() + .createFromBlob(new ReadableBlob(content), { owner }) + let original = await co + .fileStream() + .createFromBlob(new ReadableBlob("preview", "image/png"), { owner }) + let preview = co.image().create( + { + original, + originalSize: [1, 1], + progressive: false, + }, + owner, + ) + return TldrawRevision.create( + { + snapshot, + lightPreview: preview, + darkPreview: preview, + createdAt: new Date(), + }, + owner, + ) +} + +class ReadableBlob extends Blob { + readonly content: string + + constructor(content: string, type = "application/octet-stream") { + super([content], { type }) + this.content = content + } + + async arrayBuffer() { + let bytes = new TextEncoder().encode(this.content) + let buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + return buffer + } +} diff --git a/src/app/features/backup/lib/engine.ts b/src/app/features/backup/lib/engine.ts index 5616eb11..6f8b1f6c 100644 --- a/src/app/features/backup/lib/engine.ts +++ b/src/app/features/backup/lib/engine.ts @@ -1,6 +1,13 @@ import { co, Group, Account } from "jazz-tools" -import { createImage } from "jazz-tools/media" -import { Document, Asset, ImageAsset, VideoAsset } from "@/schema" +import { Document, Asset } from "@/schema" +import { + AssetSerializationError, + assetContentResolve, + classifyAssetFile, + createAssetFromFile, + serializeAsset, + updateAssetFromFile, +} from "@/app/features/assets" import { getDocumentTitle } from "@/app/features/documents/lib/title" import { getPath, @@ -43,7 +50,9 @@ type LoadedDocument = co.loaded< typeof Document, { content: true - assets: { $each: { image: true; video: true } } + assets: { + $each: typeof assetContentResolve + } comments: { $each: true } } > @@ -54,7 +63,9 @@ type BackupDocumentRef = co.loaded let backupDocumentResolve = { content: true, - assets: { $each: { image: true, video: true } }, + assets: { + $each: assetContentResolve, + }, comments: { $each: true }, } as const @@ -70,6 +81,11 @@ interface ScannedAssetHash { hash: string } +interface PreparedBackupDocs { + documents: BackupDoc[] + errors: string[] +} + type BackupSyncSelection = { isComplete: boolean deletedDocumentIds: readonly string[] @@ -317,7 +333,7 @@ async function syncFromBackup( async function prepareBackupDocs( selection: BackupDocumentSelection, -): Promise { +): Promise { return prepareBackupSelection(selection, async docs => { let activeDocs: LoadedDocument[] = [] for (let doc of docs) { @@ -329,7 +345,20 @@ async function prepareBackupDocs( } activeDocs.push(loaded) } - return Promise.all(activeDocs.map(prepareBackupDoc)) + + let documents: BackupDoc[] = [] + let errors: string[] = [] + for (let doc of activeDocs) { + try { + documents.push(await prepareBackupDoc(doc)) + } catch (error) { + if (!(error instanceof AssetSerializationError)) throw error + errors.push( + `Could not back up "${getDocumentTitle(doc)}": ${error.message}`, + ) + } + } + return { documents, errors } }) } @@ -354,15 +383,7 @@ async function prepareBackupDoc(doc: LoadedDocument): Promise { for (let asset of Array.from(doc.assets)) { if (!asset?.$isLoaded) continue - let blob: Blob | undefined - if (asset.type === "image" && asset.image?.$isLoaded) { - let original = asset.image.original - if (original?.$isLoaded) { - blob = original.toBlob() - } - } else if (asset.type === "video" && asset.video?.$isLoaded) { - blob = asset.video.toBlob() - } + let blob = await serializeAsset(asset) if (blob) { assets.push({ id: asset.$jazz.id, name: asset.name, blob }) @@ -758,7 +779,10 @@ async function createDocFromFile( let docAssets: co.loaded[] = [] let assetFilesById = new Map() for (let assetFile of file.assets) { - let asset = await createAssetFromBlob(assetFile, docGroup, now) + let asset = await createAssetFromFile( + { blob: assetFile.blob, fileName: assetFile.name, createdAt: now }, + docGroup, + ) docAssets.push(asset) assetFilesById.set(asset.$jazz.id, assetFile.name) } @@ -805,7 +829,9 @@ async function updateDocFromFile( let loadedDoc = await Document.load(doc.$jazz.id, { resolve: { content: true, - assets: { $each: { image: true, video: true } }, + assets: { + $each: assetContentResolve, + }, comments: { $each: true }, }, }) @@ -933,10 +959,13 @@ async function syncDocAssetsFromFile( continue } - let created = await createAssetFromBlob( - fileAsset, + let created = await createAssetFromFile( + { + blob: fileAsset.blob, + fileName: fileAsset.name, + createdAt: new Date(), + }, doc.$jazz.owner, - new Date(), ) doc.assets.$jazz.push(created) keepAssetIds.add(created.$jazz.id) @@ -963,76 +992,34 @@ async function syncExistingAssetFromFile( assetId: string, fileAsset: { name: string; blob: Blob }, ): Promise { - let nextType = fileAsset.blob.type.startsWith("video/") ? "video" : "image" + let nextType = classifyAssetFile({ + name: fileAsset.name, + type: fileAsset.blob.type, + }) + if (!nextType) throw new Error(`Unsupported asset file: ${fileAsset.name}`) if (existing.type !== nextType) { let index = doc.assets?.findIndex(asset => asset?.$jazz.id === assetId) ?? -1 if (index === -1 || !doc.assets) return assetId doc.assets.$jazz.splice(index, 1) - let replacement = await createAssetFromBlob( - fileAsset, + let replacement = await createAssetFromFile( + { + blob: fileAsset.blob, + fileName: fileAsset.name, + createdAt: existing.createdAt, + }, doc.$jazz.owner, - existing.createdAt, ) doc.assets.$jazz.push(replacement) return replacement.$jazz.id } - if (existing.type === "video") { - let stream = await co.fileStream().createFromBlob(fileAsset.blob, { - owner: doc.$jazz.owner, - }) - existing.$jazz.applyDiff({ - name: removeExtension(fileAsset.name), - video: stream, - mimeType: fileAsset.blob.type || "video/mp4", - }) - return assetId - } - - let image = await createImage(fileAsset.blob, { - owner: doc.$jazz.owner, - maxSize: 2048, - }) - existing.$jazz.applyDiff({ name: removeExtension(fileAsset.name), image }) - return assetId -} - -async function createAssetFromBlob( - assetFile: { name: string; blob: Blob }, - owner: Group, - now: Date, -) { - let isVideo = assetFile.blob.type.startsWith("video/") - if (isVideo) { - let video = await co.fileStream().createFromBlob(assetFile.blob, { - owner, - }) - return VideoAsset.create( - { - type: "video", - name: removeExtension(assetFile.name), - video, - mimeType: assetFile.blob.type || "video/mp4", - createdAt: now, - }, - owner, - ) - } - - let image = await createImage(assetFile.blob, { - owner, - maxSize: 2048, - }) - return ImageAsset.create( - { - type: "image", - name: removeExtension(assetFile.name), - image, - createdAt: now, - }, - owner, + await updateAssetFromFile( + existing, + { blob: fileAsset.blob, fileName: fileAsset.name }, + doc.$jazz.owner, ) + return assetId } function removeExtension(filename: string): string { diff --git a/src/app/features/backup/widgets/subscribers.tsx b/src/app/features/backup/widgets/subscribers.tsx index 06b696f4..49efc57a 100644 --- a/src/app/features/backup/widgets/subscribers.tsx +++ b/src/app/features/backup/widgets/subscribers.tsx @@ -153,6 +153,7 @@ function ActiveBackupSubscriber() { ) let previousHash = lastContentHashRef.current ?? lastBackupHash let directoryGeneration = directoryGenerationRef.current + let preparationErrors: string[] = [] let request = { key: `${directoryGeneration}:${contentHash}`, async run() { @@ -166,10 +167,11 @@ function ActiveBackupSubscriber() { return false } - let backupDocs = await prepareBackupDocs(selection) + let prepared = await prepareBackupDocs(selection) + preparationErrors = prepared.errors let scopeId = docs.$jazz.id ? `docs:${docs.$jazz.id}` : "docs:unknown" - await syncBackup(handle, backupDocs, scopeId, { - isComplete: selection.isComplete, + await syncBackup(handle, prepared.documents, scopeId, { + isComplete: selection.isComplete && preparationErrors.length === 0, deletedDocumentIds: selection.deletedDocumentIds, }) return true @@ -179,7 +181,9 @@ function ActiveBackupSubscriber() { lastContentHashRef.current = contentHash setLastBackupAt(new Date().toISOString()) setLastBackupHash(contentHash) - setLastError(null) + setLastError( + preparationErrors.length > 0 ? preparationErrors.join("\n") : null, + ) }, fail(error: unknown) { if (directoryGeneration !== directoryGenerationRef.current) return @@ -332,6 +336,7 @@ function SpaceBackupSubscriber({ spaceId }: SpaceBackupSubscriberProps) { let { contentHash } = selection let previousHash = lastContentHashRef.current ?? getSpaceBackupHash(spaceId) let directoryGeneration = directoryGenerationRef.current + let preparationErrors: string[] = [] let request = { key: `${directoryGeneration}:${contentHash}`, async run() { @@ -343,10 +348,11 @@ function SpaceBackupSubscriber({ spaceId }: SpaceBackupSubscriberProps) { return false } - let backupDocs = await prepareBackupDocs(selection) + let prepared = await prepareBackupDocs(selection) + preparationErrors = prepared.errors let scopeId = docs.$jazz.id ? `docs:${docs.$jazz.id}` : "docs:unknown" - await syncBackup(handle, backupDocs, scopeId, { - isComplete: selection.isComplete, + await syncBackup(handle, prepared.documents, scopeId, { + isComplete: selection.isComplete && preparationErrors.length === 0, deletedDocumentIds: selection.deletedDocumentIds, }) return true @@ -355,6 +361,12 @@ function SpaceBackupSubscriber({ spaceId }: SpaceBackupSubscriberProps) { if (directoryGeneration !== directoryGenerationRef.current) return lastContentHashRef.current = contentHash setSpaceBackupHash(spaceId, contentHash) + if (preparationErrors.length > 0) { + console.error( + `Space backup skipped documents for ${spaceId}:`, + preparationErrors, + ) + } }, fail(error: unknown) { if (directoryGeneration !== directoryGenerationRef.current) return diff --git a/src/app/features/documents/lib/queries.ts b/src/app/features/documents/lib/queries.ts index 2f44b148..f1135d8a 100644 --- a/src/app/features/documents/lib/queries.ts +++ b/src/app/features/documents/lib/queries.ts @@ -2,6 +2,7 @@ import { co, type ResolveQuery } from "jazz-tools" import { useCoState, useAccount } from "jazz-tools/react" import { Document } from "./schema" import { UserAccount } from "@/schema" +import { assetPreviewResolve } from "@/app/features/assets" export { loaderResolve, resolve, settingsResolve, meResolve } export type { LoadedDocument, LoaderDocument, MaybeDocWithContent, LoadedMe } @@ -25,7 +26,9 @@ let loaderResolve = { let resolve = { content: true, cursors: true, - assets: { $each: { image: true, video: true } }, + assets: { + $each: assetPreviewResolve, + }, comments: { $each: { replies: true } }, } as const satisfies ResolveQuery diff --git a/src/app/features/documents/screens/doc-preview-screen.tsx b/src/app/features/documents/screens/doc-preview-screen.tsx index 380c9f90..58d48459 100644 --- a/src/app/features/documents/screens/doc-preview-screen.tsx +++ b/src/app/features/documents/screens/doc-preview-screen.tsx @@ -67,12 +67,15 @@ import { } from "../lib/comment-text-match" import { printToPdf } from "@/app/features/import-export" import { useIntl } from "@/shared/intl/setup" +import { assetPreviewResolve } from "@/app/features/assets" export { DocPreviewScreen, previewResolve, resolveDocTitles } let previewResolve = { content: true, - assets: { $each: { image: true } }, + assets: { + $each: assetPreviewResolve, + }, comments: { $each: { replies: true } }, } as const satisfies ResolveQuery diff --git a/src/app/features/documents/screens/doc-screen.tsx b/src/app/features/documents/screens/doc-screen.tsx index 9d025d99..5674cde7 100644 --- a/src/app/features/documents/screens/doc-screen.tsx +++ b/src/app/features/documents/screens/doc-screen.tsx @@ -23,7 +23,12 @@ import { makeDownloadAsset, canEncodeVideo, imageExtensions, + createTldrawAsset, + updateTldrawAsset, SidebarAssets, + useTldrawEditor, + toEditorAsset, + toSidebarAsset, type SidebarAsset, } from "@/app/features/assets" import { @@ -104,7 +109,11 @@ import { SidebarFileMenu } from "../widgets/sidebar-file-menu" import { SidebarEditMenu } from "@/app/features/editor" import { SidebarFormatMenu } from "@/app/features/editor" import { SidebarCollaboration } from "@/app/features/sharing" -import { ThemeToggle, useTheme } from "@/app/components/appearance" +import { + ThemeToggle, + useTheme, + useResolvedTheme, +} from "@/app/components/appearance" import { Tooltip, TooltipContent, @@ -249,6 +258,7 @@ function EditorContent({ doc, docId }: EditorContentProps) { }, []) let { theme, setTheme } = useTheme() + let resolvedTheme = useResolvedTheme() let { toggleLeft, toggleRight, @@ -280,19 +290,9 @@ function EditorContent({ doc, docId }: EditorContentProps) { editorRef: editor, }) let assets = - doc.assets?.flatMap(a => { - if (!a?.$isLoaded) return [] - return [ - { - id: a.$jazz.id, - name: a.name, - type: a.type, - imageId: a.type === "image" ? a.image?.$jazz.id : undefined, - video: a.type === "video" ? a.video : undefined, - muteAudio: a.type === "video" ? a.muteAudio : undefined, - }, - ] - }) ?? [] + doc.assets?.flatMap(a => + a?.$isLoaded ? [toEditorAsset(a, resolvedTheme)] : [], + ) ?? [] let assetsRef = useRef(assets) useEffect(() => { assetsRef.current = assets @@ -362,20 +362,18 @@ function EditorContent({ doc, docId }: EditorContentProps) { }) let sidebarAssets: SidebarAsset[] = - doc.assets?.flatMap(a => { - if (!a?.$isLoaded) return [] - let video = a.type === "video" && a.video?.$isLoaded ? a.video : null - return [ - { - id: a.$jazz.id, - name: a.name, - type: a.type, - imageId: a.type === "image" ? a.image?.$jazz.id : undefined, - getVideoBlob: video ? () => video.toBlob() : undefined, - muteAudio: a.type === "video" ? a.muteAudio : undefined, - }, - ] - }) ?? [] + doc.assets?.flatMap(a => (a?.$isLoaded ? [toSidebarAsset(a)] : [])) ?? [] + let tldrawEditor = useTldrawEditor({ + assets: sidebarAssets, + readOnly, + createAsset: async (name, save) => { + let asset = await createTldrawAsset(doc, name, save) + return { id: asset.$jazz.id, name: asset.name } + }, + updateAsset: async (assetId, save) => { + await updateTldrawAsset(doc, assetId, save) + }, + }) // Get documents for wikilink insertion menu - personal docs only let wikiLinkDocs: { id: string; title: string }[] = [] @@ -669,6 +667,9 @@ function EditorContent({ doc, docId }: EditorContentProps) { onCreateDocument={makeCreateDocument(me)} onUploadImage={makeUploadImage(doc)} onUploadVideo={canUploadVideo ? makeUploadVideo(doc) : undefined} + onImportTldraw={readOnly ? undefined : tldrawEditor.importFile} + onCreateTldraw={readOnly ? undefined : tldrawEditor.create} + onEditTldraw={readOnly ? undefined : tldrawEditor.edit} onAddComment={ commentsEnabled ? handleCreateCommentFromSelection : undefined } @@ -842,11 +843,15 @@ function EditorContent({ doc, docId }: EditorContentProps) { }} isAssetUsed={makeIsAssetUsed(docWithContent)} canUploadVideo={canUploadVideo} + onImportTldraw={tldrawEditor.importFile} + onCreateTldraw={tldrawEditor.create} + onEditTldraw={tldrawEditor.edit} /> )} + {tldrawEditor.dialog} ) } diff --git a/src/app/features/documents/screens/space-doc-screen.tsx b/src/app/features/documents/screens/space-doc-screen.tsx index 4b5baf0f..0c9e0ef1 100644 --- a/src/app/features/documents/screens/space-doc-screen.tsx +++ b/src/app/features/documents/screens/space-doc-screen.tsx @@ -23,7 +23,12 @@ import { makeDownloadAsset, canEncodeVideo, imageExtensions, + createTldrawAsset, + updateTldrawAsset, SidebarAssets, + useTldrawEditor, + toEditorAsset, + toSidebarAsset, type SidebarAsset, } from "@/app/features/assets" import { @@ -101,7 +106,11 @@ import { SidebarFileMenu } from "../widgets/sidebar-file-menu" import { SidebarEditMenu } from "@/app/features/editor" import { SidebarFormatMenu } from "@/app/features/editor" import { SidebarCollaboration } from "@/app/features/sharing" -import { ThemeToggle, useTheme } from "@/app/components/appearance" +import { + ThemeToggle, + useTheme, + useResolvedTheme, +} from "@/app/components/appearance" import { Tooltip, TooltipContent, @@ -251,6 +260,7 @@ function SpaceEditorContent({ }, []) let { theme, setTheme } = useTheme() + let resolvedTheme = useResolvedTheme() let { toggleLeft, toggleRight, @@ -284,19 +294,9 @@ function SpaceEditorContent({ editorRef: editor, }) let assets = - doc.assets?.flatMap(a => { - if (!a?.$isLoaded) return [] - return [ - { - id: a.$jazz.id, - name: a.name, - type: a.type, - imageId: a.type === "image" ? a.image?.$jazz.id : undefined, - video: a.type === "video" ? a.video : undefined, - muteAudio: a.type === "video" ? a.muteAudio : undefined, - }, - ] - }) ?? [] + doc.assets?.flatMap(a => + a?.$isLoaded ? [toEditorAsset(a, resolvedTheme)] : [], + ) ?? [] let assetsRef = useRef(assets) useEffect(() => { assetsRef.current = assets @@ -369,20 +369,18 @@ function SpaceEditorContent({ }) let sidebarAssets: SidebarAsset[] = - doc.assets?.flatMap(a => { - if (!a?.$isLoaded) return [] - let video = a.type === "video" && a.video?.$isLoaded ? a.video : null - return [ - { - id: a.$jazz.id, - name: a.name, - type: a.type, - imageId: a.type === "image" ? a.image?.$jazz.id : undefined, - getVideoBlob: video ? () => video.toBlob() : undefined, - muteAudio: a.type === "video" ? a.muteAudio : undefined, - }, - ] - }) ?? [] + doc.assets?.flatMap(a => (a?.$isLoaded ? [toSidebarAsset(a)] : [])) ?? [] + let tldrawEditor = useTldrawEditor({ + assets: sidebarAssets, + readOnly, + createAsset: async (name, save) => { + let asset = await createTldrawAsset(doc, name, save) + return { id: asset.$jazz.id, name: asset.name } + }, + updateAsset: async (assetId, save) => { + await updateTldrawAsset(doc, assetId, save) + }, + }) let wikiLinkDocs: { id: string; title: string }[] = [] if (space.documents?.$isLoaded) { @@ -679,6 +677,9 @@ function SpaceEditorContent({ onCreateDocument={makeCreateDocument(space)} onUploadImage={makeUploadImage(doc)} onUploadVideo={canUploadVideo ? makeUploadVideo(doc) : undefined} + onImportTldraw={readOnly ? undefined : tldrawEditor.importFile} + onCreateTldraw={readOnly ? undefined : tldrawEditor.create} + onEditTldraw={readOnly ? undefined : tldrawEditor.edit} onAddComment={ commentsEnabled ? handleCreateCommentFromSelection : undefined } @@ -861,11 +862,15 @@ function SpaceEditorContent({ }} isAssetUsed={makeIsAssetUsed(docWithContent)} canUploadVideo={canUploadVideo} + onImportTldraw={tldrawEditor.importFile} + onCreateTldraw={tldrawEditor.create} + onEditTldraw={tldrawEditor.edit} /> )} + {tldrawEditor.dialog} ) } diff --git a/src/app/features/documents/widgets/duplicate-doc-dialog.tsx b/src/app/features/documents/widgets/duplicate-doc-dialog.tsx index 5fdec292..c74601fc 100644 --- a/src/app/features/documents/widgets/duplicate-doc-dialog.tsx +++ b/src/app/features/documents/widgets/duplicate-doc-dialog.tsx @@ -1,6 +1,5 @@ import { useState, useRef, useEffect } from "react" import { co, Group } from "jazz-tools" -import { createImage } from "jazz-tools/media" import { useAccount } from "jazz-tools/react" import { toast } from "sonner" import { User } from "lucide-react" @@ -25,18 +24,12 @@ import { } from "@/app/components/ui/select" import { Progress } from "@/app/components/ui/progress" import { SpaceInitials } from "@/app/features/spaces" -import { - Asset, - ImageAsset, - VideoAsset, - Document, - Space, - UserAccount, -} from "@/schema" +import { Asset, Document, Space, UserAccount } from "@/schema" import { getSpaceGroup } from "@/app/features/spaces" import { copyCommentsAndApplyContent } from "@/app/features/comments" import { createDocumentMetadata } from "@/app/features/documents" import { useIntl } from "@/shared/intl/setup" +import { assetContentResolve, copyAsset } from "@/app/features/assets" export { DuplicateDocDialog, duplicateDocument } export type { DuplicateDocDialogProps, DuplicateProgress } @@ -45,7 +38,9 @@ type LoadedDocument = co.loaded< typeof Document, { content: true - assets: { $each: { image: true; video: true } } + assets: { + $each: typeof assetContentResolve + } comments: { $each: { replies: true } } } > @@ -268,7 +263,8 @@ async function duplicateDocument(opts: DuplicateOptions): Promise { a => a?.$isLoaded && ((a.type === "image" && a.image?.$isLoaded) || - (a.type === "video" && a.video?.$isLoaded)), + (a.type === "video" && a.video?.$isLoaded) || + (a.type === "tldraw" && a.revision?.$isLoaded)), ).length let progress: DuplicateProgress = { total: totalAssets, @@ -307,60 +303,15 @@ async function duplicateDocument(opts: DuplicateOptions): Promise { // Create the new assets list let newAssets = co.list(Asset).create([], owner) - // Deep copy each asset (image or video) + // Deep copy each asset for (let asset of [...assets]) { if (!asset?.$isLoaded) continue try { - let newAssetId: string - - if (asset.type === "image" && asset.image?.$isLoaded) { - let original = asset.image.original - if (!original?.$isLoaded) continue - - let blob = original.toBlob() - if (!blob) continue - - let newImage = await createImage(blob, { - owner, - maxSize: 2048, - }) - - let newAsset = ImageAsset.create( - { - type: "image", - name: asset.name, - image: newImage, - createdAt: new Date(), - }, - owner, - ) - newAssets.$jazz.push(newAsset) - newAssetId = newAsset.$jazz.id - } else if (asset.type === "video" && asset.video?.$isLoaded) { - let blob = asset.video.toBlob() - if (!blob) continue - - let newVideo = await co.fileStream().createFromBlob(blob, { owner }) - - let newAsset = VideoAsset.create( - { - type: "video", - name: asset.name, - video: newVideo, - mimeType: asset.mimeType, - muteAudio: asset.muteAudio, - createdAt: new Date(), - }, - owner, - ) - newAssets.$jazz.push(newAsset) - newAssetId = newAsset.$jazz.id - } else { - continue - } + let newAsset = await copyAsset(asset, owner) + newAssets.$jazz.push(newAsset) - assetIdMap.set(asset.$jazz.id, newAssetId) + assetIdMap.set(asset.$jazz.id, newAsset.$jazz.id) progress = { ...progress, copied: progress.copied + 1 } onProgress?.(progress) diff --git a/src/app/features/documents/widgets/preview.tsx b/src/app/features/documents/widgets/preview.tsx index eac3caff..b5b1d9d8 100644 --- a/src/app/features/documents/widgets/preview.tsx +++ b/src/app/features/documents/widgets/preview.tsx @@ -29,10 +29,14 @@ export { Preview } type Asset = { $jazz: { id: string } $isLoaded?: boolean - type?: "image" | "video" + type?: "image" | "video" | "tldraw" image?: { $jazz: { id: string } } video?: { $isLoaded?: boolean; toBlob?: () => Blob | undefined } muteAudio?: boolean + revision?: { + lightPreview?: { $jazz: { id: string } } + darkPreview?: { $jazz: { id: string } } + } } interface PreviewProps { @@ -91,6 +95,7 @@ function Preview({ content={content} assets={assets} marked={marked} + colorScheme={resolvedTheme} cacheVersion={wikilinks.size} onExit={onExit} documentTheme={documentTheme} @@ -110,6 +115,7 @@ function PreviewContent({ content, assets, marked, + colorScheme, cacheVersion, onExit, documentTheme, @@ -120,6 +126,7 @@ function PreviewContent({ content: string assets?: Asset[] marked: Marked + colorScheme: "light" | "dark" cacheVersion: number onExit?: () => void documentTheme: ResolvedTheme @@ -147,14 +154,14 @@ function PreviewContent({ let { body } = parseFrontmatter(content) let cancelled = false - parseSegments(body, assets, marked).then(result => { + parseSegments(body, assets, marked, colorScheme).then(result => { if (!cancelled) setSegments(result) }) return () => { cancelled = true } - }, [content, assets, marked, cacheVersion]) + }, [content, assets, marked, cacheVersion, colorScheme]) useEffect(() => { document.title = getDocumentTitle(content) @@ -710,6 +717,7 @@ async function parseSegments( content: string, assets: Asset[] | undefined, marked: Marked, + colorScheme: "light" | "dark", ): Promise { let rawSegments: RawSegment[] = [] let lastIndex = 0 @@ -734,6 +742,20 @@ async function parseSegments( imageId: asset.image.$jazz.id, alt, }) + } else if (asset?.$isLoaded && asset.type === "tldraw") { + let preview = + colorScheme === "dark" + ? asset.revision?.darkPreview + : asset.revision?.lightPreview + if (preview) { + rawSegments.push({ + type: "image", + imageId: preview.$jazz.id, + alt, + }) + } else { + rawSegments.push({ type: "text", content: match[0] }) + } } else if (asset?.$isLoaded && asset.type === "video" && asset.video) { rawSegments.push({ type: "video", diff --git a/src/app/features/documents/widgets/sidebar-document-list.tsx b/src/app/features/documents/widgets/sidebar-document-list.tsx index feab789d..d702eaca 100644 --- a/src/app/features/documents/widgets/sidebar-document-list.tsx +++ b/src/app/features/documents/widgets/sidebar-document-list.tsx @@ -12,6 +12,7 @@ import { useVirtualizer } from "@tanstack/react-virtual" import { co } from "jazz-tools" import { useAccount, useCoState } from "jazz-tools/react" import { UserAccount, Document } from "@/schema" +import { assetContentResolve, serializeAsset } from "@/app/features/assets" import { togglePinned } from "@/app/features/editor" import { formatRelativeDate } from "../lib/title" import { applyContentDiffLoadingCommentAnchors } from "@/app/features/comments" @@ -1332,24 +1333,19 @@ async function loadDocumentAssets( doc: co.loaded, ): Promise { let loaded = await doc.$jazz.ensureLoaded({ - resolve: { assets: { $each: { image: true } } }, + resolve: { + assets: { + $each: assetContentResolve, + }, + }, }) let docAssets: ExportAsset[] = [] if (loaded.assets?.$isLoaded) { for (let asset of Array.from(loaded.assets)) { - if ( - !asset?.$isLoaded || - asset.type !== "image" || - !asset.image?.$isLoaded - ) - continue - let original = asset.image.original - if (!original?.$isLoaded) continue - let blob = original.toBlob() - if (blob) { - docAssets.push({ id: asset.$jazz.id, name: asset.name, blob }) - } + if (!asset?.$isLoaded) continue + let blob = await serializeAsset(asset) + if (blob) docAssets.push({ id: asset.$jazz.id, name: asset.name, blob }) } } diff --git a/src/app/features/documents/widgets/sidebar-file-menu.tsx b/src/app/features/documents/widgets/sidebar-file-menu.tsx index 2d1ff7b8..7c49a7bc 100644 --- a/src/app/features/documents/widgets/sidebar-file-menu.tsx +++ b/src/app/features/documents/widgets/sidebar-file-menu.tsx @@ -48,6 +48,7 @@ import { getExportComments, } from "@/app/features/comments" import type { MarkdownEditorRef } from "@/app/features/editor" +import { serializeAsset } from "@/app/features/assets" import { loadThemesForPdf } from "@/app/features/themes" import { createDocumentMetadata, syncDocumentMetadata } from "../lib/metadata" @@ -487,18 +488,9 @@ function makeDownload(doc: LoadedDocument, content: string) { let docAssets: ExportAsset[] = [] if (doc.assets?.$isLoaded) { for (let asset of Array.from(doc.assets)) { - if ( - !asset?.$isLoaded || - asset.type !== "image" || - !asset.image?.$isLoaded - ) - continue - let original = asset.image.original - if (!original?.$isLoaded) continue - let blob = original.toBlob() - if (blob) { - docAssets.push({ id: asset.$jazz.id, name: asset.name, blob }) - } + if (!asset?.$isLoaded) continue + let blob = await serializeAsset(asset) + if (blob) docAssets.push({ id: asset.$jazz.id, name: asset.name, blob }) } } let title = getDocumentTitle(content) diff --git a/src/app/features/editor/index.ts b/src/app/features/editor/index.ts index 0b9f6080..5522a9e0 100644 --- a/src/app/features/editor/index.ts +++ b/src/app/features/editor/index.ts @@ -15,7 +15,7 @@ export { WikiLinkDialog, TaskAction, LinkAction, - ImageAction, + MediaAction, WikiLinkAction, type FloatingActionsProps, type FloatingActionsRef, diff --git a/src/app/features/editor/widgets/editor.tsx b/src/app/features/editor/widgets/editor.tsx index eb37a5be..ccefac67 100644 --- a/src/app/features/editor/widgets/editor.tsx +++ b/src/app/features/editor/widgets/editor.tsx @@ -1,6 +1,6 @@ import { useImperativeHandle, useEffect, useRef, useState } from "react" import { diff } from "fast-myers-diff" -import { ImageOff } from "lucide-react" +import { ImageOff, Maximize2, Minimize2, PenTool } from "lucide-react" import { toast } from "sonner" import { EditorState, @@ -66,14 +66,17 @@ import { useScreenKeyboardBottomInset } from "../hooks/use-screen-keyboard-botto import { Dialog, DialogContent, + DialogFooter, DialogHeader, DialogTitle, } from "@/app/components/ui/dialog" +import { Button } from "@/app/components/ui/button" +import { cn } from "@/app/lib/cn" import { FloatingActions, TaskAction, LinkAction, - ImageAction, + MediaAction, WikiLinkAction, CommentAction, type FloatingActionsRef, @@ -83,6 +86,7 @@ import { type UploadPhase, } from "@/app/features/import-export" import { useIntl } from "@/shared/intl/setup" +import type { EditorAsset } from "@/app/features/assets" export { MarkdownEditor, useMarkdownEditorRef } export { parseFrontmatter } from "../lib/frontmatter" @@ -104,15 +108,6 @@ type WikilinkDoc = { type DropTarget = { pos: number } -type Asset = { - id: string - name: string - type: "image" | "video" - imageId?: string - video?: { $isLoaded?: boolean; toBlob?: () => Blob | undefined } - muteAudio?: boolean -} - interface MarkdownEditorProps { // Core value: string @@ -124,7 +119,7 @@ interface MarkdownEditorProps { onBlur?: () => void // Data for decorations (optional = feature detection) - assets?: Asset[] + assets?: EditorAsset[] documents?: WikilinkDoc[] // Wikilink integration: caller resolves ids and handles clicks. @@ -142,6 +137,11 @@ interface MarkdownEditorProps { signal: AbortSignal }, ) => Promise<{ id: string; name: string }> + onImportTldraw?: (file: File) => void + onCreateTldraw?: ( + onCreated: (asset: { id: string; name: string }) => void, + ) => void + onEditTldraw?: (assetId: string) => void onAddComment?: ( selection: { from: number; to: number }, body: string, @@ -235,6 +235,9 @@ function MarkdownEditor( onCreateDocument, onUploadImage, onUploadVideo, + onImportTldraw, + onCreateTldraw, + onEditTldraw, onAddComment, extensions: externalExtensions, placeholder, @@ -261,11 +264,13 @@ function MarkdownEditor( let [isFocused, setIsFocused] = useState(false) let [findPanelHeight, setFindPanelHeight] = useState(0) let [mediaPreviewOpen, setMediaPreviewOpen] = useState(false) + let [mediaPreviewExpanded, setMediaPreviewExpanded] = useState(false) let [mediaPreview, setMediaPreview] = useState<{ url: string alt: string assetId: string | null } | null>(null) + let pendingTldrawEdit = useRef(null) let [videoUpload, setVideoUpload] = useState(null) let callbacksRef = useRef({ onChange, onCursorChange, onFocus, onBlur }) @@ -274,6 +279,7 @@ function MarkdownEditor( let autoSortRef = useRef(autoSortTasks ?? false) let uploadImageRef = useRef(onUploadImage) let uploadVideoRef = useRef(onUploadVideo) + let importTldrawRef = useRef(onImportTldraw) let addCommentEnabledRef = useRef(Boolean(onAddComment)) let activeDropsRef = useRef>(new Set()) @@ -289,6 +295,10 @@ function MarkdownEditor( uploadVideoRef.current = onUploadVideo }) + useEffect(() => { + importTldrawRef.current = onImportTldraw + }) + useEffect(() => { addCommentEnabledRef.current = Boolean(onAddComment) }) @@ -321,9 +331,22 @@ function MarkdownEditor( assetId = url.slice(6) } setMediaPreview({ url, alt, assetId }) + setMediaPreviewExpanded(false) setMediaPreviewOpen(true) } + let editableTldrawAssetId = + !readOnly && mediaPreview?.assetId && onEditTldraw + ? assets?.find( + asset => asset.id === mediaPreview.assetId && asset.type === "tldraw", + )?.id + : undefined + + function handleEditTldrawPreview(assetId: string) { + pendingTldrawEdit.current = assetId + setMediaPreviewOpen(false) + } + let wikilinkResolver = (id: string) => resolveWikilink?.(id) let handleWikilinkNavigate = (id: string, newTab: boolean) => { onWikilinkClick?.(id, newTab) @@ -569,6 +592,14 @@ function MarkdownEditor( if (activeView.state.readOnly) return let uploadImage = uploadImageRef.current let uploadVideo = uploadVideoRef.current + let importTldraw = importTldrawRef.current + let tldrawFile = Array.from(files).find(file => + file.name.toLowerCase().endsWith(".tldr"), + ) + if (tldrawFile && importTldraw) { + importTldraw(tldrawFile) + return + } let images = uploadImage ? Array.from(files).filter(f => f.type.startsWith("image/")) @@ -932,7 +963,13 @@ function MarkdownEditor( return { from, to } }, getSelectedText, - restoreSelection: () => {}, + restoreSelection: selection => { + if (!view) return + view.focus() + view.dispatch({ + selection: { anchor: selection.from, head: selection.to }, + }) + }, getScrollPosition: () => ({ top: 0, left: 0 }), setScrollPosition: () => {}, undo: () => {}, @@ -1009,10 +1046,11 @@ function MarkdownEditor( docs={documents ?? []} onCreateDoc={onCreateDocument} /> - { - if (!open) setMediaPreview(null) + if (open) return + setMediaPreviewExpanded(false) + setMediaPreview(null) + let assetId = pendingTldrawEdit.current + pendingTldrawEdit.current = null + if (assetId) onEditTldraw?.(assetId) }} > - - + + {mediaPreview?.alt ?? t("editor.dialog.selectMedia")} - {mediaPreview && ( - - )} + +
+ {mediaPreview && ( + + )} +
+ + + {editableTldrawAssetId && ( + + )} +
@@ -1059,9 +1159,11 @@ function MarkdownEditor( function MediaPreviewContent({ preview, assets, + expanded, }: { preview: { url: string; alt: string; assetId: string | null } - assets?: Asset[] + assets?: EditorAsset[] + expanded: boolean }) { let t = useIntl() let asset = preview.assetId @@ -1074,7 +1176,10 @@ function MediaPreviewContent({ {preview.alt} ) } @@ -1090,18 +1195,21 @@ function MediaPreviewContent({ } // Image asset - if (asset.type === "image" && asset.imageId) { + if ((asset.type === "image" || asset.type === "tldraw") && asset.previewId) { return ( ) } // Video asset if (asset.type === "video" && asset.video) { - return + return } return ( @@ -1112,7 +1220,13 @@ function MediaPreviewContent({ ) } -function VideoPreview({ asset }: { asset: Asset }) { +function VideoPreview({ + asset, + expanded, +}: { + asset: EditorAsset + expanded: boolean +}) { let t = useIntl() let [url, setUrl] = useState(null) let [trackedVideo, setTrackedVideo] = useState(asset.video) @@ -1169,7 +1283,10 @@ function VideoPreview({ asset }: { asset: Asset }) { controls autoPlay muted={asset.muteAudio} - className="max-h-[70vh] w-full object-contain" + className={cn( + "w-full object-contain", + expanded ? "h-full" : "max-h-[70vh]", + )} /> ) } diff --git a/src/app/features/editor/widgets/floating-actions.tsx b/src/app/features/editor/widgets/floating-actions.tsx index f2c82ecc..c63b0016 100644 --- a/src/app/features/editor/widgets/floating-actions.tsx +++ b/src/app/features/editor/widgets/floating-actions.tsx @@ -23,14 +23,15 @@ import { Kbd } from "@/app/components/ui/kbd" import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/app/components/ui/dropdown-menu" import { Check, ExternalLink, - ImagePlus, - Upload, Image as ImageIcon, Film, Command, @@ -40,6 +41,7 @@ import { Plus, FileSymlinkIcon, MessageSquarePlus, + PenTool, } from "lucide-react" import { parseWikiLinks } from "../lib/wikilink-parser" import { useNavigate } from "@tanstack/react-router" @@ -59,7 +61,7 @@ export { FloatingActions, TaskAction, LinkAction, - ImageAction, + MediaAction, WikiLinkAction, CommentAction, WikiLinkDialog, @@ -91,8 +93,12 @@ interface FloatingActionsContext { image: { isImage: boolean imageRange: Range | null + assetMenuOpen: boolean + setAssetMenuOpen: (open: boolean) => void + markAssetMenuAction: () => void imageDialogOpen: boolean setImageDialogOpen: (open: boolean) => void + closeImageDialog: () => void imageRangeRef: React.RefObject } wikiLink: { @@ -125,6 +131,7 @@ interface EditorContext { linkRange: Range | null isImage: boolean imageRange: Range | null + imageSelection: Range | null wikiLinkId: string | null wikiLinkRange: Range | null selectionRange: Range | null @@ -151,12 +158,14 @@ function FloatingActions({ linkRange: null, isImage: false, imageRange: null, + imageSelection: null, wikiLinkId: null, wikiLinkRange: null, selectionRange: null, selectedText: "", }) let [imageDialogOpen, setImageDialogOpen] = useState(false) + let [assetMenuOpen, setAssetMenuOpen] = useState(false) let [wikiLinkMenuOpen, setWikiLinkMenuOpen] = useState(false) let [wikiLinkDialogOpen, setWikiLinkDialogOpen] = useState(false) let [commentDialogOpen, setCommentDialogOpen] = useState(false) @@ -166,6 +175,11 @@ function FloatingActions({ let [wikilinkPrefill, setWikilinkPrefill] = useState("") let imageRangeRef = useRef(null) + let imageSelectionRef = useRef(null) + let assetMenuActionPendingRef = useRef(false) + let imageDialogDismissPendingRef = useRef(false) + let assetMenuCloseTimerRef = useRef(null) + let imageDialogCloseTimerRef = useRef(null) let wikiLinkRangeRef = useRef(null) let rightOffset = @@ -181,6 +195,7 @@ function FloatingActions({ let shouldResetContext = !focused && !isInteracting && + !assetMenuOpen && !imageDialogOpen && !wikiLinkMenuOpen && !wikiLinkDialogOpen && @@ -195,6 +210,7 @@ function FloatingActions({ linkRange: null, isImage: false, imageRange: null, + imageSelection: null, wikiLinkId: null, wikiLinkRange: null, selectionRange: null, @@ -232,6 +248,7 @@ function FloatingActions({ linkRange: null, isImage: false, imageRange: null, + imageSelection: null, wikiLinkId: null, wikiLinkRange: null, selectionRange: @@ -286,6 +303,7 @@ function FloatingActions({ if (current.name === "Image") { result.isImage = true result.imageRange = { from: current.from, to: current.to } + result.imageSelection = { from: selection.from, to: selection.to } } current = current.parent @@ -421,6 +439,10 @@ function FloatingActions({ while (current) { if (current.name === "Image") { imageRangeRef.current = { from: current.from, to: current.to } + imageSelectionRef.current = { + from: state.selection.main.from, + to: state.selection.main.to, + } setImageDialogOpen(true) return } @@ -430,13 +452,85 @@ function FloatingActions({ })) function handleImageDialogOpenChange(open: boolean) { - if (open && context.imageRange) { - imageRangeRef.current = context.imageRange + if (open) { + rememberImageTarget() + clearImageDialogCloseTimer() + imageDialogDismissPendingRef.current = false + } else { + imageDialogDismissPendingRef.current = true + scheduleImageDialogSelectionRestore() } setImageDialogOpen(open) } + function closeImageDialog() { + imageDialogDismissPendingRef.current = false + setImageDialogOpen(false) + } + + function handleAssetMenuOpenChange(open: boolean) { + if (open) { + rememberImageTarget() + clearAssetMenuCloseTimer() + assetMenuActionPendingRef.current = false + } else { + scheduleAssetMenuSelectionRestore() + } + setAssetMenuOpen(open) + } + + function scheduleImageDialogSelectionRestore() { + clearImageDialogCloseTimer() + imageDialogCloseTimerRef.current = window.setTimeout(() => { + imageDialogCloseTimerRef.current = null + if (!imageDialogDismissPendingRef.current) return + + imageDialogDismissPendingRef.current = false + restoreImageSelection() + }, 150) + } + + function scheduleAssetMenuSelectionRestore() { + clearAssetMenuCloseTimer() + assetMenuCloseTimerRef.current = window.setTimeout(() => { + assetMenuCloseTimerRef.current = null + if (!assetMenuActionPendingRef.current) restoreImageSelection() + + assetMenuActionPendingRef.current = false + }, 150) + } + + function clearImageDialogCloseTimer() { + if (imageDialogCloseTimerRef.current === null) return + + clearTimeout(imageDialogCloseTimerRef.current) + imageDialogCloseTimerRef.current = null + } + + function clearAssetMenuCloseTimer() { + if (assetMenuCloseTimerRef.current === null) return + + clearTimeout(assetMenuCloseTimerRef.current) + assetMenuCloseTimerRef.current = null + } + + function restoreImageSelection() { + let selection = imageSelectionRef.current + if (selection) editor.current?.restoreSelection(selection) + } + + function markAssetMenuAction() { + assetMenuActionPendingRef.current = true + } + + function rememberImageTarget() { + if (context.imageRange) imageRangeRef.current = context.imageRange + if (context.imageSelection) + imageSelectionRef.current = context.imageSelection + } + function handlePointerDown() { + rememberImageTarget() setIsInteracting(true) } @@ -448,6 +542,7 @@ function FloatingActions({ let shouldShow = focused || + assetMenuOpen || imageDialogOpen || wikiLinkMenuOpen || wikiLinkDialogOpen || @@ -469,8 +564,12 @@ function FloatingActions({ image: { isImage: context.isImage, imageRange: context.imageRange, + assetMenuOpen, + setAssetMenuOpen: handleAssetMenuOpenChange, + markAssetMenuAction, imageDialogOpen, setImageDialogOpen: handleImageDialogOpenChange, + closeImageDialog, imageRangeRef, }, wikiLink: { @@ -895,41 +994,61 @@ function WikiLinkDialog({ ) } -interface ImageActionProps { +interface MediaActionProps { editor: React.RefObject isImage: boolean imageRange: Range | null + assetMenuOpen: boolean + setAssetMenuOpen: (open: boolean) => void + markAssetMenuAction: () => void imageDialogOpen: boolean setImageDialogOpen: (open: boolean) => void + closeImageDialog: () => void imageRangeRef: React.RefObject - assets: { id: string; name: string; type: "image" | "video" }[] + assets: { + id: string + name: string + type: "image" | "video" | "tldraw" + }[] onUploadAndInsert?: (file: File, replaceRange: Range) => Promise + onCreateTldraw?: ( + onCreated: (asset: { id: string; name: string }) => void, + ) => void } -function ImageAction({ +type AssetPickerStep = "media" | "whiteboard" + +function MediaAction({ editor, isImage, + assetMenuOpen, + setAssetMenuOpen, + markAssetMenuAction, imageDialogOpen, setImageDialogOpen, + closeImageDialog, imageRangeRef, assets, onUploadAndInsert, -}: ImageActionProps) { + onCreateTldraw, +}: MediaActionProps) { let t = useIntl() let fileInputRef = useRef(null) let [inputValue, setInputValue] = useState("") + let [step, setStep] = useState("media") - if (!isImage) return null + if (!isImage && !assetMenuOpen && !imageDialogOpen) return null - let filteredAssets = assets.filter(asset => + let selectableAssets = assets.filter(asset => + step === "whiteboard" + ? asset.type === "tldraw" + : asset.type === "image" || asset.type === "video", + ) + let filteredAssets = selectableAssets.filter(asset => asset.name.toLowerCase().includes(inputValue.toLowerCase()), ) - function handleSelectAsset(assetId: string | null) { - if (!assetId) return - let asset = assets.find(a => a.id === assetId) - if (!asset) return - + function insertAsset(asset: { id: string; name: string }) { let view = editor.current?.getEditor() let range = imageRangeRef.current if (!view || !range) return @@ -938,66 +1057,131 @@ function ImageAction({ changes: { from: range.from, to: range.to, - insert: `![${asset.name}](asset:${assetId})`, + insert: `![${asset.name}](asset:${asset.id})`, }, }) - setImageDialogOpen(false) - setInputValue("") view.focus() } - function handleUploadClick() { + function handleSelectAsset(assetId: string | null) { + if (!assetId) return + let asset = assets.find(a => a.id === assetId) + if (!asset) return + + insertAsset(asset) + closeImageDialog() + } + + function handleDialogOpenChange(open: boolean) { + setImageDialogOpen(open) + if (!open) { + setStep("media") + setInputValue("") + } + } + + function createWhiteboard() { + markAssetMenuAction() + requestAnimationFrame(() => onCreateTldraw?.(insertAsset)) + } + + function createMedia() { + markAssetMenuAction() fileInputRef.current?.click() } + function selectAsset(assetType: AssetPickerStep) { + markAssetMenuAction() + setStep(assetType) + setImageDialogOpen(true) + } + async function handleFileChange(e: React.ChangeEvent) { let file = e.target.files?.[0] let range = imageRangeRef.current if (!file || !range || !onUploadAndInsert) return e.target.value = "" - setImageDialogOpen(false) - setInputValue("") + closeImageDialog() await onUploadAndInsert(file, range) editor.current?.focus() } return ( <> - - { - if (editor.current?.getEditor()) { - imageRangeRef.current = - editor.current.getSelection() as Range | null + + + + + } - setImageDialogOpen(true) - }} + /> + } + /> + + + Ctrl Space + + + + + + + + - - - } - /> - - - Ctrl Space - - + + +
+ selectAsset("media")}> + + + + + + + + + + + + + + selectAsset("whiteboard")}> + + + + + + - + - + {step === "media" + ? t("editor.dialog.selectMedia") + : t("editor.dialog.selectWhiteboard")} - + {step === "media" + ? t("editor.dialog.searchMedia") + : t("editor.dialog.searchWhiteboards")} @@ -1009,8 +1193,12 @@ function ImageAction({ >
@@ -1019,7 +1207,9 @@ function ImageAction({ {filteredAssets.length === 0 && (
- + {step === "media" + ? t("editor.dialog.noMediaFound") + : t("editor.dialog.noWhiteboardsFound")}
)} @@ -1027,10 +1217,12 @@ function ImageAction({ {asset.type === "video" ? ( + ) : asset.type === "tldraw" ? ( + ) : ( )} @@ -1042,23 +1234,12 @@ function ImageAction({ -
- {onUploadAndInsert && ( - - )} -
+
diff --git a/src/app/features/import-export/lib/export.ts b/src/app/features/import-export/lib/export.ts index b68f66da..f4b7a092 100644 --- a/src/app/features/import-export/lib/export.ts +++ b/src/app/features/import-export/lib/export.ts @@ -1,4 +1,5 @@ import JSZip from "jszip" +import { assetExtensionFromMimeType } from "@/app/features/assets" import type { ExportComment } from "@/app/features/comments" export { @@ -152,18 +153,7 @@ function sanitizeFilename(name: string): string { } function getExtensionFromBlob(blob: Blob): string { - let mimeToExt: Record = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - "image/svg+xml": ".svg", - "image/bmp": ".bmp", - "video/mp4": ".mp4", - "video/webm": ".webm", - "video/quicktime": ".mov", - } - return mimeToExt[blob.type] || ".png" + return assetExtensionFromMimeType(blob.type) ?? ".png" } // Structure: diff --git a/src/app/features/import-export/lib/import.ts b/src/app/features/import-export/lib/import.ts index e0563b1e..2f97edc8 100644 --- a/src/app/features/import-export/lib/import.ts +++ b/src/app/features/import-export/lib/import.ts @@ -1,4 +1,8 @@ import JSZip from "jszip" +import { + assetMimeTypeFromFileName, + isAssetFileName, +} from "@/app/features/assets" import type { ExportComment } from "./export" export { @@ -133,7 +137,7 @@ async function importFolderFiles( mdFiles.push({ path, file }) } else if (isCommentSidecar(fileName)) { sidecarFiles.push({ key: path, file }) - } else if (isMediaFile(fileName)) { + } else if (isAssetFileName(fileName)) { assetFiles.push({ path, file }) } } @@ -277,7 +281,7 @@ async function importZipFile(file: File): Promise { mdFiles.push({ path: relativePath, name: fileName }) } else if (isCommentSidecar(fileName)) { sidecarFiles.push({ key: relativePath, file: zipEntry }) - } else if (isMediaFile(fileName)) { + } else if (isAssetFileName(fileName)) { assetFiles.push({ path: relativePath, file: zipEntry }) } }) @@ -319,7 +323,7 @@ async function importZipFile(file: File): Promise { let blob = await assetEntry.file.async("blob") let fileName = assetEntry.path.split("/").pop() || "image" let assetFile = new File([blob], fileName, { - type: getMimeType(fileName), + type: assetMimeTypeFromFileName(fileName), }) assets.push({ @@ -337,7 +341,7 @@ async function importZipFile(file: File): Promise { let blob = await assetFile.file.async("blob") let fileName = assetFile.path.split("/").pop() || "image" let file = new File([blob], fileName, { - type: getMimeType(fileName), + type: assetMimeTypeFromFileName(fileName), }) assets.push({ name: fileName.replace(/\.[^.]+$/, ""), @@ -450,37 +454,6 @@ function getCommentSidecarPath(markdownPath: string): string { return markdownPath.replace(/\.(md|markdown|txt)$/i, ".comments.json") } -function isImageFile(filename: string): boolean { - let ext = filename.toLowerCase().split(".").pop() - return ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"].includes(ext || "") -} - -function isVideoFile(filename: string): boolean { - let ext = filename.toLowerCase().split(".").pop() - return ["mp4", "webm", "mov"].includes(ext || "") -} - -function isMediaFile(filename: string): boolean { - return isImageFile(filename) || isVideoFile(filename) -} - -function getMimeType(filename: string): string { - let ext = filename.toLowerCase().split(".").pop() - let mimeTypes: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", - bmp: "image/bmp", - mp4: "video/mp4", - webm: "video/webm", - mov: "video/quicktime", - } - return mimeTypes[ext || ""] || "application/octet-stream" -} - function resolveWikilinkPath( linkPath: string, currentDocPath: string | null, diff --git a/src/app/features/import-export/widgets/sidebar-import-export.tsx b/src/app/features/import-export/widgets/sidebar-import-export.tsx index 576c7ff8..6218cfcb 100644 --- a/src/app/features/import-export/widgets/sidebar-import-export.tsx +++ b/src/app/features/import-export/widgets/sidebar-import-export.tsx @@ -1,8 +1,14 @@ import { useRef, useState } from "react" import { Group, co } from "jazz-tools" -import { createImage } from "jazz-tools/media" -import { Document, Asset, ImageAsset, VideoAsset } from "@/schema" -import { compressVideo, canEncodeVideo } from "@/app/features/assets" +import { Document, Asset } from "@/schema" +import { + assetContentResolve, + classifyAssetFile, + compressVideo, + canEncodeVideo, + createAssetFromFile, + serializeAsset, +} from "@/app/features/assets" import { getPath } from "@/app/features/editor" import { Button } from "@/app/components/ui/button" import { @@ -229,12 +235,12 @@ async function handleImportFiles( if (signal?.aborted) throw new DOMException("Aborted", "AbortError") let importedAsset = importedAssets[assetIndex] - let isVideo = importedAsset.file.type.startsWith("video/") - let asset: co.loaded - - if (isVideo) { + let kind = classifyAssetFile(importedAsset.file) + if (!kind) + throw new Error(`Unsupported asset: ${importedAsset.file.name}`) + let blob: Blob = importedAsset.file + if (kind === "video") { // Compress video if supported, otherwise use original - let videoBlob: Blob = importedAsset.file if (await canEncodeVideo()) { onProgress?.({ phase: "compressing", @@ -246,7 +252,7 @@ async function handleImportFiles( compressionProgress: 0, }) try { - videoBlob = await compressVideo(importedAsset.file, { + blob = await compressVideo(importedAsset.file, { onProgress: p => onProgress?.({ phase: "compressing", @@ -264,29 +270,16 @@ async function handleImportFiles( if (signal?.aborted) throw new DOMException("Aborted", "AbortError") } } - let video = await co.fileStream().createFromBlob(videoBlob, { - owner: docGroup, - }) - asset = VideoAsset.create( - { - type: "video", - name: importedAsset.name, - video, - mimeType: "video/mp4", - createdAt: now, - }, - docGroup, - ) - } else { - let image = await createImage(importedAsset.file, { - owner: docGroup, - maxSize: 2048, - }) - asset = ImageAsset.create( - { type: "image", name: importedAsset.name, image, createdAt: now }, - docGroup, - ) } + let asset = await createAssetFromFile( + { + blob, + fileName: importedAsset.file.name, + name: importedAsset.name, + createdAt: now, + }, + docGroup, + ) docAssets.push(asset) if (importedAsset.refName) { @@ -408,7 +401,11 @@ async function loadDocumentAssets( doc: co.loaded, ): Promise { let loaded = await doc.$jazz.ensureLoaded({ - resolve: { assets: { $each: { image: true, video: true } } }, + resolve: { + assets: { + $each: assetContentResolve, + }, + }, }) let docAssets: ExportAsset[] = [] @@ -416,19 +413,8 @@ async function loadDocumentAssets( for (let asset of Array.from(loaded.assets)) { if (!asset?.$isLoaded) continue - if (asset.type === "image" && asset.image?.$isLoaded) { - let original = asset.image.original - if (!original?.$isLoaded) continue - let blob = original.toBlob() - if (blob) { - docAssets.push({ id: asset.$jazz.id, name: asset.name, blob }) - } - } else if (asset.type === "video" && asset.video?.$isLoaded) { - let blob = asset.video.toBlob() - if (blob) { - docAssets.push({ id: asset.$jazz.id, name: asset.name, blob }) - } - } + let blob = await serializeAsset(asset) + if (blob) docAssets.push({ id: asset.$jazz.id, name: asset.name, blob }) } } diff --git a/src/app/features/presentation/screens/slideshow-screen.tsx b/src/app/features/presentation/screens/slideshow-screen.tsx index 9f6079cf..0ef18f0d 100644 --- a/src/app/features/presentation/screens/slideshow-screen.tsx +++ b/src/app/features/presentation/screens/slideshow-screen.tsx @@ -16,13 +16,16 @@ import { canEdit } from "@/app/features/sharing" import { Slideshow, type Slide } from "../widgets/slideshow" import { parsePresentation, type PresentationItem } from "../lib/presentation" import { useScreenWakeLock } from "../lib/screen-wake-lock" +import { assetPreviewResolve } from "@/app/features/assets" export { SlideshowScreen, resolve, loadWikilinkCache } export type { LoaderData } let resolve = { content: true, - assets: { $each: { image: true, video: true } }, + assets: { + $each: assetPreviewResolve, + }, } as const satisfies ResolveQuery type LoadedDoc = co.loaded diff --git a/src/app/features/presentation/widgets/slideshow.tsx b/src/app/features/presentation/widgets/slideshow.tsx index 608e6abf..1fee82dc 100644 --- a/src/app/features/presentation/widgets/slideshow.tsx +++ b/src/app/features/presentation/widgets/slideshow.tsx @@ -82,10 +82,14 @@ let ContentContext = createContext("") type Asset = { $jazz: { id: string } $isLoaded?: boolean - type?: "image" | "video" + type?: "image" | "video" | "tldraw" image?: { $jazz: { id: string } } video?: { $isLoaded?: boolean; toBlob?: () => Blob | undefined } muteAudio?: boolean + revision?: { + lightPreview?: { $jazz: { id: string } } + darkPreview?: { $jazz: { id: string } } + } } let AssetContext = createContext(undefined) @@ -608,6 +612,7 @@ function ScaledSlideContainer({ fontSet?.removeEventListener?.("loadingerror", handleFontsDone) } }, [ + blocks, blocksKey, isPortrait, baseSize, @@ -878,6 +883,7 @@ function SlideContentItem({ item }: { item: SlideContent }) { function SlideImage({ src, alt }: { src: string; alt: string }) { let assets = useContext(AssetContext) + let colorScheme = useResolvedTheme() let assetMatch = src.match(/^asset:(.+)$/) if (assetMatch) { @@ -895,6 +901,23 @@ function SlideImage({ src, alt }: { src: string; alt: string }) { ) } + if (asset?.$isLoaded && asset.type === "tldraw") { + let preview = + colorScheme === "dark" + ? asset.revision?.darkPreview + : asset.revision?.lightPreview + if (preview) { + return ( + + ) + } + } + if (asset?.$isLoaded && asset.video) { return } diff --git a/src/app/features/time-machine/lib/time-machine.test.ts b/src/app/features/time-machine/lib/time-machine.test.ts index 88145197..5eceabfe 100644 --- a/src/app/features/time-machine/lib/time-machine.test.ts +++ b/src/app/features/time-machine/lib/time-machine.test.ts @@ -1,14 +1,21 @@ import { beforeEach, describe, expect, test } from "vitest" import { createJazzTestAccount, setupJazzTestSync } from "jazz-tools/testing" -import { Document, UserAccount } from "@/schema" +import { co } from "jazz-tools" +import { + Document, + Asset, + TldrawAsset, + TldrawRevision, + UserAccount, +} from "@/schema" import { createPersonalDocument } from "@/app/features/documents/lib/documents" import { getEditHistory, getContentAtEdit, accountIdFromSessionId, formatEditDate, + restoreAssetsAtTime, } from "./time-machine" -import type { co } from "jazz-tools" describe("Time Machine - Edit History", () => { let adminAccount: co.loaded @@ -184,6 +191,73 @@ describe("Time Machine - Edit History", () => { expect(middleContent.length).toBeGreaterThan(0) }) + test("restores the tldraw revision active at the selected time", async () => { + let doc = await createPersonalDocument(adminAccount, "Whiteboard") + let firstRevision = await createTldrawTestRevision(adminAccount, "first") + let asset = TldrawAsset.create( + { + version: 1, + type: "tldraw", + name: "Diagram", + revision: firstRevision, + createdAt: new Date(), + }, + adminAccount, + ) + doc.$jazz.set("assets", co.list(Asset).create([], adminAccount)) + doc.assets!.$jazz.push(asset) + + await new Promise(resolve => setTimeout(resolve, 20)) + let firstRevisionTime = Date.now() + await new Promise(resolve => setTimeout(resolve, 20)) + let secondRevision = await createTldrawTestRevision(adminAccount, "second") + asset.$jazz.set("revision", secondRevision) + + let loaded = await Document.load(doc.$jazz.id, { + resolve: { content: true, assets: true }, + }) + if (!loaded.$isLoaded) throw new Error("Doc not loaded") + + await restoreAssetsAtTime(loaded, firstRevisionTime) + + let restored = loaded.assets?.[0] + expect(restored?.$isLoaded && restored.type).toBe("tldraw") + if (!restored?.$isLoaded || restored.type !== "tldraw") return + expect(restored.revision?.$jazz.id).toBe(firstRevision.$jazz.id) + }) + + test("does not partially restore when a historical asset is unavailable", async () => { + let doc = await createPersonalDocument(adminAccount, "Whiteboard") + let revision = await createTldrawTestRevision(adminAccount, "deleted") + let asset = TldrawAsset.create( + { + version: 1, + type: "tldraw", + name: "Deleted diagram", + revision, + createdAt: new Date(), + }, + adminAccount, + ) + doc.$jazz.set("assets", co.list(Asset).create([asset], adminAccount)) + + await new Promise(resolve => setTimeout(resolve, 20)) + let historicalTimestamp = Date.now() + await new Promise(resolve => setTimeout(resolve, 20)) + doc.assets!.$jazz.splice(0, 1) + asset.$jazz.raw.core.deleteCoValue() + + let loaded = await Document.load(doc.$jazz.id, { + resolve: { content: true, assets: true }, + }) + if (!loaded.$isLoaded) throw new Error("Doc not loaded") + + await expect( + restoreAssetsAtTime(loaded, historicalTimestamp), + ).rejects.toThrow("Historical asset is unavailable") + expect(loaded.assets).toHaveLength(0) + }) + test("getEditHistory maintains O(n) complexity", async () => { let doc = await createPersonalDocument(adminAccount, "Initial") @@ -217,6 +291,53 @@ describe("Time Machine - Edit History", () => { }) }) +async function createTldrawTestRevision( + owner: co.loaded, + content: string, +) { + let snapshot = await co + .fileStream() + .createFromBlob(new ReadableBlob(content, "application/vnd.tldraw+json"), { + owner, + }) + let original = await co.fileStream().createFromBlob(new ReadableBlob(""), { + owner, + }) + let preview = co.image().create( + { + original, + originalSize: [1, 1], + progressive: false, + }, + owner, + ) + return TldrawRevision.create( + { + snapshot, + lightPreview: preview, + darkPreview: preview, + createdAt: new Date(), + }, + owner, + ) +} + +class ReadableBlob extends Blob { + readonly content: string + + constructor(content: string, type = "application/octet-stream") { + super([content], { type }) + this.content = content + } + + async arrayBuffer() { + let bytes = new TextEncoder().encode(this.content) + let buffer = new ArrayBuffer(bytes.byteLength) + new Uint8Array(buffer).set(bytes) + return buffer + } +} + describe("Time Machine - Offline Support", () => { let adminAccount: co.loaded diff --git a/src/app/features/time-machine/lib/time-machine.ts b/src/app/features/time-machine/lib/time-machine.ts index 1869e4ff..0879c65e 100644 --- a/src/app/features/time-machine/lib/time-machine.ts +++ b/src/app/features/time-machine/lib/time-machine.ts @@ -1,9 +1,13 @@ import type { co } from "jazz-tools" import type { Document, UserAccount } from "@/schema" +import { Asset, TldrawRevision } from "@/schema" export { getEditHistory, getContentAtEdit, + getEditTimestamp, + getHistoricalAssetIds, + restoreAssetsAtTime, formatEditDate, getAuthorName, accountIdFromSessionId, @@ -67,6 +71,9 @@ function getEditHistory(doc: LoadedDocument): EditHistoryItem[] { if (doc.assets?.$isLoaded) { collectTransactions(doc.assets.$jazz.raw.core) + for (let asset of doc.assets.values()) { + if (asset?.$isLoaded) collectTransactions(asset.$jazz.raw.core) + } } if (doc.comments?.$isLoaded) { @@ -120,6 +127,84 @@ function getContentAtEdit(doc: LoadedDocument, editIndex: number): string { return doc.content.$jazz.raw.atTime(edit.madeAt.getTime()).toString() } +function getEditTimestamp(doc: LoadedDocument, editIndex: number): number { + let edits = getEditHistory(doc) + return edits[editIndex]?.madeAt.getTime() ?? Date.now() +} + +function getHistoricalAssetIds( + doc: LoadedDocument, + timestamp: number, +): string[] { + if (!doc.assets?.$isLoaded) return [] + let items = doc.assets.$jazz.raw.atTime(timestamp).toJSON() + return items.filter(item => typeof item === "string") +} + +async function restoreAssetsAtTime(doc: LoadedDocument, timestamp: number) { + if (!doc.assets?.$isLoaded) return + + let assetIds = getHistoricalAssetIds(doc, timestamp) + let assets = await Promise.all( + assetIds.map(assetId => + Asset.load(assetId, { + resolve: { + image: true, + video: true, + revision: true, + }, + }), + ), + ) + let loadedAssets = assets.map(asset => { + if (!asset?.$isLoaded) throw new Error("Historical asset is unavailable") + return asset + }) + let restorations = await Promise.all( + loadedAssets.map(async asset => { + let historical = asset.$jazz.raw.atTime(timestamp) + let historicalName = historical.get("name") + let name = + typeof historicalName === "string" ? historicalName : asset.name + if (asset.type !== "tldraw") { + return { asset, name, revision: undefined } + } + let revisionId = historical.get("revision") + if (typeof revisionId !== "string") { + throw new Error("Historical whiteboard revision is unavailable") + } + let revision = await TldrawRevision.load(revisionId, { + resolve: { + snapshot: true, + lightPreview: true, + darkPreview: true, + }, + }) + if (!revision?.$isLoaded) { + throw new Error("Historical whiteboard revision is unavailable") + } + return { asset, name, revision } + }), + ) + + for (let { asset, name, revision } of restorations) { + if (asset.type === "image" && name !== asset.name) { + asset.$jazz.set("name", name) + } + if (asset.type === "video" && name !== asset.name) { + asset.$jazz.set("name", name) + } + if (asset.type === "tldraw" && name !== asset.name) { + asset.$jazz.set("name", name) + } + if (asset.type === "tldraw" && revision) { + asset.$jazz.set("revision", revision) + } + } + + doc.assets.$jazz.splice(0, doc.assets.length, ...loadedAssets) +} + function formatEditDate(date: Date): string { return date.toLocaleString(undefined, { month: "short", @@ -184,6 +269,10 @@ function getTransactionCount(doc: LoadedDocument) { if (doc.assets?.$isLoaded) { count += doc.assets.$jazz.raw.core.getValidSortedTransactions().length + for (let asset of doc.assets.values()) { + if (!asset?.$isLoaded) continue + count += asset.$jazz.raw.core.getValidSortedTransactions().length + } } if (!doc.comments?.$isLoaded) return count diff --git a/src/app/features/time-machine/screens/time-machine-screen.tsx b/src/app/features/time-machine/screens/time-machine-screen.tsx index 52df2362..964adeb4 100644 --- a/src/app/features/time-machine/screens/time-machine-screen.tsx +++ b/src/app/features/time-machine/screens/time-machine-screen.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from "react" import { useNavigate } from "@tanstack/react-router" import { co, Group, type ResolveQuery } from "jazz-tools" -import { createImage } from "jazz-tools/media" import { useCoState, useAccount } from "jazz-tools/react" import { Slider as SliderPrimitive } from "@base-ui/react/slider" import { @@ -12,7 +11,7 @@ import { ChevronDown, } from "lucide-react" import { toast } from "sonner" -import { Document, UserAccount, Asset, ImageAsset, Space } from "@/schema" +import { Document, UserAccount, Asset, TldrawRevision, Space } from "@/schema" import { MarkdownEditor, useMarkdownEditorRef } from "@/app/features/editor" import { useEditorSettings } from "@/app/features/editor" import { @@ -47,6 +46,9 @@ import { import { getEditHistory, getContentAtEdit, + getEditTimestamp, + restoreAssetsAtTime, + getHistoricalAssetIds, getAuthorName, formatEditDate, groupEditsByDay, @@ -67,6 +69,12 @@ import { import { Kbd } from "@/app/components/ui/kbd" import { cn } from "@/app/lib/cn" import { useIntl } from "@/shared/intl/setup" +import { useResolvedTheme } from "@/app/components/appearance" +import { + assetContentResolve, + assetPreviewResolve, + copyAsset, +} from "@/app/features/assets" export { TimeMachineScreen, resolve, settingsResolve } export type { ViewMode } @@ -75,7 +83,9 @@ type ViewMode = "days" | "edits" let resolve = { content: true, - assets: { $each: { image: true } }, + assets: { + $each: assetContentResolve, + }, comments: { $each: { replies: true } }, } as const satisfies ResolveQuery @@ -165,6 +175,7 @@ function TimeMachineContent({ let navigate = useNavigate() let editor = useMarkdownEditorRef() let restoreDialog = useConfirmDialog() + let colorScheme = useResolvedTheme() let me = useAccount(UserAccount, { resolve: meResolve }) @@ -189,6 +200,12 @@ function TimeMachineContent({ let currentEdit = editHistory[currentEditIndex] let timeMachineContent = getContentAtEdit(doc, currentEditIndex) + let historicalTimestamp = getEditTimestamp(doc, currentEditIndex) + let historicalAssets = useHistoricalEditorAssets( + doc, + historicalTimestamp, + colorScheme, + ) let viewMode: ViewMode = initialMode ?? "days" let selectedDayIndex: number | null = null @@ -348,7 +365,7 @@ function TimeMachineContent({ onCursorChange={() => {}} placeholder="" readOnly={true} - assets={[]} + assets={historicalAssets} documents={[]} extensions={[]} /> @@ -370,6 +387,7 @@ function TimeMachineContent({ historicalContent: timeMachineContent, originalTitle: docTitle, editDate: currentEdit?.madeAt ?? doc.createdAt, + historicalTimestamp, me, navigate, })} @@ -387,8 +405,10 @@ function TimeMachineContent({ onConfirm={makeTimeMachineRestore({ doc, historicalContent: timeMachineContent, + historicalTimestamp, navigate, docId, + restoreFailedMessage: t("timeMachine.restoreFailed"), })} /> Blob | undefined } + muteAudio?: boolean +} + +function useHistoricalEditorAssets( + doc: LoadedDocument, + timestamp: number, + colorScheme: "light" | "dark", +) { + let [assets, setAssets] = useState([]) + + useEffect(() => { + let cancelled = false + void loadHistoricalEditorAssets(doc, timestamp, colorScheme).then( + result => { + if (!cancelled) setAssets(result) + }, + ) + return () => { + cancelled = true + } + }, [doc, timestamp, colorScheme]) + + return assets +} + +async function loadHistoricalEditorAssets( + doc: LoadedDocument, + timestamp: number, + colorScheme: "light" | "dark", +): Promise { + let assetIds = getHistoricalAssetIds(doc, timestamp) + let loaded = await Promise.all( + assetIds.map(assetId => + Asset.load(assetId, { + resolve: assetPreviewResolve, + }), + ), + ) + let result: HistoricalEditorAsset[] = [] + + for (let asset of loaded) { + if (!asset?.$isLoaded) continue + let historical = asset.$jazz.raw.atTime(timestamp) + let historicalName = historical.get("name") + let name = typeof historicalName === "string" ? historicalName : asset.name + + if (asset.type === "image") { + let imageId = historical.get("image") + result.push({ + id: asset.$jazz.id, + name, + type: "image", + imageId: typeof imageId === "string" ? imageId : undefined, + }) + continue + } + if (asset.type === "video") { + result.push({ + id: asset.$jazz.id, + name, + type: "video", + video: asset.video?.$isLoaded ? asset.video : undefined, + muteAudio: asset.muteAudio, + }) + continue + } + + let revisionId = historical.get("revision") + if (typeof revisionId !== "string") continue + let revision = await TldrawRevision.load(revisionId, { + resolve: { lightPreview: true, darkPreview: true }, + }) + if (!revision?.$isLoaded) continue + let preview = + colorScheme === "dark" ? revision.darkPreview : revision.lightPreview + result.push({ + id: asset.$jazz.id, + name, + type: "tldraw", + imageId: preview?.$isLoaded ? preview.$jazz.id : undefined, + }) + } + + return result +} + type TimeMachineCopyParams = { doc: LoadedDocument historicalContent: string originalTitle: string editDate: Date + historicalTimestamp: number me: LoadedMe navigate: ReturnType } @@ -415,8 +528,15 @@ type LoadedSpace = co.loaded function makeTimeMachineCreateCopy(params: TimeMachineCopyParams) { return async function handleTimeMachineCreateCopy() { - let { doc, historicalContent, originalTitle, editDate, me, navigate } = - params + let { + doc, + historicalContent, + originalTitle, + editDate, + historicalTimestamp, + me, + navigate, + } = params if (!me.$isLoaded || !me.root?.documents?.$isLoaded) return let owner: Group @@ -442,38 +562,33 @@ function makeTimeMachineCreateCopy(params: TimeMachineCopyParams) { let assetIdMap = new Map() let newAssets = co.list(Asset).create([], owner) - let assets = doc.assets ?? [] - - for (let asset of [...assets]) { - if ( - !asset?.$isLoaded || - asset.type !== "image" || - !asset.image?.$isLoaded - ) - continue - - let original = asset.image.original - if (!original?.$isLoaded) continue + let assetIds = getHistoricalAssetIds(doc, historicalTimestamp) + let assets = await Promise.all( + assetIds.map(assetId => + Asset.load(assetId, { + resolve: assetContentResolve, + }), + ), + ) - let blob = original.toBlob() - if (!blob) continue + for (let asset of assets) { + if (!asset?.$isLoaded) continue + let historical = asset.$jazz.raw.atTime(historicalTimestamp) + let historicalName = historical.get("name") + let name = + typeof historicalName === "string" ? historicalName : asset.name try { - let newImage = await createImage(blob, { - owner, - maxSize: 2048, + let revision = + asset.type === "tldraw" + ? await loadHistoricalTldrawRevision(asset, historicalTimestamp) + : undefined + if (asset.type === "tldraw" && !revision) continue + let newAsset = await copyAsset(asset, owner, { + name, + tldrawRevision: revision, }) - let newAsset = ImageAsset.create( - { - type: "image", - name: asset.name, - image: newImage, - createdAt: new Date(), - }, - owner, - ) - newAssets.$jazz.push(newAsset) assetIdMap.set(asset.$jazz.id, newAsset.$jazz.id) } catch (err) { @@ -535,22 +650,48 @@ function makeTimeMachineCreateCopy(params: TimeMachineCopyParams) { } } +async function loadHistoricalTldrawRevision( + asset: co.loaded, + timestamp: number, +) { + let revisionId = asset.$jazz.raw.atTime(timestamp).get("revision") + if (typeof revisionId !== "string") return undefined + let revision = await TldrawRevision.load(revisionId, { + resolve: { + snapshot: true, + lightPreview: true, + darkPreview: true, + }, + }) + return revision?.$isLoaded ? revision : undefined +} + type TimeMachineRestoreParams = { doc: LoadedDocument historicalContent: string + historicalTimestamp: number navigate: ReturnType docId: string + restoreFailedMessage: string } function makeTimeMachineRestore(params: TimeMachineRestoreParams) { - return function handleTimeMachineRestore() { - let { doc, historicalContent, navigate, docId } = params + return async function handleTimeMachineRestore() { + let { doc, historicalContent, historicalTimestamp, navigate, docId } = + params if (!doc.content) return if (!canEdit(doc)) return - applyContentDiffWithCommentAnchors(doc, historicalContent) - doc.$jazz.set("updatedAt", new Date()) - syncDocumentMetadata(doc) + try { + await restoreAssetsAtTime(doc, historicalTimestamp) + applyContentDiffWithCommentAnchors(doc, historicalContent) + doc.$jazz.set("updatedAt", new Date()) + syncDocumentMetadata(doc) + } catch (error) { + console.error("Failed to restore document version:", error) + toast.error(params.restoreFailedMessage) + return + } navigate({ to: "/doc/$id", diff --git a/src/schema/index.ts b/src/schema/index.ts index e7d62d74..f17be33b 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -5,7 +5,13 @@ import { Document } from "@/app/features/documents/lib/schema" import { Space } from "@/app/features/spaces/lib/schema" import { runAccountMigration } from "@/schema/migrations" -export { ImageAsset, VideoAsset, Asset } from "@/app/features/assets/lib/schema" +export { + ImageAsset, + VideoAsset, + TldrawAsset, + TldrawRevision, + Asset, +} from "@/app/features/assets/lib/schema" export { Space } from "@/app/features/spaces/lib/schema" export { createSpace } from "@/app/features/spaces/lib/create-space" diff --git a/src/shared/intl/messages.common.ts b/src/shared/intl/messages.common.ts index a1fe053e..03ad7c56 100644 --- a/src/shared/intl/messages.common.ts +++ b/src/shared/intl/messages.common.ts @@ -25,6 +25,28 @@ let baseCommonMessages = messages({ "assets.save": "Save", "assets.nameRequired": "Name is required", "assets.nameTooLong": "Name too long", + "assets.newWhiteboard": "New whiteboard", + "assets.editWhiteboard": "Edit whiteboard", + "assets.uploadOrImport": "Upload or import", + "assets.whiteboard": "Whiteboard", + "assets.importOneWhiteboard": "Import one whiteboard at a time", + "assets.multiplePagesUnsupported": "This tldraw file has multiple pages", + "assets.embeddedMediaUnsupported": + "Embedded images and media are not supported yet", + "assets.invalidTldraw": "This is not a valid tldraw file", + "assets.whiteboardEditorDescription": + "Edit the whiteboard, then save or cancel your changes.", + "assets.whiteboardLoading": "Whiteboard is still loading", + "assets.addBeforeSaving": "Add something to the whiteboard before saving", + "assets.whiteboardSaved": "Whiteboard saved", + "assets.whiteboardAdded": "Whiteboard added", + "assets.whiteboardSaveFailed": "Could not save the whiteboard", + "assets.discardWhiteboardTitle": "Discard whiteboard changes?", + "assets.discardWhiteboardDescription": + "Your changes since opening the whiteboard will be lost.", + "assets.discard": "Discard", + "assets.saving": "Saving…", + "assets.import": "Import", "common.goHome": "Go Home", "common.goBack": "Go back", @@ -161,6 +183,30 @@ let deCommonMessages = translate(baseCommonMessages, { "assets.save": "Speichern", "assets.nameRequired": "Name erforderlich", "assets.nameTooLong": "Name zu lang", + "assets.newWhiteboard": "Neues Whiteboard", + "assets.editWhiteboard": "Whiteboard bearbeiten", + "assets.uploadOrImport": "Hochladen oder importieren", + "assets.whiteboard": "Whiteboard", + "assets.importOneWhiteboard": "Importiere jeweils nur ein Whiteboard", + "assets.multiplePagesUnsupported": + "Diese tldraw-Datei enthält mehrere Seiten", + "assets.embeddedMediaUnsupported": + "Eingebettete Bilder und Medien werden noch nicht unterstützt", + "assets.invalidTldraw": "Dies ist keine gültige tldraw-Datei", + "assets.whiteboardEditorDescription": + "Bearbeite das Whiteboard und speichere oder verwirf anschließend deine Änderungen.", + "assets.whiteboardLoading": "Das Whiteboard wird noch geladen", + "assets.addBeforeSaving": "Füge vor dem Speichern etwas zum Whiteboard hinzu", + "assets.whiteboardSaved": "Whiteboard gespeichert", + "assets.whiteboardAdded": "Whiteboard hinzugefügt", + "assets.whiteboardSaveFailed": + "Das Whiteboard konnte nicht gespeichert werden", + "assets.discardWhiteboardTitle": "Whiteboard-Änderungen verwerfen?", + "assets.discardWhiteboardDescription": + "Deine Änderungen seit dem Öffnen des Whiteboards gehen verloren.", + "assets.discard": "Verwerfen", + "assets.saving": "Speichern…", + "assets.import": "Importieren", "common.goHome": "Zur Startseite", "common.goBack": "Zurück", diff --git a/src/shared/intl/messages.documents.ts b/src/shared/intl/messages.documents.ts index 4d55a589..215c247f 100644 --- a/src/shared/intl/messages.documents.ts +++ b/src/shared/intl/messages.documents.ts @@ -189,6 +189,7 @@ let baseDocumentsMessages = messages({ "timeMachine.clampedDescription": "Edit {$edit} doesn't exist. Showing closest available version.", "timeMachine.failedToCopyAsset": "Failed to copy asset: {$name}", + "timeMachine.restoreFailed": "Could not restore this version", }) let deDocumentsMessages = translate(baseDocumentsMessages, { @@ -383,4 +384,6 @@ let deDocumentsMessages = translate(baseDocumentsMessages, { "timeMachine.clampedDescription": "Bearbeitung {$edit} existiert nicht. Zeige die nächste verfügbare Version.", "timeMachine.failedToCopyAsset": "Fehler beim Kopieren des Assets: {$name}", + "timeMachine.restoreFailed": + "Diese Version konnte nicht wiederhergestellt werden", }) diff --git a/src/shared/intl/messages.editor.ts b/src/shared/intl/messages.editor.ts index fe2f27cd..78c432f2 100644 --- a/src/shared/intl/messages.editor.ts +++ b/src/shared/intl/messages.editor.ts @@ -82,19 +82,29 @@ let baseEditorMessages = messages({ "editor.floating.changeLinkedDoc": "Change linked doc", "editor.floating.removeLink": "Remove link", "editor.floating.selectMedia": "Select media", + "editor.floating.addAsset": "Add media or whiteboard", + "editor.floating.new": "New", + "editor.floating.select": "Select", "editor.dialog.linkToDocument": "Link to document", "editor.dialog.searchDocuments": "Search for a document to link to", "editor.dialog.noDocumentsFound": "No documents found", "editor.dialog.changeLinkedDocument": "Change linked document", "editor.dialog.selectMedia": "Select media", + "editor.dialog.media": "Media", + "editor.dialog.selectWhiteboard": "Select whiteboard", + "editor.dialog.searchWhiteboards": "Search your existing whiteboards", + "editor.dialog.noWhiteboardsFound": "No whiteboards found", + "editor.dialog.searchWhiteboardsPlaceholder": "Search whiteboards...", "editor.dialog.searchMedia": "Search for an existing image or video", "editor.dialog.noMediaFound": "No media found", - "editor.dialog.upload": "Upload", "editor.dialog.cancel": "Cancel", + "editor.dialog.close": "Close", "editor.media.notAvailable": "Media not available", "editor.media.loadingVideo": "Loading video...", + "editor.media.expandPreview": "Expand preview", + "editor.media.collapsePreview": "Collapse preview", "editor.dialog.searchDocumentsPlaceholder": "Search documents...", "editor.dialog.searchMediaPlaceholder": "Search media...", @@ -187,19 +197,29 @@ let deEditorMessages = translate(baseEditorMessages, { "editor.floating.changeLinkedDoc": "Verlinktes Dokument ändern", "editor.floating.removeLink": "Link entfernen", "editor.floating.selectMedia": "Media auswählen", + "editor.floating.addAsset": "Media oder Whiteboard hinzufügen", + "editor.floating.new": "Neu", + "editor.floating.select": "Auswählen", "editor.dialog.linkToDocument": "Mit Dokument verlinken", "editor.dialog.searchDocuments": "Suche nach einem Dokument zum Verlinken", "editor.dialog.noDocumentsFound": "Keine Dokumente gefunden", "editor.dialog.changeLinkedDocument": "Verlinktes Dokument ändern", "editor.dialog.selectMedia": "Media auswählen", + "editor.dialog.media": "Media", + "editor.dialog.selectWhiteboard": "Whiteboard auswählen", + "editor.dialog.searchWhiteboards": "Vorhandene Whiteboards durchsuchen", + "editor.dialog.noWhiteboardsFound": "Keine Whiteboards gefunden", + "editor.dialog.searchWhiteboardsPlaceholder": "Whiteboards durchsuchen...", "editor.dialog.searchMedia": "Suche nach einem existierenden Bild oder Video", "editor.dialog.noMediaFound": "Keine Media gefunden", - "editor.dialog.upload": "Hochladen", "editor.dialog.cancel": "Abbrechen", + "editor.dialog.close": "Schließen", "editor.media.notAvailable": "Media nicht verfügbar", "editor.media.loadingVideo": "Lade Video...", + "editor.media.expandPreview": "Vorschau vergrößern", + "editor.media.collapsePreview": "Vorschau verkleinern", "editor.dialog.searchDocumentsPlaceholder": "Dokumente suchen...", "editor.dialog.searchMediaPlaceholder": "Medien suchen...",