diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 624f19a..1452456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1279,8 +1279,8 @@ importers: specifier: 0.2.2 version: 0.2.2(@automerge/automerge-repo-react-hooks@2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0))(react@18.3.1) '@tldraw/tldraw': - specifier: 4.3.1 - version: 4.3.1(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 4.5.12 + version: 4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/lodash': specifier: ^4.17.20 version: 4.17.24 @@ -1293,9 +1293,106 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - tldraw: - specifier: ^4.3.1 - version: 4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + vite-plugin-wasm: + specifier: ^3.5.0 + version: 3.6.0(vite@7.3.6(@types/node@24.13.3)) + devDependencies: + '@automerge/automerge': + specifier: ^3.2.5 + version: 3.3.2 + '@automerge/automerge-repo': + specifier: 2.6.0-subduction.46 + version: 2.6.0-subduction.46(supports-color@7.2.0) + '@eslint/js': + specifier: ^9.36.0 + version: 9.39.5 + '@types/node': + specifier: ^24.6.0 + version: 24.13.3 + '@types/react': + specifier: ^18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^5.0.4 + version: 5.2.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.13.3)) + babel-plugin-react-compiler: + specifier: 19.1.0-rc.3 + version: 19.1.0-rc.3 + esbuild: + specifier: ^0.23.1 + version: 0.23.1 + eslint: + specifier: ^9.36.0 + version: 9.39.5(supports-color@7.2.0) + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.39.5(supports-color@7.2.0)) + eslint-plugin-react-refresh: + specifier: ^0.4.22 + version: 0.4.26(eslint@9.39.5(supports-color@7.2.0)) + globals: + specifier: ^16.4.0 + version: 16.5.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.45.0 + version: 8.65.0(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + + tldraw5: + dependencies: + '@automerge/automerge-repo-react-hooks': + specifier: 2.6.0-subduction.46 + version: 2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0) + '@automerge/react': + specifier: ^2.6.0-subduction.9 + version: 2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0) + '@inkandswitch/patchwork-bootloader': + specifier: ^0.2.8 + version: 0.2.8(@automerge/automerge-repo-keyhive@0.3.0-alpha.sub.1c(supports-color@7.2.0)(ws@8.21.1))(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0))(@automerge/automerge@3.3.2)(@automerge/vanillajs@2.6.0-subduction.29(supports-color@7.2.0))(@types/react@18.3.31)(react@18.3.1)(solid-js@1.9.14)(supports-color@7.2.0) + '@inkandswitch/patchwork-filesystem': + specifier: ^0.0.3 + version: 0.0.3(supports-color@7.2.0) + '@inkandswitch/patchwork-plugins': + specifier: ^0.0.5 + version: 0.0.5(supports-color@7.2.0) + '@inkandswitch/patchwork-providers': + specifier: 0.3.0 + version: 0.3.0(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0)) + '@inkandswitch/patchwork-providers-react': + specifier: 0.2.2 + version: 0.2.2(@automerge/automerge-repo-react-hooks@2.6.0-subduction.46(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@7.2.0))(@automerge/automerge-repo@2.6.0-subduction.46(supports-color@7.2.0))(react@18.3.1) + '@tldraw/state': + specifier: 5.2.5 + version: 5.2.5 + '@tldraw/tldraw': + specifier: 5.2.5 + version: 5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/tlschema': + specifier: 5.2.5 + version: 5.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/utils': + specifier: 5.2.5 + version: 5.2.5 + '@types/lodash': + specifier: ^4.17.20 + version: 4.17.24 + lodash: + specifier: ^4.17.21 + version: 4.18.1 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + sql.js: + specifier: ^1.14.1 + version: 1.14.1 vite-plugin-wasm: specifier: ^3.5.0 version: 3.6.0(vite@7.3.6(@types/node@24.13.3)) @@ -4045,11 +4142,9 @@ packages: '@tiptap/starter-kit@3.29.0': resolution: {integrity: sha512-J3jTp3/WXnnL58TCtdMsfeQ2BeycFYrAib6Nbpjd9G0OLUBE7AILiH2HrobRQpvRbAUgD5SW+sBZYOonbWWqNg==} - '@tldraw/editor@4.3.1': - resolution: {integrity: sha512-7qEhQjRAXrzBVMRQad6APCWOHWuid3LHozUHJ3lPBj9ua/5KG1g4ZE6uEluNTU7mx5o8TBrYdDEpgv5FpHWUaA==} - peerDependencies: - react: ^18.2.0 || ^19.2.1 - react-dom: ^18.2.0 || ^19.2.1 + '@tldraw/driver@5.2.5': + resolution: {integrity: sha512-smu4NnqTdIM59/hHTvuXxqJWQ5aLpFUR59ibS//o30P9pgMAoN4v/7NhYx9g+V7RAeOzIpDqREouz3hZdoEt9Q==} + engines: {node: '>=22.12.0'} '@tldraw/editor@4.5.12': resolution: {integrity: sha512-7t5goTeXsf/sH3+IPSJ3eKrkah5wfdRvHyXdAHFRPOrRgQwepCH7BWfzeWDxi26KDUF8wHEIp/yf6PLUWQFjyQ==} @@ -4057,8 +4152,9 @@ packages: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 - '@tldraw/state-react@4.3.1': - resolution: {integrity: sha512-rXddhMhWPP5LhzgrOMe0Na1N7Pbyv7lJqynL8Idv5HEp8xIXnFT0Qlu8LhhiJ3hyWji52O0dkoAPqJ/AZYb8PA==} + '@tldraw/editor@5.2.5': + resolution: {integrity: sha512-/2aIuOLik7+tIzdmnjZ5XuxPRfSzZnNXVbxqCiFIVgbwe1W4jwm2lr2K4EpGduzdnbTNNvh6nPDJGg/jamAvnA==} + engines: {node: '>=22.12.0'} peerDependencies: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 @@ -4069,30 +4165,40 @@ packages: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 - '@tldraw/state@4.3.1': - resolution: {integrity: sha512-5VtARGQ36cH/T9jMbQV93XDfpPH+WXPoLzZ11O3GHB2M8JQwE0Qoc88K7OZD4rlqe/TbkvbTWo/n1V8tfB3ODQ==} + '@tldraw/state-react@5.2.5': + resolution: {integrity: sha512-/DbTo2Oulg/ZlQwfqlyJq+rjpYBIdwl9iELB01ImQ3aDIfM5TEyabc65baj/eCW3UNlFwKVTRft9m5z4jlPbVw==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 '@tldraw/state@4.5.12': resolution: {integrity: sha512-DPt4rUI+m21FW4mQjYj+RCoZpriDwMravQSh8EPsyz5SimlWFiiv0mc7RaV2LMQXgvQvX3xtIyJsqaWSXpOO5g==} - '@tldraw/store@4.3.1': - resolution: {integrity: sha512-ytw3MGEe2+nC7CFRD5llCdgVi9W3TiTRNqdyqeEgzBXKTjpD81P3UPC5KJi2kXyhe1t6HWWV3Ld4gfPUICuNFg==} - peerDependencies: - react: ^18.2.0 || ^19.2.1 + '@tldraw/state@5.2.5': + resolution: {integrity: sha512-fTn6YG6+MMlc3Rshb0/9k2YQ5TnKjy9FLwiGD7ph5maj9quSdpWQJNRgYxk7o2tRd/m7j+NhgylIb/F+2iKwbw==} + engines: {node: '>=22.12.0'} '@tldraw/store@4.5.12': resolution: {integrity: sha512-k3iepU+DlB9A0U6sKHr3J1IIDOMohpLOLGnCZLFrGRDO8lDLF/GUtzVKkzBfzG3zoYWw6vqVxM76i8GQUgpTnw==} peerDependencies: react: ^18.2.0 || ^19.2.1 - '@tldraw/tldraw@4.3.1': - resolution: {integrity: sha512-2AknMcaAWhaEjNSbXmKaLVXOUfpTm9xnc0i3bLAjAMlzJNuS9VgIge1Nh2iBQMEPJfKPfpBaWyhlcgV3MhWyyA==} + '@tldraw/store@5.2.5': + resolution: {integrity: sha512-d3sWGoFH1j9jip71VdbVYQC5WLebn2ydo2n1BG3J+PhRhv7DGP9B1bojPUd9lEPBlB4VaS6/BmP4zyk/sLmAnw==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + + '@tldraw/tldraw@4.5.12': + resolution: {integrity: sha512-FtK2kSU4/sjeLMVxgpDUDE+kamsuxCYe4gQwDSrDsJobSMI+zkQp+E1BAUuAKxUCmwrFlN/Y1kIu2UjaHm6oRg==} peerDependencies: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 - '@tldraw/tlschema@4.3.1': - resolution: {integrity: sha512-+PDQsFkAdEd4BMyB5AGLZVECHQhXUyKLV08ihkZvMjsuTn2WfOrJvANHGkHhJ7XwGiyNj+ROhNaGmWnKPxOoXQ==} + '@tldraw/tldraw@5.2.5': + resolution: {integrity: sha512-U/qBggcmauwAzAi+sd3rGH3SJbwo+TN3cPEUq6h1SyU3T3NzhA2rPCKCn0FsZoCUrXnsC0ajENrL/ADA9Jj71g==} + engines: {node: '>=22.12.0'} peerDependencies: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 @@ -4103,18 +4209,27 @@ packages: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 - '@tldraw/utils@4.3.1': - resolution: {integrity: sha512-6Low8mIRzupdkNooPboZyBJ3lnJGcKHTMk5ZdNAOu+aL3ffnoP/+rstNoH5k4kFdyv+ScBSRZ8Oj97nHPwc2CQ==} + '@tldraw/tlschema@5.2.5': + resolution: {integrity: sha512-Oe9f1e0pcO6M42RVeBTAMqUXdWMJTeCFnM9DsBvJoY94ukIau5huUARAJ2mIV+oqUQS3pkDOrDz/L0KSN9+Eig==} + engines: {node: '>=22.12.0'} + peerDependencies: + react: ^18.2.0 || ^19.2.1 + react-dom: ^18.2.0 || ^19.2.1 '@tldraw/utils@4.5.12': resolution: {integrity: sha512-9g679nJFKVLwoAe1RASJCWt7C/0m+c0OWtKgd/o5VRfOvqc0BGrzd0eTm+wJlHbrH0RyNpSPasE2haErbuqIrA==} - '@tldraw/validate@4.3.1': - resolution: {integrity: sha512-kKrPheHfKlVDiokYArgShGn2HYHgSJ5af7oThPlnrACil3ZXcbcedUJcEoSY10uTnYJ9h/eKGrDX26VdSPUWPQ==} + '@tldraw/utils@5.2.5': + resolution: {integrity: sha512-8f2KqXVsPdXhZsGC2c1c+V19hpct7BQIcKnsq1b+zKUqDf7B/lNSHm/S63NeRhmsOTgzEIeOXVQYHX7OYXo2Xg==} + engines: {node: '>=22.12.0'} '@tldraw/validate@4.5.12': resolution: {integrity: sha512-LHGO0yHIe5A2Rzj+uL1Mo8NQJw2po6mdPrlvj3sJ7QT0Kx0A4+TYOUhrwTB32CimH17EpsdZ8eD04isO1KsRhw==} + '@tldraw/validate@5.2.5': + resolution: {integrity: sha512-FgFbMuKVdFUWyZFq0wr+ojZ/zeXlm4/Ed47duvYfDWoGkQ5r/j2q4vnfeB0MVyGHxGbvVMqDOgGbWPbCJoDspA==} + engines: {node: '>=22.12.0'} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4130,9 +4245,6 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/core-js@2.5.8': - resolution: {integrity: sha512-VgnAj6tIAhJhZdJ8/IpxdatM8G4OD3VWGlp6xIxUGENZlpbob9Ty4VVdC1FIEp0aK6DBscDDjyzy5FB60TuNqg==} - '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -4511,9 +4623,6 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - core-js@3.49.0: - resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} - crelt@1.0.7: resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} @@ -5341,6 +5450,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + sql.js@1.14.1: + resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -5388,14 +5500,15 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldraw@4.3.1: - resolution: {integrity: sha512-duKi/aHG/PlsHLsRBXPbhxQgLKzRN9E52AeP4GRg4ysQniQY6pk7qIm89Vevfi3yP5vF4koX8FpQp/J6Z1L9aQ==} + tldraw@4.5.12: + resolution: {integrity: sha512-n7AZFbQ8w4PyqIXQmyJbPXBKHcdwgRCgNqlU78tEMGkExxkIwKQ5p167o3DbKxyHyTmxihdu/DAeMx9uSeehMg==} peerDependencies: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 - tldraw@4.5.12: - resolution: {integrity: sha512-n7AZFbQ8w4PyqIXQmyJbPXBKHcdwgRCgNqlU78tEMGkExxkIwKQ5p167o3DbKxyHyTmxihdu/DAeMx9uSeehMg==} + tldraw@5.2.5: + resolution: {integrity: sha512-bTnOkeZhi+RgdHXPjxp0/nG6kbXzLC8ZlwNMpmGjWjT2Oel6uaJWni7PxFPIK4p2zInmnRamdU06ArDStLvPag==} + engines: {node: '>=22.12.0'} peerDependencies: react: ^18.2.0 || ^19.2.1 react-dom: ^18.2.0 || ^19.2.1 @@ -9716,30 +9829,16 @@ snapshots: '@tiptap/extensions': 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0) '@tiptap/pm': 3.29.0 - '@tldraw/editor@4.3.1(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tldraw/driver@5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tiptap/core': 3.29.0(@tiptap/pm@3.29.0) - '@tiptap/pm': 3.29.0 - '@tiptap/react': 3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tldraw/state': 4.3.1 - '@tldraw/state-react': 4.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tldraw/store': 4.3.1(react@18.3.1) - '@tldraw/tlschema': 4.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tldraw/utils': 4.3.1 - '@tldraw/validate': 4.3.1 - '@types/core-js': 2.5.8 - '@use-gesture/react': 10.3.1(react@18.3.1) - classnames: 2.5.1 - core-js: 3.49.0 - eventemitter3: 4.0.7 - idb: 7.1.1 - is-plain-object: 5.0.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + '@tldraw/editor': 5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/utils': 5.2.5 transitivePeerDependencies: - '@floating-ui/dom' - '@types/react' - '@types/react-dom' + - react + - react-dom '@tldraw/editor@4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -9765,12 +9864,28 @@ snapshots: - '@types/react' - '@types/react-dom' - '@tldraw/state-react@4.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tldraw/editor@5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tldraw/state': 4.3.1 - '@tldraw/utils': 4.3.1 + '@tiptap/core': 3.29.0(@tiptap/pm@3.29.0) + '@tiptap/pm': 3.29.0 + '@tiptap/react': 3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/state': 5.2.5 + '@tldraw/state-react': 5.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/store': 5.2.5(react@18.3.1) + '@tldraw/tlschema': 5.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/utils': 5.2.5 + '@tldraw/validate': 5.2.5 + classnames: 2.5.1 + eventemitter3: 4.0.7 + idb: 7.1.1 + is-plain-object: 5.0.0 + rbush: 3.0.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@types/react' + - '@types/react-dom' '@tldraw/state-react@4.5.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -9779,19 +9894,20 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tldraw/state@4.3.1': + '@tldraw/state-react@5.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tldraw/utils': 4.3.1 + '@tldraw/state': 5.2.5 + '@tldraw/utils': 5.2.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) '@tldraw/state@4.5.12': dependencies: '@tldraw/utils': 4.5.12 - '@tldraw/store@4.3.1(react@18.3.1)': + '@tldraw/state@5.2.5': dependencies: - '@tldraw/state': 4.3.1 - '@tldraw/utils': 4.3.1 - react: 18.3.1 + '@tldraw/utils': 5.2.5 '@tldraw/store@4.5.12(react@18.3.1)': dependencies: @@ -9799,24 +9915,31 @@ snapshots: '@tldraw/utils': 4.5.12 react: 18.3.1 - '@tldraw/tldraw@4.3.1(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tldraw/store@5.2.5(react@18.3.1)': + dependencies: + '@tldraw/state': 5.2.5 + '@tldraw/utils': 5.2.5 + react: 18.3.1 + + '@tldraw/tldraw@4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - tldraw: 4.3.1(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tldraw: 4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@floating-ui/dom' - '@types/react' - '@types/react-dom' - '@tldraw/tlschema@4.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@tldraw/tldraw@5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@tldraw/state': 4.3.1 - '@tldraw/store': 4.3.1(react@18.3.1) - '@tldraw/utils': 4.3.1 - '@tldraw/validate': 4.3.1 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + tldraw: 5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + transitivePeerDependencies: + - '@floating-ui/dom' + - '@types/react' + - '@types/react-dom' '@tldraw/tlschema@4.5.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -9827,7 +9950,16 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@tldraw/utils@4.3.1': + '@tldraw/tlschema@5.2.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tldraw/state': 5.2.5 + '@tldraw/store': 5.2.5(react@18.3.1) + '@tldraw/utils': 5.2.5 + '@tldraw/validate': 5.2.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tldraw/utils@4.5.12': dependencies: jittered-fractional-indexing: 1.0.1 lodash.isequal: 4.5.0 @@ -9835,22 +9967,21 @@ snapshots: lodash.throttle: 4.1.1 lodash.uniq: 4.5.0 - '@tldraw/utils@4.5.12': + '@tldraw/utils@5.2.5': dependencies: - jittered-fractional-indexing: 1.0.1 lodash.isequal: 4.5.0 lodash.isequalwith: 4.4.0 lodash.throttle: 4.1.1 lodash.uniq: 4.5.0 - '@tldraw/validate@4.3.1': - dependencies: - '@tldraw/utils': 4.3.1 - '@tldraw/validate@4.5.12': dependencies: '@tldraw/utils': 4.5.12 + '@tldraw/validate@5.2.5': + dependencies: + '@tldraw/utils': 5.2.5 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.4 @@ -9877,8 +10008,6 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/core-js@2.5.8': {} - '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -10361,8 +10490,6 @@ snapshots: convert-source-map@2.0.0: {} - core-js@3.49.0: {} - crelt@1.0.7: {} cross-spawn@7.0.6: @@ -11346,6 +11473,8 @@ snapshots: source-map-js@1.2.1: {} + sql.js@1.14.1: {} + stackback@0.0.2: {} std-env@4.2.0: {} @@ -11381,7 +11510,7 @@ snapshots: tinyrainbow@3.1.0: {} - tldraw@4.3.1(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + tldraw@4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@tiptap/core': 3.29.0(@tiptap/pm@3.29.0) '@tiptap/extension-code': 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0)) @@ -11390,8 +11519,8 @@ snapshots: '@tiptap/pm': 3.29.0 '@tiptap/react': 3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/starter-kit': 3.29.0 - '@tldraw/editor': 4.3.1(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tldraw/store': 4.3.1(react@18.3.1) + '@tldraw/editor': 4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/store': 4.5.12(react@18.3.1) classnames: 2.5.1 hotkeys-js: 3.13.15 idb: 7.1.1 @@ -11404,7 +11533,7 @@ snapshots: - '@types/react' - '@types/react-dom' - tldraw@4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + tldraw@5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@tiptap/core': 3.29.0(@tiptap/pm@3.29.0) '@tiptap/extension-code': 3.29.0(@tiptap/core@3.29.0(@tiptap/pm@3.29.0)) @@ -11413,10 +11542,10 @@ snapshots: '@tiptap/pm': 3.29.0 '@tiptap/react': 3.29.0(@floating-ui/dom@1.8.0)(@tiptap/core@3.29.0(@tiptap/pm@3.29.0))(@tiptap/pm@3.29.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/starter-kit': 3.29.0 - '@tldraw/editor': 4.5.12(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tldraw/store': 4.5.12(react@18.3.1) + '@tldraw/driver': 5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/editor': 5.2.5(@floating-ui/dom@1.8.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tldraw/store': 5.2.5(react@18.3.1) classnames: 2.5.1 - hotkeys-js: 3.13.15 idb: 7.1.1 lz-string: 1.5.0 radix-ui: 1.6.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/tldraw4/package.json b/tldraw4/package.json index fef190f..db5e605 100644 --- a/tldraw4/package.json +++ b/tldraw4/package.json @@ -34,12 +34,11 @@ "@inkandswitch/patchwork-plugins": "^0.0.5", "@inkandswitch/patchwork-providers": "0.3.0", "@inkandswitch/patchwork-providers-react": "0.2.2", - "@tldraw/tldraw": "4.3.1", + "@tldraw/tldraw": "4.5.12", "@types/lodash": "^4.17.20", "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", - "tldraw": "^4.3.1", "vite-plugin-wasm": "^3.5.0" }, "devDependencies": { diff --git a/tldraw4/src/lith/AutomergeToTLStore.ts b/tldraw4/src/lith/AutomergeToTLStore.ts index 9f656c0..9d62d17 100644 --- a/tldraw4/src/lith/AutomergeToTLStore.ts +++ b/tldraw4/src/lith/AutomergeToTLStore.ts @@ -1,127 +1,43 @@ -import type { TLRecord, RecordId, TLStore } from "@tldraw/tldraw"; +import type { TLRecord, TLStore } from "@tldraw/tldraw"; import * as Automerge from "@automerge/automerge/slim"; +/** + * Sync automerge doc changes into the tldraw store. + * + * The patches are only used to find out *which* records changed; the record + * contents are rebuilt wholesale from the doc. Replaying patch mechanics + * against store records would require modeling every patch shape automerge + * can emit (nested `del`, `splice`, `conflict`, ...) and any gap silently + * diverges the view from the doc until reload. Rebuilding from the doc makes + * the doc the single source of truth: after every change the store record is + * exactly what the doc says, regardless of what the patches looked like. + * tldraw records are small, so the per-record rebuild cost is negligible. + */ export function applyAutomergePatchesToTLStore( patches: Automerge.Patch[], - store: TLStore + store: TLStore, + doc: { store: Record } ) { - const toRemove: TLRecord["id"][] = []; - const updatedObjects: { [id: string]: TLRecord } = {}; - - patches.forEach((rawPatch) => { - let patch = rawPatch; - - if (!isStorePatch(patch)) return; - - const id = pathToId(patch.path.map((p) => `${p}`)); - const record = updatedObjects[id] || structuredClone(store.get(id) || {}); - - switch (patch.action) { - case "insert": { - updatedObjects[id] = applyInsertToObject(patch, record); - break; - } - case "put": - updatedObjects[id] = applyPutToObject(patch, record); - break; - case "splice": { - updatedObjects[id] = applySpliceToObject(patch, record); - break; - } - case "del": { - toRemove.push(id); - break; - } - default: { - console.log("Unsupported patch:", patch); - } + const changedIds = new Set(); + for (const patch of patches) { + if (patch.path[0] === "store" && patch.path.length > 1) { + changedIds.add(`${patch.path[1]}`); } - }); - const toPut = Object.values(updatedObjects); + } - // put / remove the records in the store + const toRemove: TLRecord["id"][] = []; + const toPut: TLRecord[] = []; + for (const id of changedIds) { + const record = doc.store[id]; + if (record === undefined) { + toRemove.push(id as TLRecord["id"]); + } else { + toPut.push(structuredClone(record) as TLRecord); + } + } store.mergeRemoteChanges(() => { if (toRemove.length) store.remove(toRemove); if (toPut.length) store.put(toPut); }); } - -const isStorePatch = (patch: Automerge.Patch): boolean => { - return patch.path[0] === "store" && patch.path.length > 1; -}; - -// path: ["store", "camera:page:page", "x"] => "camera:page:page" -const pathToId = (path: string[]): RecordId => { - return path[1] as RecordId; -}; - -const applyInsertToObject = ( - patch: Automerge.InsertPatch, - object: any -): TLRecord => { - const { path, values } = patch; - let current = object; - const insertionPoint = path[path.length - 1]; - const pathEnd = path[path.length - 2]; - const parts = path.slice(2, -2); - for (const part of parts) { - if (current[part] === undefined) { - throw new Error("NO WAY"); - } - current = current[part]; - } - // splice is a mutator... yay. - const clone = current[pathEnd].slice(0); - clone.splice(insertionPoint, 0, ...values); - current[pathEnd] = clone; - return object; -}; - -const applyPutToObject = (patch: Automerge.PutPatch, object: any): TLRecord => { - const { path, value } = patch; - let current = object; - // special case - if (path.length === 2) { - // this would be creating the object, but we have done - return object; - } - - const parts = path.slice(2, -2); - const property = path[path.length - 1]; - const target = path[path.length - 2]; - - if (path.length === 3) { - return { ...object, [property]: value }; - } - - // default case - for (const part of parts) { - current = current[part]; - } - current[target] = { ...current[target], [property]: value }; - return object; -}; - -const applySpliceToObject = ( - patch: Automerge.SpliceTextPatch, - object: any -): TLRecord => { - const { path, value } = patch; - let current = object; - const insertionPoint = path[path.length - 1]; - const pathEnd = path[path.length - 2]; - const parts = path.slice(2, -2); - for (const part of parts) { - if (current[part] === undefined) { - throw new Error("NO WAY"); - } - current = current[part]; - } - // TODO: we're not supporting actual splices yet because TLDraw won't generate them natively - if (insertionPoint !== 0) { - throw new Error("Splices are not supported yet"); - } - current[pathEnd] = value; // .splice(insertionPoint, 0, value) - return object; -}; diff --git a/tldraw4/src/lith/useAutomergeStore.ts b/tldraw4/src/lith/useAutomergeStore.ts index 492532c..8fea7fa 100644 --- a/tldraw4/src/lith/useAutomergeStore.ts +++ b/tldraw4/src/lith/useAutomergeStore.ts @@ -80,9 +80,10 @@ export function useAutomergeStore({ /* Automerge to TLDraw */ const syncAutomergeDocChangesToStore = ({ patches, - }: DocHandleChangePayload) => { + doc, + }: DocHandleChangePayload) => { if (preventPatchApplications) return; - applyAutomergePatchesToTLStore(patches, store); + applyAutomergePatchesToTLStore(patches, store, doc ?? handle.doc()); }; handle.on("change", syncAutomergeDocChangesToStore); @@ -96,8 +97,8 @@ export function useAutomergeStore({ store.mergeRemoteChanges(() => { store.loadStoreSnapshot({ - store: JSON.parse(JSON.stringify(doc.store)), - schema: JSON.parse(JSON.stringify(doc.schema)), + store: structuredClone(doc.store), + schema: structuredClone(doc.schema), }); }); diff --git a/tldraw4/src/main.ts b/tldraw4/src/main.ts index 9065c31..08f3d71 100644 --- a/tldraw4/src/main.ts +++ b/tldraw4/src/main.ts @@ -18,8 +18,11 @@ export const plugins = [ { type: "patchwork:datatype", id: "tldraw4", - name: "tldraw", + name: "tldraw4", icon: "PenLine", + // New canvases are tldraw5 now; this datatype stays registered so existing + // documents still open, but is no longer offered for new ones. + unlisted: true, async load() { return (await import("./datatype.ts")).datatype; }, @@ -27,7 +30,7 @@ export const plugins = [ { type: "patchwork:tool", id: "tldraw4", - name: "tldraw", + name: "tldraw4", supportedDatatypes: ["tldraw4"], async load(): Promise { const { render } = await import("./tool.tsx"); diff --git a/tldraw5/.gitignore b/tldraw5/.gitignore new file mode 100644 index 0000000..94674fe --- /dev/null +++ b/tldraw5/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.pushwork/ diff --git a/tldraw5/esbuild/build.ts b/tldraw5/esbuild/build.ts new file mode 100644 index 0000000..96d97f0 --- /dev/null +++ b/tldraw5/esbuild/build.ts @@ -0,0 +1,3 @@ +import options from "./options.ts"; +import * as esbuild from "esbuild"; +esbuild.build(options); diff --git a/tldraw5/esbuild/context.ts b/tldraw5/esbuild/context.ts new file mode 100644 index 0000000..b161d42 --- /dev/null +++ b/tldraw5/esbuild/context.ts @@ -0,0 +1,3 @@ +import options from "./options.ts"; +import * as esbuild from "esbuild"; +export default await esbuild.context(options); diff --git a/tldraw5/esbuild/options.ts b/tldraw5/esbuild/options.ts new file mode 100644 index 0000000..876a0c9 --- /dev/null +++ b/tldraw5/esbuild/options.ts @@ -0,0 +1,37 @@ +import type { BuildOptions, Plugin } from "esbuild"; +import externals from "@inkandswitch/patchwork-bootloader/externals"; +import process from "node:process"; +import { existsSync, rmSync } from "node:fs"; + +import pushworkSync from "./plugin-pushwork-sync.ts"; +import pkgJSON from "../package.json" with { type: "json" }; + +const pushworking = process.argv.includes("pushwork") || process.env.PUSHWORK; + +export default { + entryPoints: Object.values(pkgJSON.exports) + .filter((dsc) => typeof dsc == "object" && "source" in dsc) + .map((dsc) => dsc.source), + outdir: "dist", + bundle: true, + platform: "browser", + format: "esm", + splitting: true, + logLevel: "debug", + sourcemap: true, + jsx: "automatic", + jsxImportSource: "react", + external: externals, + plugins: [ + { + name: "empty outdir", + setup(build) { + build.onStart(() => { + const { outdir } = build.initialOptions; + if (outdir && existsSync(outdir)) rmSync(outdir, { recursive: true }); + }); + }, + } satisfies Plugin, + ].concat(pushworking ? [pushworkSync()] : []), + loader: { ".ttf": "dataurl", ".wasm": "binary" }, +} satisfies BuildOptions; diff --git a/tldraw5/esbuild/plugin-pushwork-sync.ts b/tldraw5/esbuild/plugin-pushwork-sync.ts new file mode 100644 index 0000000..e84abf6 --- /dev/null +++ b/tldraw5/esbuild/plugin-pushwork-sync.ts @@ -0,0 +1,29 @@ +import type { Plugin as EsbuildPlugin } from "esbuild"; +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +export default function pushworkSync() { + return { + name: "pushwork", + setup(build) { + if (!existsSync(".pushwork")) { + console.warn("no .pushwork directory! run `pushwork init .` first"); + return; + } + + build.onEnd((result) => { + if (result.errors.length) { + console.warn("esbuild errors! skipping pushwork sync"); + return; + } + try { + execSync("pushwork sync", { + stdio: "inherit", + }); + } catch (error) { + console.warn((error as Error).message); + } + }); + }, + } satisfies EsbuildPlugin; +} diff --git a/tldraw5/esbuild/watch.ts b/tldraw5/esbuild/watch.ts new file mode 100644 index 0000000..016a01d --- /dev/null +++ b/tldraw5/esbuild/watch.ts @@ -0,0 +1,2 @@ +import context from "./context.ts"; +await context.watch(); diff --git a/tldraw5/package.json b/tldraw5/package.json new file mode 100644 index 0000000..7bdca70 --- /dev/null +++ b/tldraw5/package.json @@ -0,0 +1,65 @@ +{ + "name": "@patchwork/tldraw5", + "private": true, + "version": "0.1.0", + "type": "module", + "main": "./dist/main.js", + "exports": { + ".": { + "import": "./dist/main.js", + "source": "./src/main.ts" + }, + "./tool": { + "import": "./dist/tool.js", + "source": "./src/tool.tsx" + }, + "./datatype": { + "import": "./dist/datatype.js", + "source": "./src/datatype.ts" + }, + "./style": "./dist/main.css" + }, + "scripts": { + "build": "node esbuild/build.ts", + "dev": "node esbuild/watch.ts", + "pushwatch": "pnpm dev pushwork", + "sync": "pnpm build && pushwork sync", + "register": "pw-modules add \"$MODULE_SETTINGS_DOC_URL\" \"$(pushwork url)\"" + }, + "dependencies": { + "@automerge/automerge-repo-react-hooks": "2.6.0-subduction.46", + "@automerge/react": "^2.6.0-subduction.9", + "@inkandswitch/patchwork-bootloader": "^0.2.8", + "@inkandswitch/patchwork-filesystem": "^0.0.3", + "@inkandswitch/patchwork-plugins": "^0.0.5", + "@inkandswitch/patchwork-providers": "0.3.0", + "@inkandswitch/patchwork-providers-react": "0.2.2", + "@tldraw/tldraw": "5.2.5", + "@tldraw/tlschema": "5.2.5", + "@tldraw/state": "5.2.5", + "@tldraw/utils": "5.2.5", + "@types/lodash": "^4.17.20", + "lodash": "^4.17.21", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sql.js": "^1.14.1", + "vite-plugin-wasm": "^3.5.0" + }, + "devDependencies": { + "@automerge/automerge": "^3.2.5", + "@automerge/automerge-repo": "2.6.0-subduction.46", + "@eslint/js": "^9.36.0", + "@types/node": "^24.6.0", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^5.0.4", + "babel-plugin-react-compiler": "19.1.0-rc.3", + "esbuild": "^0.23.1", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0" + } +} diff --git a/tldraw5/src/DiffShapeWrapper.tsx b/tldraw5/src/DiffShapeWrapper.tsx new file mode 100644 index 0000000..e079be6 --- /dev/null +++ b/tldraw5/src/DiffShapeWrapper.tsx @@ -0,0 +1,44 @@ +import { createContext, forwardRef, useContext } from "react"; +import { + DefaultShapeWrapper, + useValue, + type Atom, + type TLShapeId, + type TLShapeWrapperProps, +} from "@tldraw/tldraw"; + +export type DiffStatus = "added" | "changed" | "deleted"; + +// Per-tool atom mapping each shape id to its diff status. It lives in a tldraw +// `atom` (not React state) so the wrapper can read it via `useValue` and only +// the shapes whose status actually changed re-render. It reaches the wrapper +// through context because tldraw instantiates `ShapeWrapper` internally — props +// can't be passed to it, but context (which flows down from ``) can. +export type DiffStatusAtom = Atom>; + +export const DiffStatusContext = createContext(null); + +// Wraps every shape's DOM and tags it with a `tl-diff-*` class so a CSS +// `drop-shadow` glow can hug the shape outline (added / changed) or fade it out +// (deleted ghosts). Re-uses `DefaultShapeWrapper` so all default behaviour is +// preserved. +export const DiffShapeWrapper = forwardRef( + function DiffShapeWrapper({ children, shape, isBackground }, ref) { + const statusAtom = useContext(DiffStatusContext); + const status = useValue( + "diff status", + () => statusAtom?.get().get(shape.id), + [statusAtom, shape.id] + ); + return ( + + {children} + + ); + } +); diff --git a/tldraw5/src/NewDocTool.tsx b/tldraw5/src/NewDocTool.tsx new file mode 100644 index 0000000..2437dae --- /dev/null +++ b/tldraw5/src/NewDocTool.tsx @@ -0,0 +1,430 @@ +/** + * NewDocTool — a tldraw tool that lets the user draw a box to create a new + * embedded Patchwork document of a chosen datatype. + * + * Exports: + * - NewDocShapeTool — pass to Tldraw `tools` + * - newDocUiOverrides — spread into Tldraw `overrides` + * - NewDocToolbar — pass as Tldraw `components.Toolbar` + * - setNewDocToolContext() — call once per editor when it's ready + */ + +import { + DefaultToolbar, + DefaultToolbarContent, + StateNode, + createShapeId, + useEditor, + useValue, + type Editor, + type TLPointerEventInfo, + type TLUiOverrides, + type TLUiToolsContextType, +} from "@tldraw/tldraw"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { Repo } from "@automerge/automerge-repo/slim"; +import { + createDocOfDatatype2, + getRegistry, + type DatatypeDescription, + type LoadedDatatype, +} from "@inkandswitch/patchwork-plugins"; +import { PATCHWORK_DOC_SHAPE_TYPE, makeShapeId } from "./PatchworkDocShape.tsx"; + +// Per-editor context, keyed by editor instance, so a nested canvas embedded in +// another canvas doesn't clobber the outer one's repo reference. +interface NewDocContext { + repo: Repo; +} + +const _contextByEditor = new WeakMap(); +let _selectedDatatypeId = ""; + +export function setNewDocToolContext(repo: Repo, editor: Editor) { + _contextByEditor.set(editor, { repo }); +} + +export function setSelectedDatatypeId(id: string) { + _selectedDatatypeId = id; +} + +function getListedDatatypeDescriptions(): DatatypeDescription[] { + try { + const registry = getRegistry("patchwork:datatype"); + return registry.filter((d) => !(d as DatatypeDescription).unlisted) as unknown as DatatypeDescription[]; + } catch { + return []; + } +} + +async function loadDatatype(id: string): Promise { + try { + const registry = getRegistry("patchwork:datatype"); + return (await registry.load(id)) as unknown as LoadedDatatype | undefined; + } catch { + return undefined; + } +} + +export class NewDocShapeTool extends StateNode { + static override id = "new-doc"; + + private startPoint = { x: 0, y: 0 }; + private previewId: ReturnType | null = null; + + override onPointerDown(_info: TLPointerEventInfo) { + const { currentPagePoint } = this.editor.inputs; + this.startPoint = { x: currentPagePoint.x, y: currentPagePoint.y }; + + this.previewId = createShapeId(); + this.editor.createShape({ + id: this.previewId, + type: "geo", + x: currentPagePoint.x, + y: currentPagePoint.y, + parentId: this.editor.getCurrentPageId(), + props: { w: 1, h: 1, geo: "rectangle", dash: "dashed", fill: "none" }, + }); + } + + override onPointerMove(_info: TLPointerEventInfo) { + if (!this.previewId) return; + const { currentPagePoint } = this.editor.inputs; + const x = Math.min(this.startPoint.x, currentPagePoint.x); + const y = Math.min(this.startPoint.y, currentPagePoint.y); + const w = Math.max(1, Math.abs(currentPagePoint.x - this.startPoint.x)); + const h = Math.max(1, Math.abs(currentPagePoint.y - this.startPoint.y)); + this.editor.updateShape({ id: this.previewId, type: "geo", x, y, props: { w, h } }); + } + + override onCancel() { + this.cleanup(); + this.editor.setCurrentTool("select"); + } + + override onInterrupt() { + this.cleanup(); + } + + private cleanup() { + if (this.previewId) { + if (this.editor.getShape(this.previewId)) { + this.editor.deleteShapes([this.previewId]); + } + this.previewId = null; + } + } + + override onPointerUp(_info: TLPointerEventInfo) { + if (!this.previewId) return; + + const preview = this.editor.getShape(this.previewId); + this.editor.deleteShapes([this.previewId]); + this.previewId = null; + + if (!preview) { + this.editor.setCurrentTool("select"); + return; + } + + const px = (preview as { x: number }).x; + const py = (preview as { y: number }).y; + const pw = (preview as { props: { w: number } }).props.w; + const ph = (preview as { props: { h: number } }).props.h; + + const finalW = Math.max(pw, 240); + const finalH = Math.max(ph, 180); + + const ctx = _contextByEditor.get(this.editor); + if (!ctx) { + console.warn("[tldraw5] NewDocTool: context not set for this editor"); + this.editor.setCurrentTool("select"); + return; + } + + const datatypeId = _selectedDatatypeId; + if (!datatypeId) { + console.warn("[tldraw5] NewDocTool: no datatype selected"); + this.editor.setCurrentTool("select"); + return; + } + + const editor = this.editor; + const repo = ctx.repo; + const placeholderId = createShapeId(); + + editor.createShape({ + id: placeholderId, + type: PATCHWORK_DOC_SHAPE_TYPE, + x: px, + y: py, + rotation: 0, + parentId: editor.getCurrentPageId(), + props: { + w: finalW, + h: finalH, + docUrl: "", + docName: "Creating\u2026", + docType: datatypeId, + toolId: "", + }, + }); + + editor.setCurrentTool("select"); + editor.setSelectedShapes([placeholderId]); + + void (async () => { + try { + const datatype = await loadDatatype(datatypeId); + if (!datatype) throw new Error(`Could not load datatype: ${datatypeId}`); + + // `createDocOfDatatype2` in the installed plugins package is typed + // against an older @automerge/automerge-repo Repo; cast to bridge the + // version skew (same pattern used by the space / llm-canvas tools). + const docHandle = await ( + createDocOfDatatype2 as (d: LoadedDatatype, r: unknown) => Promise<{ url: string }> + )(datatype, repo); + const docUrl = docHandle.url; + const deterministicId = makeShapeId(docUrl); + + const tempShape = editor.getShape(placeholderId) as + | { x: number; y: number; props: { w: number; h: number } } + | undefined; + const sx = tempShape?.x ?? px; + const sy = tempShape?.y ?? py; + const sw = tempShape?.props?.w ?? finalW; + const sh = tempShape?.props?.h ?? finalH; + + if (editor.getShape(placeholderId)) { + editor.deleteShapes([placeholderId]); + } + + if (editor.getShape(deterministicId)) { + editor.updateShape({ + id: deterministicId, + type: PATCHWORK_DOC_SHAPE_TYPE, + props: { + docUrl, + docName: datatype.name ?? datatypeId, + docType: datatypeId, + toolId: "", + }, + }); + } else { + editor.createShape({ + id: deterministicId, + type: PATCHWORK_DOC_SHAPE_TYPE, + x: sx, + y: sy, + rotation: 0, + parentId: editor.getCurrentPageId(), + props: { + w: sw, + h: sh, + docUrl, + docName: datatype.name ?? datatypeId, + docType: datatypeId, + toolId: "", + }, + }); + } + + editor.setSelectedShapes([deterministicId]); + } catch (err) { + console.error("[tldraw5] new doc creation failed:", err); + if (editor.getShape(placeholderId)) { + editor.updateShape({ + id: placeholderId, + type: PATCHWORK_DOC_SHAPE_TYPE, + props: { docName: `Error creating ${datatypeId}` }, + }); + } + } + })(); + } +} + +export const newDocUiOverrides: TLUiOverrides = { + tools(editor: Editor, tools: TLUiToolsContextType) { + tools["new-doc"] = { + id: "new-doc", + icon: "plus", + label: "New document", + kbd: "c", + onSelect() { + editor.setCurrentTool("new-doc"); + }, + }; + return tools; + }, +}; + +export function NewDocToolbar() { + const editor = useEditor(); + const isActive = useValue("new-doc active", () => editor.getCurrentToolId() === "new-doc", [ + editor, + ]); + + const [datatypes, setDatatypes] = useState([]); + const [menuOpen, setMenuOpen] = useState(false); + const [selectedId, setSelectedId] = useState(_selectedDatatypeId); + const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null); + const buttonRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => { + const dt = getListedDatatypeDescriptions(); + setDatatypes(dt); + if (dt.length > 0 && !_selectedDatatypeId) { + setSelectedDatatypeId(dt[0].id); + setSelectedId(dt[0].id); + } + }, []); + + useEffect(() => { + if (!menuOpen) return; + const handler = (e: PointerEvent) => { + const t = e.target as Node; + if (buttonRef.current?.contains(t) || menuRef.current?.contains(t)) return; + setMenuOpen(false); + }; + document.addEventListener("pointerdown", handler, true); + return () => document.removeEventListener("pointerdown", handler, true); + }, [menuOpen]); + + const openMenu = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setMenuPos({ x: rect.left + rect.width / 2, y: rect.top }); + } + setDatatypes(getListedDatatypeDescriptions()); + setMenuOpen(true); + }, []); + + const handlePlusClick = useCallback(() => { + if (datatypes.length > 0 && !_selectedDatatypeId) { + setSelectedDatatypeId(datatypes[0].id); + setSelectedId(datatypes[0].id); + } + editor.setCurrentTool("new-doc"); + if (datatypes.length > 1) { + if (menuOpen) setMenuOpen(false); + else requestAnimationFrame(openMenu); + } + }, [datatypes, editor, menuOpen, openMenu]); + + const handlePick = useCallback( + (id: string) => { + setSelectedDatatypeId(id); + setSelectedId(id); + setMenuOpen(false); + editor.setCurrentTool("new-doc"); + }, + [editor], + ); + + if (datatypes.length === 0) { + return ( + + + + ); + } + + const selectedName = datatypes.find((d) => d.id === selectedId)?.name ?? "document"; + + return ( + + + +
+ +
+ + + {menuOpen && + menuPos && + datatypes.length > 1 && + createPortal( +
+ {datatypes.map((dt) => ( + + ))} +
, + document.body, + )} +
+ + ); +} diff --git a/tldraw5/src/PatchworkDocShape.tsx b/tldraw5/src/PatchworkDocShape.tsx new file mode 100644 index 0000000..cd3bdb4 --- /dev/null +++ b/tldraw5/src/PatchworkDocShape.tsx @@ -0,0 +1,596 @@ +import { + Geometry2d, + HTMLContainer, + Rectangle2d, + ShapeUtil, + T, + createShapeId, + resizeBox, + useEditor, + useValue, + type RecordProps, + type TLResizeInfo, + type TLShape, + type TLShapeId, +} from "@tldraw/tldraw"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + getRegistry, + getSupportedToolsForType, + type DatatypeDescription, + type LoadedDatatype, + type LoadedTool, +} from "@inkandswitch/patchwork-plugins"; +import type { AutomergeUrl } from "@automerge/automerge-repo/slim"; +import { useDocument, useRepo } from "@automerge/react"; +import { automergeUrlToServiceWorkerUrl } from "@inkandswitch/patchwork-filesystem"; + +// A tldraw shape that embeds another Patchwork document, rendered via the +// host-provided `` custom element. The document reference and +// its display metadata live in the shape props, so they persist through the +// normal tldraw <-> Automerge sync like any other shape. + +export const PATCHWORK_DOC_SHAPE_TYPE = "patchwork-doc" as const; + +declare module "@tldraw/tldraw" { + export interface TLGlobalShapePropsMap { + [PATCHWORK_DOC_SHAPE_TYPE]: { + w: number; + h: number; + docUrl: string; + docName: string; + docType: string; + toolId: string; + }; + } +} + +export type PatchworkDocShape = TLShape; + +// Deterministic shape id derived from the doc url, so remote peers that create +// a shape for the same document converge on a single shape instead of dupes. +export function makeShapeId(docUrl: string): TLShapeId { + return createShapeId(docUrl.replace(/[^a-zA-Z0-9]/g, "_")); +} + +export class PatchworkDocShapeUtil extends ShapeUtil { + static override type = PATCHWORK_DOC_SHAPE_TYPE; + + static override props: RecordProps = { + w: T.number, + h: T.number, + docUrl: T.string, + docName: T.string, + docType: T.string, + toolId: T.string, + }; + + getDefaultProps(): PatchworkDocShape["props"] { + return { + w: 640, + h: 480, + docUrl: "", + docName: "Untitled", + docType: "", + toolId: "", + }; + } + + getGeometry(shape: PatchworkDocShape): Geometry2d { + return new Rectangle2d({ + width: shape.props.w, + height: shape.props.h, + isFilled: true, + }); + } + + override canResize() { + return true; + } + override canEdit() { + return true; + } + override isAspectRatioLocked() { + return false; + } + override hideRotateHandle() { + return true; + } + + override onResize(shape: PatchworkDocShape, info: TLResizeInfo) { + return resizeBox(shape, info); + } + + component(shape: PatchworkDocShape) { + return ; + } + + getIndicatorPath(shape: PatchworkDocShape) { + const path = new Path2D(); + path.rect(0, 0, shape.props.w, shape.props.h); + return path; + } +} + +// Best default tool for a datatype: prefer a tool that explicitly lists the +// datatype over a wildcard ("*") tool; fall back to the first available. +export function getDefaultToolId(datatypeId: string): string { + if (!datatypeId) return ""; + try { + const tools = getSupportedToolsForType(datatypeId).filter( + (t) => !(t as { unlisted?: boolean }).unlisted, + ); + const specific = tools.find((t) => { + const supported = (t as { supportedDatatypes?: unknown }).supportedDatatypes; + return Array.isArray(supported) && supported.includes(datatypeId); + }); + return (specific ?? tools[0])?.id ?? ""; + } catch { + return ""; + } +} + +function useSupportedTools(docType: string): LoadedTool[] { + return useMemo(() => { + if (!docType) return []; + try { + return getSupportedToolsForType(docType).filter((t) => !(t as { unlisted?: boolean }).unlisted); + } catch { + return []; + } + }, [docType]); +} + +function useIsImage(docUrl: string): boolean { + const [doc] = useDocument<{ "@patchwork"?: { type?: string }; mimeType?: string }>( + docUrl ? (docUrl as AutomergeUrl) : undefined, + ); + return doc?.["@patchwork"]?.type === "file" && !!doc?.mimeType?.startsWith("image/"); +} + +// Fire the host event that opens the document full-screen in Patchwork. The +// event contract lives in @inkandswitch/patchwork-elements; we dispatch it +// directly to avoid taking a hard dependency on that (externalized) package. +function openDocumentInHost(el: HTMLElement, url: string, toolId?: string) { + el.dispatchEvent( + new CustomEvent("patchwork:open-document", { + detail: { url, toolId: toolId || undefined }, + composed: true, + bubbles: true, + }), + ); +} + +function PatchworkDocComponent({ shape }: { shape: PatchworkDocShape }) { + const { docUrl, docName, docType, toolId } = shape.props; + const editor = useEditor(); + const repo = useRepo(); + const isImage = useIsImage(docUrl); + const tools = useSupportedTools(docType); + + const isSelectTool = useValue("is select tool", () => editor.getCurrentToolId() === "select", [ + editor, + ]); + const isEditingShape = useValue( + "is editing shape", + () => editor.getEditingShapeId() === shape.id, + [editor, shape.id], + ); + const isFocused = isEditingShape; + + const [isEditingName, setIsEditingName] = useState(false); + const [toolMenuOpen, setToolMenuOpen] = useState(false); + const nameInputRef = useRef(null); + const containerRef = useRef(null); + const contentRef = useRef(null); + const toolMenuRef = useRef(null); + + const currentTool = tools.find((t) => t.id === toolId) ?? tools[0]; + + useEffect(() => { + if (isEditingName && nameInputRef.current) { + nameInputRef.current.focus(); + nameInputRef.current.select(); + } + }, [isEditingName]); + + // Close the tool menu on outside pointerdown. + useEffect(() => { + if (!toolMenuOpen) return; + const handler = (e: PointerEvent) => { + if (toolMenuRef.current && !toolMenuRef.current.contains(e.target as Node)) { + setToolMenuOpen(false); + } + }; + window.addEventListener("pointerdown", handler, true); + return () => window.removeEventListener("pointerdown", handler, true); + }, [toolMenuOpen]); + + // While the embedded content is focused, stop tldraw from swallowing + // keyboard / wheel / pointer events so the inner tool stays interactive. + useEffect(() => { + if (!isFocused) return; + const el = contentRef.current; + if (!el) return; + + const stopKey = (e: KeyboardEvent) => e.stopPropagation(); + const stopWheel = (e: WheelEvent) => { + if (!e.ctrlKey) e.stopPropagation(); + }; + const stopPointer = (e: PointerEvent) => e.stopPropagation(); + + el.addEventListener("keydown", stopKey); + el.addEventListener("keyup", stopKey); + el.addEventListener("keypress", stopKey); + el.addEventListener("wheel", stopWheel); + el.addEventListener("pointerdown", stopPointer, true); + el.addEventListener("pointermove", stopPointer, true); + el.addEventListener("pointerup", stopPointer, true); + return () => { + el.removeEventListener("keydown", stopKey); + el.removeEventListener("keyup", stopKey); + el.removeEventListener("keypress", stopKey); + el.removeEventListener("wheel", stopWheel); + el.removeEventListener("pointerdown", stopPointer, true); + el.removeEventListener("pointermove", stopPointer, true); + el.removeEventListener("pointerup", stopPointer, true); + }; + }, [isFocused]); + + const handleToolChange = useCallback( + (newToolId: string) => { + editor.updateShape({ + id: shape.id, + type: PATCHWORK_DOC_SHAPE_TYPE, + props: { toolId: newToolId }, + }); + setToolMenuOpen(false); + }, + [editor, shape.id], + ); + + const handleOpenDocument = useCallback(() => { + const el = containerRef.current; + if (!el || !docUrl) return; + openDocumentInHost(el, docUrl, toolId || undefined); + }, [docUrl, toolId]); + + const handleRename = useCallback( + async (newName: string) => { + const trimmed = newName.trim(); + if (!trimmed || trimmed === docName || !docUrl || !docType) { + setIsEditingName(false); + return; + } + + try { + const datatype = await loadDatatype(docType); + if (datatype?.module.setTitle) { + const childHandle = await repo.find>(docUrl as AutomergeUrl); + childHandle.change((d) => { + datatype.module.setTitle!(d, trimmed); + }); + const childDoc = childHandle.doc(); + const canonicalName = childDoc ? datatype.module.getTitle(childDoc) : trimmed; + editor.updateShape({ + id: shape.id, + type: PATCHWORK_DOC_SHAPE_TYPE, + props: { docName: canonicalName }, + }); + } else { + editor.updateShape({ + id: shape.id, + type: PATCHWORK_DOC_SHAPE_TYPE, + props: { docName: trimmed }, + }); + } + } catch (err) { + console.warn("[tldraw5] rename failed", err); + } + + setIsEditingName(false); + }, + [editor, shape.id, docName, docUrl, docType, repo], + ); + + return ( + +
+ {/* Titlebar */} +
{ + if (isEditingShape) editor.setEditingShape(null); + }} + style={{ + display: "flex", + alignItems: "center", + gap: "6px", + height: "30px", + padding: "0 6px", + borderBottom: "1px solid var(--color-divider, #e5e7eb)", + flexShrink: 0, + cursor: "grab", + userSelect: "none", + background: "var(--color-low, #fafafa)", + }} + > + {/* Open button */} + + + {/* Doc name */} +
+ {isEditingName ? ( + e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Enter") handleRename((e.target as HTMLInputElement).value); + if (e.key === "Escape") setIsEditingName(false); + }} + onBlur={(e) => handleRename(e.target.value)} + style={{ + width: "100%", + fontSize: "13px", + fontWeight: 600, + fontFamily: "inherit", + color: "var(--color-text, #111827)", + background: "var(--color-panel, #fff)", + border: "1px solid var(--color-selected, #2f80ed)", + borderRadius: "4px", + padding: "1px 4px", + outline: "none", + boxSizing: "border-box", + }} + /> + ) : ( + { + // Let a plain click start rename without dragging the shape. + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + setIsEditingName(true); + }} + title={docName} + style={{ + display: "block", + fontSize: "13px", + fontWeight: 600, + color: "var(--color-text, #111827)", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + cursor: "text", + }} + > + {docName || "Untitled"} + + )} +
+ + {/* Tool picker */} + {currentTool && ( +
+ + + {toolMenuOpen && tools.length > 1 && ( +
e.stopPropagation()} + style={{ + position: "absolute", + top: "100%", + right: 0, + marginTop: "4px", + background: "var(--color-panel, #fff)", + border: "1px solid var(--color-divider, #e5e7eb)", + borderRadius: "6px", + boxShadow: "0 4px 12px rgba(0,0,0,0.12)", + padding: "4px", + minWidth: "140px", + zIndex: 10000, + }} + > + {tools.map((t) => ( + + ))} +
+ )} +
+ )} +
+ + {/* Content */} +
{ + e.stopPropagation(); + if (!isEditingShape) editor.setEditingShape(shape.id); + } + : undefined + } + onPointerUp={ + isSelectTool + ? (e) => { + e.stopPropagation(); + // Synthesize a click for frameworks that rely on + // document-level event delegation (e.g. Solid.js); tldraw's + // preventDefault on pointerdown suppresses the native click. + (e.target as HTMLElement)?.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + view: window, + clientX: e.clientX, + clientY: e.clientY, + }), + ); + } + : undefined + } + > + {docUrl && isImage ? ( + {docName} + ) : docUrl ? ( + // @ts-expect-error Custom element from @inkandswitch/patchwork-elements + + ) : ( +
+ No document +
+ )} +
+
+
+ ); +} + +async function loadDatatype(id: string): Promise { + try { + const registry = getRegistry("patchwork:datatype"); + return (await registry.load(id)) as unknown as LoadedDatatype | undefined; + } catch { + return undefined; + } +} diff --git a/tldraw5/src/color-scheme.ts b/tldraw5/src/color-scheme.ts new file mode 100644 index 0000000..b575143 --- /dev/null +++ b/tldraw5/src/color-scheme.ts @@ -0,0 +1,49 @@ +// tldraw wants an explicit "light" | "dark". Take it from the CSS context +// rather than from the OS: Patchwork themes declare `color-scheme`, and that +// property inherits, so its computed value on our element is whatever the +// closest theme says. A theme that names both (`light dark`) or names nothing +// is deferring to the OS, so we do too. + +import { useEffect, useState } from "react"; + +export type ColorScheme = "light" | "dark"; + +function prefersDark() { + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +export function resolveColorScheme(element: HTMLElement): ColorScheme { + const scheme = getComputedStyle(element).colorScheme; + const dark = /\bdark\b/.test(scheme); + const light = /\blight\b/.test(scheme); + if (dark && !light) return "dark"; + if (light && !dark) return "light"; + return prefersDark() ? "dark" : "light"; +} + +export function useColorScheme(element: HTMLElement): ColorScheme { + const [scheme, setScheme] = useState(() => + resolveColorScheme(element) + ); + + useEffect(() => { + const update = () => setScheme(resolveColorScheme(element)); + update(); + + const media = window.matchMedia("(prefers-color-scheme: dark)"); + media.addEventListener("change", update); + + // Switching themes swaps stylesheets and sets an attribute on ; + // neither fires an event, so watch for both. + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { attributes: true }); + observer.observe(document.head, { childList: true, subtree: true }); + + return () => { + media.removeEventListener("change", update); + observer.disconnect(); + }; + }, [element]); + + return scheme; +} diff --git a/tldraw5/src/datatype.ts b/tldraw5/src/datatype.ts new file mode 100644 index 0000000..e07a455 --- /dev/null +++ b/tldraw5/src/datatype.ts @@ -0,0 +1,67 @@ +import type { DatatypeImplementation } from "@inkandswitch/patchwork-plugins"; +import { + createTLStore, + defaultShapeUtils, + type SerializedSchema, + type SerializedStore, + type TLPage, + type TLPageId, + type TLRecord, + type TLShapeId, +} from "@tldraw/tldraw"; + +import type { DocLink } from "@inkandswitch/patchwork-filesystem"; + +import { tldrawValueToAutomergeValue } from "./lith/TLStoreToAutomerge.ts"; + +// SCHEMA +// +// The document is FolderDoc-compatible: `docs` holds the same `DocLink[]` a +// folder does, so anything that walks folders can walk a canvas. It carries the +// `script/**` tree of a `.tldraw` archive — each file a `file` document (a +// UnixFileEntry), each directory a nested folder. A root-level `main.js` is the +// script entry point. +export type TLDrawDoc = { + store: SerializedStore; + schema: SerializedSchema; + docs?: DocLink[]; +}; + +export type TLDrawDocAnchor = TLShapeId; + +const pageKey = "page:page" as TLPageId; + +export const getTitle = (doc: TLDrawDoc) => { + const page = doc.store[pageKey] as TLPage; + return page.name.toString() || "Canvas"; +}; + +export const setTitle = (doc: TLDrawDoc, title: string) => { + const page = doc.store[pageKey] as TLPage; + page.name = title; +}; + +export const init = (doc: TLDrawDoc) => { + Object.assign( + doc, + tldrawValueToAutomergeValue( + createTLStore({ + shapeUtils: defaultShapeUtils, + }).getStoreSnapshot() + ) + ); + doc.store[pageKey] = { + meta: {}, + id: "page:page" as TLPageId, + index: "a1" as TLPage["index"], + name: "New tldraw", + typeName: "page", + }; + doc.docs = []; +}; + +export const datatype: DatatypeImplementation = { + init, + getTitle, + setTitle, +}; diff --git a/tldraw5/src/diff.ts b/tldraw5/src/diff.ts new file mode 100644 index 0000000..b69058a --- /dev/null +++ b/tldraw5/src/diff.ts @@ -0,0 +1,59 @@ +import * as Automerge from "@automerge/automerge/slim"; +import { isEqual } from "lodash"; +import type { TLRecord, TLShapeId } from "@tldraw/tldraw"; +import type { TLDrawDoc } from "./datatype.ts"; + +// A record-level diff of the tldraw store between a baseline version and the +// current one. Modelled on how `codemirror-base` diffs text: we view the doc +// at the baseline heads and compare it against the live doc. The granularity +// here is whole records (shapes) rather than text spans. +export type ShapeDiff = { + added: Set; + changed: Set; + // Deleted shapes no longer exist in the live store, so we keep a plain + // clone of the baseline record to re-render it as a ghost. + deleted: TLRecord[]; +}; + +const isShapeId = (id: string): id is TLShapeId => id.startsWith("shape:"); + +// Compute which shapes were added / changed / deleted between the doc at +// `headsBefore` and its current state. Only `shape:*` records are considered +// — the store also holds `camera:`, `instance:`, `pointer:` and +// `instance_presence:` records (presence is written live by every peer) which +// must not show up as diffs. +export function diffStore( + doc: TLDrawDoc, + headsBefore: Automerge.Heads +): ShapeDiff { + const before = Automerge.view(doc, headsBefore); + const beforeStore = (before.store ?? {}) as Record; + const afterStore = (doc.store ?? {}) as Record; + + const added = new Set(); + const changed = new Set(); + const deleted: TLRecord[] = []; + + for (const id in afterStore) { + if (!isShapeId(id)) continue; + if (!(id in beforeStore)) { + added.add(id); + } else if (!isEqual(beforeStore[id], afterStore[id])) { + changed.add(id); + } + } + + for (const id in beforeStore) { + if (!isShapeId(id)) continue; + if (!(id in afterStore)) { + // Clone via JSON so the record survives outside the Automerge doc and + // carries no proxies (matches how the store snapshot is loaded). + deleted.push(JSON.parse(JSON.stringify(beforeStore[id])) as TLRecord); + } + } + + return { added, changed, deleted }; +} + +export const hasDiff = (d: ShapeDiff | null | undefined): d is ShapeDiff => + !!d && (d.added.size > 0 || d.changed.size > 0 || d.deleted.length > 0); diff --git a/tldraw5/src/dnd.ts b/tldraw5/src/dnd.ts new file mode 100644 index 0000000..45dc254 --- /dev/null +++ b/tldraw5/src/dnd.ts @@ -0,0 +1,127 @@ +/** + * Patchwork drag-and-drop payload parsing. + * + * Patchwork document drags (from the folder-tree-view / sideboard and most + * tools) carry these DataTransfer formats: + * - `text/x-patchwork-urls` — JSON `AutomergeUrl[]` (the detection hook) + * - `text/x-patchwork-dnd` — JSON `{ source, items: [{ id, url, type, name }] }` + * - `text/uri-list` / `text/plain` — one Patchwork web link per line + * + * Only `text/x-patchwork-urls` is guaranteed; the richer `-dnd` payload, when + * present, gives us the datatype id and display name without a repo lookup. + */ + +import { + isValidAutomergeUrl, + parseAutomergeUrl, + stringifyAutomergeUrl, + type AutomergeUrl, + type DocumentId, +} from "@automerge/automerge-repo/slim"; + +export const PATCHWORK_URLS_MIME = "text/x-patchwork-urls" as const; +export const PATCHWORK_DND_MIME = "text/x-patchwork-dnd" as const; + +export interface DroppedDoc { + url: AutomergeUrl; + /** Datatype id (from the `-dnd` payload), if known. */ + type?: string; + /** Display name (from the `-dnd` payload), if known. */ + name?: string; +} + +/** True when the drag carries Patchwork document URLs. */ +export function isPatchworkDrag(types: readonly string[]): boolean { + return types.includes(PATCHWORK_URLS_MIME); +} + +type DndItem = { url?: unknown; type?: unknown; name?: unknown }; +type DndPayload = { items?: DndItem[] }; + +/** Extract the dropped documents, preferring the richest payload available. */ +export function parseDroppedDocs(dt: DataTransfer | null): DroppedDoc[] { + if (!dt) return []; + + // 1. Structured payload with datatype + name. + const dnd = dt.getData(PATCHWORK_DND_MIME); + if (dnd) { + try { + const parsed = JSON.parse(dnd) as DndPayload; + const items = (parsed.items ?? []) + .map((it): DroppedDoc | null => { + const url = typeof it.url === "string" ? it.url : undefined; + if (!url || !isValidAutomergeUrl(url)) return null; + return { + url, + type: typeof it.type === "string" ? it.type : undefined, + name: typeof it.name === "string" ? it.name : undefined, + }; + }) + .filter((it): it is DroppedDoc => it !== null); + if (items.length > 0) return dedupe(items); + } catch { + // fall through + } + } + + // 2. Bare URL list. + const urls = dt.getData(PATCHWORK_URLS_MIME); + if (urls) { + try { + const parsed: unknown = JSON.parse(urls); + const items = (Array.isArray(parsed) ? parsed : []) + .filter((u): u is AutomergeUrl => typeof u === "string" && isValidAutomergeUrl(u)) + .map((url) => ({ url })); + if (items.length > 0) return dedupe(items); + } catch { + // fall through + } + } + + // 3. Plain-text / uri-list fallback (external links, "#doc=" form). + const text = dt.getData("text/uri-list") || dt.getData("text/plain"); + if (text) { + const items = text + .split(/\r?\n/) + .map(urlFromText) + .filter((url): url is AutomergeUrl => url !== null) + .map((url) => ({ url })); + if (items.length > 0) return dedupe(items); + } + + return []; +} + +function dedupe(items: DroppedDoc[]): DroppedDoc[] { + const seen = new Set(); + const out: DroppedDoc[] = []; + for (const it of items) { + if (seen.has(it.url)) continue; + seen.add(it.url); + out.push(it); + } + return out; +} + +// Accept `automerge:…` directly, or a Patchwork web link containing +// `#doc=` / `?doc=`. +function urlFromText(line: string): AutomergeUrl | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) return null; + if (isValidAutomergeUrl(trimmed)) return trimmed; + + const match = trimmed.match(/[#?&]doc=([^&\s]+)/); + if (match) { + const raw = decodeURIComponent(match[1]); + if (isValidAutomergeUrl(raw)) return raw; + try { + const url = stringifyAutomergeUrl({ documentId: raw as DocumentId }); + // Round-trip to reject malformed ids. + parseAutomergeUrl(url); + return url; + } catch { + return null; + } + } + return null; +} diff --git a/tldraw5/src/file-io.tsx b/tldraw5/src/file-io.tsx new file mode 100644 index 0000000..31054f2 --- /dev/null +++ b/tldraw5/src/file-io.tsx @@ -0,0 +1,260 @@ +// Import/export of `.tldraw` archives (the offline.tldraw.com format). +// +// Import creates a new Patchwork tldraw5 document from the archive; export +// writes the current document back out as an archive. + +import { createContext, useContext, type FC, type ReactNode } from "react"; +import { useRepo, type DocHandle, type Repo } from "@automerge/react"; +import { + DefaultMainMenu, + DefaultMainMenuContent, + TldrawUiMenuGroup, + TldrawUiMenuItem, + useToasts, + type TLRecord, + type SerializedStore, +} from "@tldraw/tldraw"; +import { + createDocOfDatatype2, + getRegistry, + type DatatypeDescription, + type LoadedDatatype, +} from "@inkandswitch/patchwork-plugins"; + +import type { TLDrawDoc } from "./datatype.ts"; +import { getTitle } from "./datatype.ts"; +import { PATCHWORK_DOC_SHAPE_TYPE } from "./PatchworkDocShape.tsx"; + +// `tldr-archive` pulls in the SQLite wasm build, so it is only fetched when +// the user actually imports or exports. +const archiveModule = () => import("./tldr-archive.ts"); + +export const ARCHIVE_EXTENSIONS = [".tldraw", ".tldr"]; +const SCRIPT_ENTRY = "main.js"; + +export function isTldrArchiveFile(file: File) { + const name = file.name.toLowerCase(); + return ARCHIVE_EXTENSIONS.some((ext) => name.endsWith(ext)); +} + +/** Creates a new Patchwork document from a `.tldraw` archive, returning its url. */ +export async function importArchiveFile( + file: File, + repo: Repo +): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + const { readTldrArchive } = await archiveModule(); + const archive = await readTldrArchive(bytes, baseName(file.name)); + + const { extractScriptFiles, storeScriptFiles } = await import("./script.ts"); + const docs = await storeScriptFiles(repo, extractScriptFiles(archive.files)); + + const pageId = Object.keys(archive.store).find((id) => id.startsWith("page:")); + if (pageId) { + (archive.store as Record)[pageId].name = + archive.displayName; + } + + const registry = getRegistry("patchwork:datatype"); + const datatype = (await registry.load("tldraw5")) as unknown as + | LoadedDatatype + | undefined; + if (!datatype) throw new Error("could not load the tldraw5 datatype"); + + // `createDocOfDatatype2` in the installed plugins package is typed against an + // older @automerge/automerge-repo Repo; cast to bridge the version skew. + const handle = await ( + createDocOfDatatype2 as ( + d: LoadedDatatype, + r: unknown, + change: (doc: TLDrawDoc) => void + ) => Promise<{ url: string }> + )(datatype, repo, (doc) => { + doc.store = archive.store; + doc.schema = archive.schema; + doc.docs = docs; + }); + + return handle.url; +} + +function baseName(fileName: string) { + for (const ext of ARCHIVE_EXTENSIONS) { + if (fileName.toLowerCase().endsWith(ext)) { + return fileName.slice(0, -ext.length); + } + } + return fileName; +} + +export function pickArchiveFile(): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ARCHIVE_EXTENSIONS.join(","); + input.style.display = "none"; + input.addEventListener("change", () => { + resolve(input.files?.[0] ?? null); + input.remove(); + }); + input.addEventListener("cancel", () => { + resolve(null); + input.remove(); + }); + document.body.append(input); + input.click(); + }); +} + +function download(bytes: Uint8Array, fileName: string) { + const url = URL.createObjectURL( + new Blob([bytes as BlobPart], { type: "application/zip" }) + ); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +} + +// Embedded Patchwork documents have no meaning outside Patchwork, and a store +// containing an unknown shape type fails to load in stock tldraw — so they are +// left out of the archive. +function withoutEmbeddedDocs(store: SerializedStore) { + const kept = {} as Record; + let dropped = 0; + for (const [id, record] of Object.entries(store) as [string, TLRecord][]) { + if ((record as { type?: string }).type === PATCHWORK_DOC_SHAPE_TYPE) { + dropped++; + continue; + } + kept[id] = record; + } + return { store: kept as SerializedStore, dropped }; +} + +export async function exportDoc(handle: DocHandle, repo: Repo) { + const doc = handle.doc(); + if (!doc) throw new Error("document not loaded"); + + const snapshot = JSON.parse(JSON.stringify(doc)) as TLDrawDoc; + const title = getTitle(snapshot) || "Untitled"; + const { store, dropped } = withoutEmbeddedDocs(snapshot.store); + + const scriptFiles = doc.docs?.length + ? await (await import("./script.ts")).readScriptFiles(repo, doc.docs) + : undefined; + + const { writeTldrArchive } = await archiveModule(); + const bytes = await writeTldrArchive({ + displayName: title, + store, + schema: snapshot.schema, + scriptFiles, + }); + download(bytes, `${title}.tldraw`); + return { dropped }; +} + +// React 18's types reject tldraw's menu-group return type; it renders fine. +const MenuGroup = TldrawUiMenuGroup as unknown as FC<{ + id: string; + children?: ReactNode; +}>; + +export const TldrFileContext = createContext<{ + handle: DocHandle; + element: HTMLElement; +} | null>(null); + +export function TldrFileMainMenu() { + const context = useContext(TldrFileContext); + const repo = useRepo(); + const { addToast } = useToasts(); + + const onImport = async () => { + try { + const file = await pickArchiveFile(); + if (!file || !context) return; + const url = await importArchiveFile(file, repo); + context.element.dispatchEvent( + new CustomEvent("patchwork:open-document", { + detail: { url, toolId: "tldraw5" }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + console.error("[tldraw5] import failed", error); + addToast({ title: "Import failed", description: String(error) }); + } + }; + + const onExport = async () => { + try { + if (!context) return; + const { dropped } = await exportDoc(context.handle, repo); + if (dropped > 0) { + addToast({ + title: "Exported without embedded documents", + description: `${dropped} embedded Patchwork document${ + dropped === 1 ? "" : "s" + } left out — stock tldraw can't read them.`, + }); + } + } catch (error) { + console.error("[tldraw5] export failed", error); + addToast({ title: "Export failed", description: String(error) }); + } + }; + + // `main.js` may sit behind nested folder documents, so finding it is async. + const docs = context?.handle.doc()?.docs; + const hasScript = !!docs?.some((link) => link.name === SCRIPT_ENTRY); + + const onOpenScript = async () => { + if (!context || !docs) return; + try { + const { resolveScriptFiles } = await import("./script.ts"); + const url = (await resolveScriptFiles(repo, docs))[SCRIPT_ENTRY]; + if (!url) return; + context.element.dispatchEvent( + new CustomEvent("patchwork:open-document", { + detail: { url }, + bubbles: true, + composed: true, + }) + ); + } catch (error) { + console.error("[tldraw5] could not open the script", error); + } + }; + + return ( + + + {hasScript && ( + + )} + + + + + + ); +} diff --git a/tldraw5/src/lith/AutomergeToTLStore.ts b/tldraw5/src/lith/AutomergeToTLStore.ts new file mode 100644 index 0000000..9d62d17 --- /dev/null +++ b/tldraw5/src/lith/AutomergeToTLStore.ts @@ -0,0 +1,43 @@ +import type { TLRecord, TLStore } from "@tldraw/tldraw"; +import * as Automerge from "@automerge/automerge/slim"; + +/** + * Sync automerge doc changes into the tldraw store. + * + * The patches are only used to find out *which* records changed; the record + * contents are rebuilt wholesale from the doc. Replaying patch mechanics + * against store records would require modeling every patch shape automerge + * can emit (nested `del`, `splice`, `conflict`, ...) and any gap silently + * diverges the view from the doc until reload. Rebuilding from the doc makes + * the doc the single source of truth: after every change the store record is + * exactly what the doc says, regardless of what the patches looked like. + * tldraw records are small, so the per-record rebuild cost is negligible. + */ +export function applyAutomergePatchesToTLStore( + patches: Automerge.Patch[], + store: TLStore, + doc: { store: Record } +) { + const changedIds = new Set(); + for (const patch of patches) { + if (patch.path[0] === "store" && patch.path.length > 1) { + changedIds.add(`${patch.path[1]}`); + } + } + + const toRemove: TLRecord["id"][] = []; + const toPut: TLRecord[] = []; + for (const id of changedIds) { + const record = doc.store[id]; + if (record === undefined) { + toRemove.push(id as TLRecord["id"]); + } else { + toPut.push(structuredClone(record) as TLRecord); + } + } + + store.mergeRemoteChanges(() => { + if (toRemove.length) store.remove(toRemove); + if (toPut.length) store.put(toPut); + }); +} diff --git a/tldraw5/src/lith/TLStoreToAutomerge.ts b/tldraw5/src/lith/TLStoreToAutomerge.ts new file mode 100644 index 0000000..efe1eab --- /dev/null +++ b/tldraw5/src/lith/TLStoreToAutomerge.ts @@ -0,0 +1,86 @@ +import type { RecordsDiff, TLRecord } from "@tldraw/tldraw"; +import type { TLDrawDoc } from "../datatype.ts"; +import { isObject, forIn, isArray, mapValues } from "lodash"; + +/** Prepares a value for storing in Automerge (deep recursively) + * For now, all it does is convert strings to RawStrings. + * This is critical for performance because TLDraw can generate large + * strings for inline assets, which create huge documents. + * There's also no support for string merging anyway in TLDraw, + * so raw strings work fine. + */ +export function tldrawValueToAutomergeValue(value: any): any { + if (Array.isArray(value)) { + return value.map(tldrawValueToAutomergeValue); + } + if (isObject(value)) { + return mapValues(value, tldrawValueToAutomergeValue); + } + return value; +} + +export function applyTLStoreChangesToAutomerge( + doc: TLDrawDoc, + changes: RecordsDiff +) { + Object.values(changes.added).forEach((record) => { + doc.store[record.id] = tldrawValueToAutomergeValue(record); + }); + + Object.values(changes.updated).forEach(([_, record]) => { + deepCompareAndUpdate(doc.store[record.id], record); + }); + + Object.values(changes.removed).forEach((record) => { + delete doc.store[record.id]; + }); +} + +function deepCompareAndUpdate(objectA: any, objectB: any) { + if (isArray(objectB)) { + if (!isArray(objectA)) { + // if objectA is not an array, replace it with objectB + objectA = objectB.map(tldrawValueToAutomergeValue); + } else { + // compare and update array elements + for (let i = 0; i < objectB.length; i++) { + if (i >= objectA.length) { + objectA.push(tldrawValueToAutomergeValue(objectB[i])); + } else { + if (isObject(objectB[i]) || isArray(objectB[i])) { + // if element is an object or array, recursively compare and update + deepCompareAndUpdate(objectA[i], objectB[i]); + } else if (objectA[i] !== objectB[i]) { + // update the element + objectA[i] = tldrawValueToAutomergeValue(objectB[i]); + } + } + } + // remove extra elements + if (objectA.length > objectB.length) { + objectA.splice(objectB.length); + } + } + } else if (isObject(objectB)) { + forIn(objectB, (value: any, key: any) => { + if (objectA[key] === undefined) { + // if key is not in objectA, add it + objectA[key] = tldrawValueToAutomergeValue(value); + } else { + if (isObject(value) || isArray(value)) { + // if value is an object or array, recursively compare and update + deepCompareAndUpdate(objectA[key], value); + } else if (objectA[key] !== value) { + // update the value + objectA[key] = tldrawValueToAutomergeValue(value); + } + } + }); + forIn(objectA, (_: any, key: string) => { + if ((objectB as any)[key] === undefined) { + // if key is not in objectB, remove it + delete objectA[key]; + } + }); + } +} diff --git a/tldraw5/src/lith/index.ts b/tldraw5/src/lith/index.ts new file mode 100644 index 0000000..719688f --- /dev/null +++ b/tldraw5/src/lith/index.ts @@ -0,0 +1,13 @@ +import type { TLStoreSnapshot } from "@tldraw/tldraw"; +import { createTLStore, defaultShapeUtils } from "@tldraw/tldraw"; + +/* a similar pattern to other automerge init functions */ +export function init(doc: TLStoreSnapshot) { + const store = createTLStore({ + shapeUtils: defaultShapeUtils, + }); + const snapshot = store.getStoreSnapshot(); + Object.assign(doc, snapshot); +} + +export * from "./useAutomergeStore"; diff --git a/tldraw5/src/lith/useAutomergeStore.ts b/tldraw5/src/lith/useAutomergeStore.ts new file mode 100644 index 0000000..7e47429 --- /dev/null +++ b/tldraw5/src/lith/useAutomergeStore.ts @@ -0,0 +1,246 @@ +import { + type TLAnyShapeUtilConstructor, + type TLRecord, + type TLStoreWithStatus, + createTLStore, + type TLAnyBindingUtilConstructor, + type HistoryEntry, + defaultUserPreferences, + createUserId, + type TLUser, + createPresenceStateDerivation, + InstancePresenceRecordType, + computed, + react, + type TLStoreSnapshot, + type TLInstancePresence, + sortById, +} from "@tldraw/tldraw"; +import { useEffect, useState } from "react"; +import { + type DocHandle, + type DocHandleChangePayload, +} from "@automerge/automerge-repo/slim"; +import { usePresence } from "@automerge/automerge-repo-react-hooks"; + +import { applyAutomergePatchesToTLStore } from "./AutomergeToTLStore.js"; +import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js"; + +// `shapeUtils` / `bindingUtils` are the complete lists — a document script's +// `config.js` may have added to or replaced the defaults, and the store has to +// agree with the editor about them or records of a custom type fail validation. +export function useAutomergeStore({ + handle, + shapeUtils = [], + bindingUtils = [], + readOnly = false, +}: { + handle: DocHandle; + userId: string; + shapeUtils?: TLAnyShapeUtilConstructor[]; + bindingUtils?: TLAnyBindingUtilConstructor[]; + readOnly?: boolean; +}): TLStoreWithStatus { + const [store] = useState(() => { + const store = createTLStore({ shapeUtils, bindingUtils }); + return store; + }); + + const [storeWithStatus, setStoreWithStatus] = useState({ + status: "loading", + }); + + /* -------------------- TLDraw <--> Automerge -------------------- */ + useEffect(() => { + const unsubs: (() => void)[] = []; + + // A hacky workaround to prevent local changes from being applied twice + // once into the automerge doc and then back again. + let preventPatchApplications = false; + + /* TLDraw to Automerge */ + function syncStoreChangesToAutomergeDoc({ + changes, + }: HistoryEntry) { + preventPatchApplications = true; + handle.change((doc) => { + applyTLStoreChangesToAutomerge(doc, changes); + }); + preventPatchApplications = false; + } + + // A read-only (history-pinned) handle is at fixed heads and rejects + // `handle.change`, so never forward store edits back to Automerge. + if (!readOnly) { + unsubs.push( + store.listen(syncStoreChangesToAutomergeDoc, { + source: "user", + scope: "document", + }) + ); + } + + /* Automerge to TLDraw */ + const syncAutomergeDocChangesToStore = ({ + patches, + doc, + }: DocHandleChangePayload) => { + if (preventPatchApplications) return; + applyAutomergePatchesToTLStore(patches, store, doc ?? handle.doc()); + }; + + handle.on("change", syncAutomergeDocChangesToStore); + unsubs.push(() => handle.off("change", syncAutomergeDocChangesToStore)); + + /* Load the initial document snapshot into the store. */ + // TODO: need to think through the various status possibilities here and how they map + const doc = handle.doc(); + if (!doc) throw new Error("Document not found"); + if (!doc.store) throw new Error("Document store not initialized"); + + store.mergeRemoteChanges(() => { + store.loadStoreSnapshot({ + store: structuredClone(doc.store), + schema: structuredClone(doc.schema), + }); + }); + + setStoreWithStatus({ + store, + status: "synced-remote", + connectionStatus: "online", + }); + + return () => { + unsubs.forEach((fn) => fn()); + unsubs.length = 0; + }; + }, [handle, store, readOnly]); + + return storeWithStatus; +} + +// Ephemeral state shared with peers: our tldraw presence record, or null if window is not focused +type PresenceChannels = { presence: TLInstancePresence | null }; + +export function useAutomergePresence({ + handle, + store, + userMetadata, + element, +}: { + handle: DocHandle; + store: TLStoreWithStatus; + userMetadata: any; + element: HTMLElement; +}) { + const innerStore = store?.store; + + const { userId, name, color } = userMetadata; + + const isCursorActive = useIsCursorActive(element); + + const { peerStates, update, stop } = usePresence({ + handle, + initialState: { presence: null }, + }); + + useEffect(() => { + if (!innerStore) return; + + // Records are keyed by peerId so multiple sessions of the same user stay distinct. + const toPut: TLRecord[] = peerStates.peers + .filter((peer) => peer.value?.presence) + .map((peer) => ({ + ...peer.value.presence!, + id: InstancePresenceRecordType.createId(peer.peerId), + })); + + const toRemove = innerStore.query + .records("instance_presence") + .get() + .sort(sortById) + .map((record) => record.id) + .filter((id) => !toPut.find((record) => record.id === id)); + + if (toRemove.length) innerStore.remove(toRemove); + if (toPut.length) innerStore.put(toPut); + }, [innerStore, peerStates]); + + useEffect(() => { + if (!innerStore) return; + // tldraw 5 derives presence from a `TLUser` signal rather than from local + // user preferences, so the identity we get from the Patchwork contact goes + // straight in instead of round-tripping through `setUserPreferences`. + const user = computed("user", () => ({ + typeName: "user", + id: createUserId(userId), + name, + color: color ?? defaultUserPreferences.color, + imageUrl: "", + meta: {}, + })); + + const presenceDerivation = createPresenceStateDerivation(user)(innerStore); + + // Closing the page sends a goodbye, which removes our record from peers. + window.addEventListener("pagehide", stop); + + let cancelled = false; + let dispose: (() => void) | undefined; + + if (isCursorActive) { + dispose = react("broadcast presence", () => { + const presence = presenceDerivation.get(); + // rAF throttles broadcasts to frame rate. + requestAnimationFrame(() => { + if (cancelled) return; + // Stamp activity so becoming active again shows the cursor without + // waiting for a pointer move to refresh the store's stale timestamp. + update( + "presence", + presence ? { ...presence, lastActivityTimestamp: Date.now() } : null + ); + }); + }); + } else { + // Keep the record but clear the cursor when blurred or pointer leaves + const presence = presenceDerivation.get(); + update("presence", presence ? { ...presence, cursor: null } : null); + } + + return () => { + // Invalidate pending rAF broadcasts so stale updates don't overwrite deactivated cursors. + cancelled = true; + dispose?.(); + window.removeEventListener("pagehide", stop); + }; + }, [innerStore, userId, name, color, update, stop, isCursorActive]); +} + +// The local cursor is active only when the window is focused and the pointer is over this tool. +function useIsCursorActive(element: HTMLElement) { + const [isFocused, setIsFocused] = useState(() => document.hasFocus()); + const [isPointerOver, setIsPointerOver] = useState(() => + element.matches(":hover") + ); + + useEffect(() => { + const onFocus = () => setIsFocused(true); + const onBlur = () => setIsFocused(false); + const onEnter = () => setIsPointerOver(true); + const onLeave = () => setIsPointerOver(false); + window.addEventListener("focus", onFocus); + window.addEventListener("blur", onBlur); + element.addEventListener("mouseenter", onEnter); + element.addEventListener("mouseleave", onLeave); + return () => { + window.removeEventListener("focus", onFocus); + window.removeEventListener("blur", onBlur); + element.removeEventListener("mouseenter", onEnter); + element.removeEventListener("mouseleave", onLeave); + }; + }, [element]); + + return isFocused && isPointerOver; +} diff --git a/tldraw5/src/main.css b/tldraw5/src/main.css new file mode 100644 index 0000000..cc20313 --- /dev/null +++ b/tldraw5/src/main.css @@ -0,0 +1,67 @@ +@import url("@tldraw/tldraw/tldraw.css"); + +.tlui-layout { + padding: 0px; +} + +/* Diff highlighting: glows hug each shape's outline (no bounding boxes). */ +.tl-diff-added { + filter: drop-shadow(0 0 6px #22c55e) drop-shadow(0 0 3px #22c55e); +} +.tl-diff-changed { + filter: drop-shadow(0 0 6px #eab308) drop-shadow(0 0 3px #eab308); +} +/* Deleted shapes are re-inserted as locked ghosts. The fade-out (opacity 0.1) + is set via the shape record's `opacity` prop in tool.tsx, because tldraw + writes `opacity` as an inline style that a CSS class can't override. Here we + only tint the glow red so they read as "removed". */ +.tl-diff-deleted { + filter: drop-shadow(0 0 5px #ef4444) drop-shadow(0 0 2px #ef4444); +} + +.tldraw5-migrate { + --gap: 1rem; + + display: flex; + flex-direction: column; + gap: var(--gap); + align-items: start; + max-inline-size: 48ch; + margin: auto; + padding: calc(var(--gap) * 2); + color: var(--text-color, inherit); + font-family: var(--font-family, system-ui, sans-serif); +} + +.tldraw5-migrate h2 { + margin: 0; + font-size: 1.25rem; +} + +.tldraw5-migrate p { + margin: 0; + line-height: 1.5; +} + +.tldraw5-migrate button { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color, currentColor); + border-radius: var(--radius, 6px); + background: var(--button-background, transparent); + color: inherit; + font: inherit; + cursor: pointer; +} + +.tldraw5-migrate button[disabled] { + cursor: progress; + opacity: 0.6; +} + +.tldraw5-migrate [data-error] { + color: #ef4444; +} + +.tldraw5-migrate [data-note] { + opacity: 0.7; +} diff --git a/tldraw5/src/main.ts b/tldraw5/src/main.ts new file mode 100644 index 0000000..b867b77 --- /dev/null +++ b/tldraw5/src/main.ts @@ -0,0 +1,43 @@ +import type { ToolImplementation } from "@inkandswitch/patchwork-plugins"; + +import "./main.css"; + +function addStyles(textContent: string, element: HTMLElement = document.head) { + const id = "tldraw5-styles"; + const el = element.querySelector(`#${id}`) ?? document.createElement("style"); + Object.assign(el, { textContent, id }); + element.append(el); +} + +async function loadStyles() { + const url = new URL("./main.css", import.meta.url); + return (await fetch(url)).text(); +} + +export const plugins = [ + { + type: "patchwork:datatype", + id: "tldraw5", + name: "tldraw", + icon: "PenLine", + async load() { + return (await import("./datatype.ts")).datatype; + }, + }, + { + type: "patchwork:tool", + id: "tldraw5", + name: "tldraw", + // tldraw4 is listed so a v4 document can be opened here and offered the + // one-way migration (see `migrate.tsx`). + supportedDatatypes: ["tldraw5", "tldraw4"], + async load(): Promise { + const { render } = await import("./tool.tsx"); + const styles = await loadStyles(); + return (handle, element) => { + addStyles(styles); + return render(handle, element); + }; + }, + }, +]; diff --git a/tldraw5/src/migrate.tsx b/tldraw5/src/migrate.tsx new file mode 100644 index 0000000..89c568d --- /dev/null +++ b/tldraw5/src/migrate.tsx @@ -0,0 +1,119 @@ +// A tldraw4 document opened in this tool is offered a one-way migration to +// tldraw5. The migration runs tldraw's own schema migrations by loading the +// stored snapshot into a v5 store, then writes the migrated records back and +// retypes the document. +// +// It has to be offered rather than done implicitly: mounting a v5 store on a v4 +// document migrates the records in memory, and the store->Automerge sync would +// then write them back the moment anything moved. The gate in `tool.tsx` runs +// before that store exists. + +import { useState } from "react"; +import { createTLStore, defaultShapeUtils } from "@tldraw/tldraw"; +import { + getRegistry, + type DatatypeDescription, +} from "@inkandswitch/patchwork-plugins"; +import type { DocHandle } from "@automerge/react"; + +import type { TLDrawDoc } from "./datatype.ts"; +import { PatchworkDocShapeUtil } from "./PatchworkDocShape.tsx"; + +export const TLDRAW4_TYPE = "tldraw4"; +export const TLDRAW5_TYPE = "tldraw5"; + +type PatchworkMeta = { + "@patchwork"?: { type?: string; suggestedImportUrl?: string }; +}; + +export function getDocType(doc: unknown): string | undefined { + return (doc as PatchworkMeta | undefined)?.["@patchwork"]?.type; +} + +function tldraw5ImportUrl(): string | undefined { + try { + const registry = getRegistry("patchwork:datatype"); + return registry.get(TLDRAW5_TYPE)?.importUrl; + } catch { + return undefined; + } +} + +export function migrateToTldraw5(handle: DocHandle) { + const doc = handle.doc(); + if (!doc) throw new Error("document not loaded"); + + const store = createTLStore({ + shapeUtils: [...defaultShapeUtils, PatchworkDocShapeUtil], + }); + store.loadStoreSnapshot({ + store: JSON.parse(JSON.stringify(doc.store)), + schema: JSON.parse(JSON.stringify(doc.schema)), + }); + + // Through JSON so the snapshot carries no `undefined`, which Automerge + // refuses to store. + const snapshot = JSON.parse( + JSON.stringify(store.getStoreSnapshot()) + ) as TLDrawDoc; + const importUrl = tldraw5ImportUrl(); + + handle.change((d) => { + d.store = snapshot.store; + d.schema = snapshot.schema; + const meta = (d as TLDrawDoc & PatchworkMeta)["@patchwork"]; + if (meta) { + meta.type = TLDRAW5_TYPE; + if (importUrl) meta.suggestedImportUrl = importUrl; + } + }); +} + +export function MigratePrompt({ + handle, + readOnly, +}: { + handle: DocHandle; + readOnly: boolean; +}) { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const onMigrate = () => { + setBusy(true); + setError(null); + try { + migrateToTldraw5(handle); + } catch (err) { + console.error("[tldraw5] migration failed", err); + setError(String(err)); + setBusy(false); + } + }; + + return ( +
+

This is a tldraw 4 document

+

+ Migrating rewrites its shapes to the tldraw 5 schema and retypes the + document, so the tldraw 4 tool will no longer open it. Anyone else with + this document is affected too. +

+

+ There is no undo for this — copy the document first if + you want to keep a tldraw 4 version. +

+ {readOnly ? ( +

+ This view is pinned to a point in history and can't be written to. + Open the document at its latest version to migrate it. +

+ ) : ( + + )} + {error &&

{error}

} +
+ ); +} diff --git a/tldraw5/src/script-helpers.ts b/tldraw5/src/script-helpers.ts new file mode 100644 index 0000000..5c43f44 --- /dev/null +++ b/tldraw5/src/script-helpers.ts @@ -0,0 +1,191 @@ +// `ctx.helpers` — the editor-bound convenience bag a document script receives, +// mirroring the desktop app's `ScriptHelpers`. Pure tldraw primitives are not +// in here; scripts import those from 'tldraw' directly. + +import { + Box, + createShapeId, + renderPlaintextFromRichText, + type Editor, + type TLShape, + type TLShapeId, + type TLShapePartial, +} from "@tldraw/tldraw"; + +type ShapeRef = string | { id?: string; shapeId?: string }; + +function refToId(ref: ShapeRef): TLShapeId { + if (typeof ref === "string") return ref as TLShapeId; + return (ref.id ?? ref.shapeId) as TLShapeId; +} + +export function makeScriptHelpers(editor: Editor) { + const createShapeIfMissing = (shape: TLShapePartial & { id: TLShapeId }) => { + if (editor.getShape(shape.id)) return false; + editor.createShape(shape); + return true; + }; + + return { + richTextToPlainText(value: unknown): string { + if (typeof value === "string") return value; + if (!value) return ""; + try { + return renderPlaintextFromRichText(editor, value as never); + } catch { + return ""; + } + }, + + createShapeIfMissing, + + createShapesIfMissing(shapes: Array) { + const created: TLShapeId[] = []; + for (const shape of shapes) { + if (createShapeIfMissing(shape)) created.push(shape.id); + } + return created; + }, + + translateShapes(refs: ShapeRef[], dx: number, dy: number) { + const moved: TLShapeId[] = []; + const partials: TLShapePartial[] = []; + for (const ref of refs) { + const id = refToId(ref); + const shape = editor.getShape(id); + if (!shape) continue; + partials.push({ + id, + type: shape.type, + x: shape.x + dx, + y: shape.y + dy, + } as TLShapePartial); + moved.push(id); + } + if (partials.length) { + editor.run(() => editor.updateShapes(partials), { history: "ignore" }); + } + return moved; + }, + + // Fires only when the anchor alone moved: a resize or rotation also shifts + // x/y, and a multi-shape drag would move script-owned internals twice. + onShapeTranslate( + shapeRef: string | TLShapeId, + handler: (info: { + dx: number; + dy: number; + prev: TLShape; + next: TLShape; + }) => void, + options?: { signal?: AbortSignal } + ) { + const id = shapeRef as TLShapeId; + const unlisten = editor.store.listen( + ({ changes }) => { + const updated = Object.values(changes.updated); + if (updated.length !== 1) return; + const [prev, next] = updated[0] as [TLShape, TLShape]; + if (prev.id !== id || next.typeName !== "shape") return; + const dx = next.x - prev.x; + const dy = next.y - prev.y; + if (dx === 0 && dy === 0) return; + if ( + prev.rotation !== next.rotation || + JSON.stringify(prev.props) !== JSON.stringify(next.props) + ) { + return; + } + handler({ dx, dy, prev, next }); + }, + { source: "user", scope: "document" } + ); + options?.signal?.addEventListener("abort", unlisten, { once: true }); + return unlisten; + }, + + createArrowBetweenShapes( + fromShapeId: string, + toShapeId: string, + options: { + bend?: number; + arrowheadStart?: string; + arrowheadEnd?: string; + richText?: unknown; + } = {} + ) { + const id = createShapeId(); + editor.createShape({ + id, + type: "arrow", + props: { + bend: options.bend ?? 0, + arrowheadStart: options.arrowheadStart ?? "none", + arrowheadEnd: options.arrowheadEnd ?? "arrow", + ...(options.richText ? { richText: options.richText } : {}), + }, + } as TLShapePartial); + editor.createBindings([ + { + type: "arrow", + fromId: id, + toId: fromShapeId as TLShapeId, + props: { terminal: "start" }, + }, + { + type: "arrow", + fromId: id, + toId: toShapeId as TLShapeId, + props: { terminal: "end" }, + }, + ] as never); + return id; + }, + + boxShapes( + refs: ShapeRef[], + options: { + shapeId?: string; + color?: string; + fill?: string; + text?: string; + } = {} + ) { + const boxes = refs + .map((ref) => editor.getShapePageBounds(refToId(ref))) + .filter(Boolean) as Box[]; + if (!boxes.length) return editor; + + const bounds = Box.Common(boxes); + const padding = 16; + editor.createShape({ + id: (options.shapeId as TLShapeId) ?? createShapeId(), + type: "geo", + x: bounds.x - padding, + y: bounds.y - padding, + props: { + geo: "rectangle", + w: bounds.w + padding * 2, + h: bounds.h + padding * 2, + color: options.color ?? "black", + fill: options.fill ?? "none", + }, + } as TLShapePartial); + return editor; + }, + + // The desktop app's lints are heuristics over its own layout rules; rather + // than invent different ones, this reports nothing. + getLints() { + return { lints: [] as Array<{ type: string; shapeIds: string[]; message: string }> }; + }, + + // Patchwork persists through Automerge continuously — there is no file to + // save, and the desktop contract forbids scripts calling this anyway. + async saveDoc() { + throw new Error("saveDoc() is not available outside the tldraw desktop app"); + }, + }; +} + +export type ScriptHelpers = ReturnType; diff --git a/tldraw5/src/script.ts b/tldraw5/src/script.ts new file mode 100644 index 0000000..bf7e15a --- /dev/null +++ b/tldraw5/src/script.ts @@ -0,0 +1,516 @@ +// Document scripts. A `.tldraw` archive can carry a `script/**` tree whose +// `main.js` default-exports a function that runs against the live editor once +// the canvas mounts (and whose siblings it may import). On import each file +// becomes its own Patchwork `file` document, so the script is editable in +// Patchwork like any other file. +// +// Running one means turning those documents back into modules. There is no +// importmap on this page for `tldraw`, and relative specifiers point at +// documents rather than paths, so each file is rewritten and handed to the +// browser as a blob module. + +import type { DocHandle, Repo } from "@automerge/react"; +import type { + DocLink, + FolderDoc, + UnixFileEntry, +} from "@inkandswitch/patchwork-filesystem"; +import type { Editor } from "@tldraw/tldraw"; +import * as tldrawSdk from "@tldraw/tldraw"; +import * as reactSdk from "react"; +import * as reactJsxRuntime from "react/jsx-runtime"; +import * as reactDomSdk from "react-dom"; +import * as reactDomClient from "react-dom/client"; + +import type { ZipEntries } from "./zip.ts"; +import { makeScriptHelpers, type ScriptHelpers } from "./script-helpers.ts"; + +// What a `main.js` default export receives, mirroring the desktop app's +// `MainScriptContext`. `app` is deliberately absent — it is host-provided and +// feature-detected (`ctx.app?.window`), and there is no desktop window here. +export type MainScriptContext = { + editor: Editor; + helpers: ScriptHelpers; + signal: AbortSignal; +}; + +const SCRIPT_PREFIX = "script/"; +const ENTRY = "main.js"; +const CONFIG = "config.js"; + +const TEXT_EXTENSIONS = new Set(["js", "mjs", "json", "css", "ts", "txt", "md"]); + +function extensionOf(path: string) { + const base = path.slice(path.lastIndexOf("/") + 1); + const dot = base.lastIndexOf("."); + return dot === -1 ? "" : base.slice(dot + 1).toLowerCase(); +} + +function mimeTypeOf(path: string) { + switch (extensionOf(path)) { + case "js": + case "mjs": + return "text/javascript"; + case "json": + return "application/json"; + case "css": + return "text/css"; + case "wasm": + return "application/wasm"; + default: + return "application/octet-stream"; + } +} + +/** The `script/**` entries of an archive, keyed by path relative to `script/`. */ +export function extractScriptFiles(files: ZipEntries): Map { + const script = new Map(); + for (const [name, bytes] of files) { + if (!name.startsWith(SCRIPT_PREFIX) || name.endsWith("/")) continue; + script.set(name.slice(SCRIPT_PREFIX.length), bytes); + } + return script; +} + +async function createFileDoc(repo: Repo, path: string, bytes: Uint8Array) { + const extension = extensionOf(path); + const handle = await repo.create2({ + "@patchwork": { type: "file" }, + content: TEXT_EXTENSIONS.has(extension) + ? new TextDecoder().decode(bytes) + : bytes, + extension, + mimeType: mimeTypeOf(path), + name: path.slice(path.lastIndexOf("/") + 1), + } as UnixFileEntry); + return handle.url; +} + +/** + * Stores a `script/**` tree as `DocLink[]` — a file document per file, a nested + * folder document per directory. The result goes straight onto the canvas + * document's `docs`, which is why a canvas is FolderDoc-compatible. + */ +export async function storeScriptFiles( + repo: Repo, + files: Map +): Promise { + const here: Array<[string, Uint8Array]> = []; + const directories = new Map>(); + + for (const [path, bytes] of files) { + const slash = path.indexOf("/"); + if (slash === -1) { + here.push([path, bytes]); + continue; + } + const directory = path.slice(0, slash); + const rest = path.slice(slash + 1); + const nested = directories.get(directory) ?? new Map(); + nested.set(rest, bytes); + directories.set(directory, nested); + } + + const links: DocLink[] = []; + for (const [name, bytes] of here) { + links.push({ + name, + type: "file", + url: (await createFileDoc(repo, name, bytes)) as DocLink["url"], + }); + } + for (const [name, nested] of directories) { + const handle = await repo.create2({ + "@patchwork": { type: "folder" }, + title: name, + docs: await storeScriptFiles(repo, nested), + } as FolderDoc); + links.push({ name, type: "folder", url: handle.url }); + } + + return links; +} + +/** Flattens the `DocLink[]` tree back into archive-relative path → doc url. */ +export async function resolveScriptFiles( + repo: Repo, + docs: DocLink[] | undefined, + prefix = "" +): Promise> { + const files: Record = {}; + for (const link of docs ?? []) { + const path = prefix ? `${prefix}/${link.name}` : link.name; + if (link.type === "folder") { + const handle = await repo.find(link.url); + Object.assign( + files, + await resolveScriptFiles(repo, handle.doc()?.docs, path) + ); + } else { + files[path] = link.url; + } + } + return files; +} + +/** + * The script tree as the runner needs it: where each file lives, a version that + * moves whenever a file is edited, and every document involved so the tree can + * be watched. + */ +export type ScriptTree = { + /** archive-relative path → file document url */ + files: Record; + /** archive-relative path → version, changing on every edit to that file */ + versions: Record; + /** every document in the tree, the folders included */ + handles: DocHandle[]; +}; + +export async function readScriptTree( + repo: Repo, + docs: DocLink[] | undefined, + prefix = "" +): Promise { + const tree: ScriptTree = { files: {}, versions: {}, handles: [] }; + for (const link of docs ?? []) { + const path = prefix ? `${prefix}/${link.name}` : link.name; + const handle = await repo.find(link.url); + tree.handles.push(handle as unknown as DocHandle); + if (link.type === "folder") { + const nested = await readScriptTree(repo, handle.doc()?.docs, path); + Object.assign(tree.files, nested.files); + Object.assign(tree.versions, nested.versions); + tree.handles.push(...nested.handles); + } else { + tree.files[path] = link.url; + tree.versions[path] = handle.heads().join(","); + } + } + return tree; +} + +/** + * Reads the tree and reads it again whenever any of its documents changes, so + * editing a script file in Patchwork takes effect on the canvas immediately. + * Reads are serialised: a burst of edits can't leave two readers racing to + * attach listeners to the same handles. + */ +export function watchScriptTree( + repo: Repo, + docs: DocLink[] | undefined, + onTree: (tree: ScriptTree) => void +): () => void { + const watched = new Map>(); + let stopped = false; + let reading: Promise = Promise.resolve(); + + const reread = () => { + reading = reading.then(async () => { + if (stopped) return; + const tree = await readScriptTree(repo, docs); + if (stopped) return; + const next = new Map>( + tree.handles.map((handle) => [handle.url, handle]) + ); + for (const [url, handle] of watched) { + if (next.has(url)) continue; + handle.off("change", reread); + watched.delete(url); + } + for (const [url, handle] of next) { + if (watched.has(url)) continue; + handle.on("change", reread); + watched.set(url, handle); + } + onTree(tree); + }); + }; + + reread(); + + return () => { + stopped = true; + for (const handle of watched.values()) handle.off("change", reread); + watched.clear(); + }; +} + +/** The part of a tree's state a script actually depends on. */ +export function signatureOf(deps: string[], versions: Record) { + return deps.map((path) => `${path}@${versions[path] ?? ""}`).join("|"); +} + +/** Reads the linked documents back out as archive bytes, keyed by path. */ +export async function readScriptFiles( + repo: Repo, + docs: DocLink[] | undefined +): Promise> { + const encoder = new TextEncoder(); + const files = new Map(); + for (const [path, url] of Object.entries( + await resolveScriptFiles(repo, docs) + )) { + const handle = await repo.find(url as never); + const content = handle.doc()?.content; + if (content == null) continue; + files.set( + path, + typeof content === "string" + ? encoder.encode(content) + : new Uint8Array(content as Uint8Array) + ); + } + return files; +} + +export function hasScript(files: Record) { + return ENTRY in files; +} + +function resolvePath(from: string, specifier: string) { + const base = from.slice(0, from.lastIndexOf("/") + 1); + const parts = (base + specifier).split("/"); + const out: string[] = []; + for (const part of parts) { + if (part === "." || part === "") continue; + if (part === "..") out.pop(); + else out.push(part); + } + return out.join("/"); +} + +// Bare specifiers a script may import. There is no importmap on this page, so +// each is served as a generated module re-exporting the copy this tool already +// bundles — the script gets the same instances the canvas is running on rather +// than a second copy of React or the SDK. +const BUNDLED: Record> = { + tldraw: tldrawSdk, + "@tldraw/tldraw": tldrawSdk, + react: reactSdk, + "react/jsx-runtime": reactJsxRuntime, + "react-dom": reactDomSdk, + "react-dom/client": reactDomClient, +}; + +// A namespace built from CommonJS carries a `default` key, and `export const +// default = …` is a syntax error. +const NOT_EXPORTABLE = new Set(["default", "class", "function", "import", "new"]); + +function shimUrl( + specifier: string, + shims: Map, + revoke: string[] +) { + const cached = shims.get(specifier); + if (cached) return cached; + + const namespace = BUNDLED[specifier]; + const key = `__tldraw5_shim_${specifier.replace(/\W/g, "_")}`; + (globalThis as Record)[key] = namespace; + + const names = Object.keys(namespace).filter( + (name) => /^[A-Za-z_$][\w$]*$/.test(name) && !NOT_EXPORTABLE.has(name) + ); + const source = [ + `const m = globalThis[${JSON.stringify(key)}];`, + `export default m.default ?? m;`, + ...names.map((name) => `export const ${name} = m[${JSON.stringify(name)}];`), + ].join("\n"); + + const url = URL.createObjectURL( + new Blob([source], { type: "text/javascript" }) + ); + revoke.push(url); + shims.set(specifier, url); + return url; +} + +// `from '…'` / `import('…')` specifiers only — a bare word match would rewrite +// unrelated strings inside a bundled vendor file. +const SPECIFIER = + /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(["'])([^"']+)\2/g; + +/** + * Builds a blob module for `path`, recursively building whatever it imports. + * Cycles are not supported — a script that imports itself in a loop will throw. + */ +async function buildModule( + repo: Repo, + files: Record, + path: string, + built: Map, + shims: Map, + revoke: string[] +): Promise { + const existing = built.get(path); + if (existing) return existing; + + const url = files[path]; + if (!url) throw new Error(`script file not found: ${path}`); + + const handle = await repo.find(url as never); + const content = handle.doc()?.content; + const source = + typeof content === "string" + ? content + : new TextDecoder().decode(content as Uint8Array); + + const specifiers = new Map(); + for (const [, , , specifier] of source.matchAll(SPECIFIER)) { + if (specifiers.has(specifier)) continue; + if (specifier in BUNDLED) { + specifiers.set(specifier, shimUrl(specifier, shims, revoke)); + } else if (specifier.startsWith(".")) { + const target = resolvePath(path, specifier); + const targetUrl = files[target]; + if (!targetUrl) continue; + const cached = built.get(target); + if (cached) { + specifiers.set(specifier, cached); + continue; + } + // Non-JS siblings (wasm, json) are fetched by the script rather than + // imported, so they become plain blob urls. + const isModule = + TEXT_EXTENSIONS.has(extensionOf(target)) && + extensionOf(target) !== "json"; + const targetBlob = isModule + ? await buildModule(repo, files, target, built, shims, revoke) + : await blobUrlFor(repo, targetUrl, target, revoke); + built.set(target, targetBlob); + specifiers.set(specifier, targetBlob); + } + } + + const rewritten = source.replace( + SPECIFIER, + (match, keyword: string, quote: string, specifier: string) => { + const replacement = specifiers.get(specifier); + return replacement ? `${keyword}${quote}${replacement}${quote}` : match; + } + ); + + const blobUrl = URL.createObjectURL( + new Blob([rewritten], { type: "text/javascript" }) + ); + revoke.push(blobUrl); + built.set(path, blobUrl); + return blobUrl; +} + +async function blobUrlFor( + repo: Repo, + url: string, + path: string, + revoke: string[] +) { + const handle = await repo.find(url as never); + const content = handle.doc()?.content; + const bytes = + typeof content === "string" ? new TextEncoder().encode(content) : content; + const blobUrl = URL.createObjectURL( + new Blob([bytes as BlobPart], { type: mimeTypeOf(path) }) + ); + revoke.push(blobUrl); + return blobUrl; +} + +// The shape of the object a `config.js` receives and returns. Mirrors the +// desktop app's `TldrawConfig`: arrays of constructors the script pushes onto, +// a components map it overwrites slots in, and the editor options. +export type TldrawConfig = { + shapeUtils: unknown[]; + bindingUtils: unknown[]; + assetUtils: unknown[]; + overlayUtils: unknown[]; + tools: unknown[]; + components: Record; + options: Record; + getShapeVisibility?: unknown; + assetUrls?: unknown; + initialState?: string; +}; + +/** + * Runs `config.js` before the editor mounts, giving it the config the canvas is + * about to be built from. Built as its own module graph — `config.js` and + * `main.js` don't share instances of the files they both import, matching the + * desktop app. + */ +export async function runConfigScript( + repo: Repo, + files: Record, + config: TldrawConfig +): Promise<{ config: TldrawConfig; dispose: () => void; deps: string[] }> { + const dispose = (urls: string[]) => () => { + for (const url of urls) URL.revokeObjectURL(url); + }; + + if (!(CONFIG in files)) return { config, dispose: dispose([]), deps: [] }; + + const revoke: string[] = []; + const shims = new Map(); + const built = new Map(); + + try { + const url = await buildModule(repo, files, CONFIG, built, shims, revoke); + const module = (await import(/* @vite-ignore */ url)) as { + default?: (ctx: { config: TldrawConfig }) => TldrawConfig | Promise; + }; + const result = (await module.default?.({ config })) ?? config; + return { config: result, dispose: dispose(revoke), deps: [...built.keys()] }; + } catch (error) { + dispose(revoke)(); + throw error; + } +} + +/** + * Runs a document's script against the editor. Returns the files it was built + * from — so a caller can re-run it when one of them is edited — and a cleanup + * that aborts the script's signal and revokes the blob urls. + */ +export async function runScript( + repo: Repo, + files: Record, + editor: Editor +): Promise<{ dispose: () => void; deps: string[] }> { + const revoke: string[] = []; + const shims = new Map(); + const built = new Map(); + const aborter = new AbortController(); + + try { + const entryUrl = await buildModule( + repo, + files, + ENTRY, + built, + shims, + revoke + ); + const module = (await import(/* @vite-ignore */ entryUrl)) as { + default?: (ctx: MainScriptContext) => unknown; + }; + // `signal` is the script's teardown hook — it aborts when the canvas + // unmounts, the same point at which the desktop app aborts it. + await module.default?.({ + editor, + helpers: makeScriptHelpers(editor), + signal: aborter.signal, + }); + } catch (error) { + aborter.abort(); + for (const url of revoke) URL.revokeObjectURL(url); + throw error; + } + + return { + deps: [...built.keys()], + dispose: () => { + aborter.abort(); + for (const url of revoke) URL.revokeObjectURL(url); + }, + }; +} diff --git a/tldraw5/src/sql-js.d.ts b/tldraw5/src/sql-js.d.ts new file mode 100644 index 0000000..b1315d8 --- /dev/null +++ b/tldraw5/src/sql-js.d.ts @@ -0,0 +1,26 @@ +declare module "sql.js" { + export type SqlValue = string | number | Uint8Array | null; + + export interface Statement { + step(): boolean; + get(): SqlValue[]; + run(params?: SqlValue[]): void; + free(): void; + } + + export interface Database { + run(sql: string, params?: SqlValue[]): void; + prepare(sql: string): Statement; + export(): Uint8Array; + close(): void; + } + + export interface SqlJs { + Database: new (bytes?: Uint8Array) => Database; + } + + export default function initSqlJs(config?: { + wasmBinary?: Uint8Array; + locateFile?: (file: string) => string; + }): Promise; +} diff --git a/tldraw5/src/tldr-archive.ts b/tldraw5/src/tldr-archive.ts new file mode 100644 index 0000000..0961c9a --- /dev/null +++ b/tldraw5/src/tldr-archive.ts @@ -0,0 +1,230 @@ +// Read/write the `.tldraw` archive format used by offline.tldraw.com: a zip +// holding a SQLite database of the store records, plus metadata and session +// JSON. Record state is stored as JSON text keyed by record id, which maps +// directly onto our `TLDrawDoc` (`store` + `schema`). + +import initSqlJs from "sql.js"; +import wasmBinary from "sql.js/dist/sql-wasm.wasm"; +import { + createTLStore, + defaultShapeUtils, + type SerializedSchema, + type SerializedStore, + type TLRecord, +} from "@tldraw/tldraw"; + +import { unzip, zip, type ZipEntries } from "./zip.ts"; + +export type TldrArchive = { + displayName: string; + store: SerializedStore; + schema: SerializedSchema; + // The raw zip entries, so the caller can pick up `script/**` and anything + // else the archive carries that isn't part of the store. + files: ZipEntries; +}; + +const SCHEMA_SQL = ` +CREATE TABLE documents ( + id TEXT PRIMARY KEY, + state BLOB NOT NULL, + lastChangedClock INTEGER NOT NULL +); +CREATE INDEX idx_documents_lastChangedClock ON documents(lastChangedClock); +CREATE TABLE tombstones ( + id TEXT PRIMARY KEY, + clock INTEGER NOT NULL +); +CREATE INDEX idx_tombstones_clock ON tombstones(clock); +CREATE TABLE metadata ( + migrationVersion INTEGER NOT NULL, + documentClock INTEGER NOT NULL, + tombstoneHistoryStartsAtClock INTEGER NOT NULL, + schema TEXT NOT NULL +); +`; + +const MIGRATION_VERSION = 2; + +let sqlPromise: ReturnType | null = null; +function loadSql() { + return (sqlPromise ??= initSqlJs({ wasmBinary })); +} + +function text(value: unknown): string { + if (typeof value === "string") return value; + if (value instanceof Uint8Array) return new TextDecoder().decode(value); + throw new Error("expected text column"); +} + +export async function readTldrArchive( + bytes: Uint8Array, + fallbackName: string +): Promise { + const files = await unzip(bytes); + + const dbBytes = files.get("db.sqlite"); + if (!dbBytes) throw new Error("archive has no db.sqlite"); + + const SQL = await loadSql(); + const db = new SQL.Database(dbBytes); + try { + const store = {} as SerializedStore; + const documents = db.prepare("SELECT id, state FROM documents"); + while (documents.step()) { + const [id, state] = documents.get(); + (store as Record)[text(id)] = JSON.parse(text(state)); + } + documents.free(); + + const metadata = db.prepare("SELECT schema FROM metadata"); + if (!metadata.step()) throw new Error("archive has no schema"); + const schema = JSON.parse(text(metadata.get()[0])) as SerializedSchema; + metadata.free(); + + return { + displayName: displayName(files) ?? fallbackName, + files, + ...loadable(store, schema), + }; + } finally { + db.close(); + } +} + +// An archive written by a newer tldraw than the one we bundle can name sequence +// versions we have no migrators for and record types we have no definition for; +// either one makes the whole snapshot fail to load. Clamping each sequence to +// the highest version we know and dropping unknown record types lets older +// archives migrate normally and newer ones load as-is — the same trade the paste +// handler makes in `tool.tsx`. +function loadable(store: SerializedStore, schema: SerializedSchema) { + const local = createTLStore({ shapeUtils: defaultShapeUtils }); + const ourSequences = ( + local.getStoreSnapshot().schema as SerializedSchema & { + sequences: Record; + } + ).sequences; + const ourTypes = local.schema.types as Record; + + const theirSequences = ( + schema as SerializedSchema & { sequences?: Record } + ).sequences; + + const sequences: Record = {}; + for (const [name, version] of Object.entries(theirSequences ?? {})) { + const known = ourSequences[name]; + if (known === undefined) continue; + sequences[name] = Math.min(version, known); + } + + const kept = {} as Record; + for (const [id, record] of Object.entries(store) as [string, TLRecord][]) { + if (!(record.typeName in ourTypes)) continue; + kept[id] = record; + } + + return { + store: kept as SerializedStore, + schema: (theirSequences + ? { ...schema, sequences } + : schema) as SerializedSchema, + }; +} + +function displayName(files: ZipEntries): string | null { + const raw = files.get("metadata.json"); + if (!raw) return null; + try { + const name = JSON.parse(new TextDecoder().decode(raw)).displayName; + return typeof name === "string" && name ? name : null; + } catch { + return null; + } +} + +export type TldrArchiveOut = Omit & { + scriptFiles?: Map; +}; + +export async function writeTldrArchive( + archive: TldrArchiveOut +): Promise { + const SQL = await loadSql(); + const db = new SQL.Database(); + let dbBytes: Uint8Array; + try { + db.run(SCHEMA_SQL); + + const insert = db.prepare( + "INSERT INTO documents (id, state, lastChangedClock) VALUES (?, ?, ?)" + ); + let clock = 0; + for (const [id, record] of Object.entries(archive.store)) { + insert.run([id, JSON.stringify(record), ++clock]); + } + insert.free(); + + db.run( + "INSERT INTO metadata (migrationVersion, documentClock, tombstoneHistoryStartsAtClock, schema) VALUES (?, ?, ?, ?)", + [MIGRATION_VERSION, clock, 0, JSON.stringify(archive.schema)] + ); + + dbBytes = db.export(); + } finally { + db.close(); + } + + const encoder = new TextEncoder(); + const entries: ZipEntries = new Map(); + entries.set("db.sqlite", dbBytes); + entries.set( + "metadata.json", + encoder.encode( + JSON.stringify( + { + formatVersion: 1, + displayName: archive.displayName, + createdWith: "@tl/tldr-archive", + documentClock: countRecords(archive.store), + }, + null, + "\t" + ) + ) + ); + entries.set("session.json", encoder.encode(JSON.stringify(session(archive)))); + // The script tree round-trips, but `metadata.json`'s `script.sha256` does + // not: the desktop app's digest recipe isn't reproducible from the archive, + // so the key is left off rather than written wrong. + for (const [path, bytes] of archive.scriptFiles ?? []) { + entries.set(`script/${path}`, bytes); + } + entries.set("assets/", new Uint8Array(0)); + return zip(entries); +} + +function countRecords(store: SerializedStore) { + return Object.keys(store).length; +} + +function session(archive: { store: SerializedStore }) { + const pageIds = Object.keys(archive.store).filter((id) => + id.startsWith("page:") + ); + return { + version: 0, + currentPageId: pageIds[0] ?? "page:page", + exportBackground: true, + isFocusMode: false, + isDebugMode: false, + isToolLocked: false, + isGridMode: false, + pageStates: pageIds.map((pageId) => ({ + pageId, + camera: { x: 0, y: 0, z: 1 }, + selectedShapeIds: [], + focusedGroupId: null, + })), + }; +} diff --git a/tldraw5/src/tool.tsx b/tldraw5/src/tool.tsx new file mode 100644 index 0000000..ef73d92 --- /dev/null +++ b/tldraw5/src/tool.tsx @@ -0,0 +1,838 @@ +import type { AutomergeUrl, DocHandle } from "@automerge/automerge-repo/slim"; +import { decodeHeads, type UrlHeads } from "@automerge/automerge-repo/slim"; +import { + RepoContext, + useDocHandle, + useDocument, + useRepo, + type Repo, +} from "@automerge/react"; +import { + Tldraw, + useEditor, + getAssetInfo, + atom, + type VecLike, + type TLContent, + type TLAssetId, + type TLComponents, + type TLRecord, + type TLShapeId, + type TLStoreWithStatus, + type Editor, + type TldrawProps, + type TLAnyShapeUtilConstructor, + type TLAnyBindingUtilConstructor, + defaultShapeUtils, + defaultBindingUtils, + defaultAssetUtils, + defaultOverlayUtils, + defaultTools, +} from "@tldraw/tldraw"; +import { + useAutomergeStore, + useAutomergePresence, +} from "./lith/useAutomergeStore.ts"; +import type { TLDrawDoc } from "./datatype.ts"; +import type { DocLink } from "@inkandswitch/patchwork-filesystem"; +import type { TldrawConfig } from "./script.ts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { UnixFileEntry } from "@inkandswitch/patchwork-filesystem"; +import { automergeUrlToServiceWorkerUrl } from "@inkandswitch/patchwork-filesystem"; +import { useSubscribe } from "@inkandswitch/patchwork-providers-react"; +import { createRoot } from "react-dom/client"; +import { diffStore, type ShapeDiff } from "./diff.ts"; +import { + DiffShapeWrapper, + DiffStatusContext, + type DiffStatus, + type DiffStatusAtom, +} from "./DiffShapeWrapper.tsx"; +import { + PatchworkDocShapeUtil, + PATCHWORK_DOC_SHAPE_TYPE, + getDefaultToolId, + makeShapeId, +} from "./PatchworkDocShape.tsx"; +import { + NewDocShapeTool, + NewDocToolbar, + newDocUiOverrides, + setNewDocToolContext, +} from "./NewDocTool.tsx"; +import { isPatchworkDrag, parseDroppedDocs } from "./dnd.ts"; +import { MigratePrompt, TLDRAW4_TYPE, getDocType } from "./migrate.tsx"; +import { useColorScheme } from "./color-scheme.ts"; +import { + TldrFileContext, + TldrFileMainMenu, + importArchiveFile, + isTldrArchiveFile, +} from "./file-io.tsx"; + +// Custom shapes / tools that let a tldraw canvas embed other Patchwork +// documents. These must be registered both on the Automerge-backed store (so +// the shape type persists) and on the component (so it renders). +const customShapeUtils = [PatchworkDocShapeUtil]; +const customTools = [NewDocShapeTool]; + +// Diff baseline (fork-point heads) served by the draft overlay +// (`draft:baseline`). `heads` is `null` when there is no baseline and no +// diff is rendered (e.g. on "main"). It is `null` rather than optional so the +// value is a valid structured-cloneable `JSONValue` crossing the provider +// channel. +type Baseline = { heads: UrlHeads | null }; + +const MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/svg+xml": "svg", + "image/bmp": "bmp", + "image/tiff": "tiff", + "image/avif": "avif", + "video/mp4": "mp4", + "video/webm": "webm", + "video/quicktime": "mov", +}; + +function extensionForMimeType(mimeType: string): string { + return MIME_TO_EXT[mimeType] || mimeType.split("/")[1] || "bin"; +} + +interface ContactDoc { + type: string; + name?: string; + color?: string; +} + +function useContactInfo() { + const repo = useRepo(); + const [contactUrl, setContactUrl] = useState(); + + useEffect(() => { + const accountDocHandle = (window as any).accountDocHandle as + | DocHandle<{ contactUrl: AutomergeUrl }> + | undefined; + if (!accountDocHandle) return; + const doc = accountDocHandle.doc(); + if (doc?.contactUrl) { + setContactUrl(doc.contactUrl); + } + }, []); + + const [contactDoc] = useDocument(contactUrl); + + return { + // Compose userId as `${contactUrl}-${repo.peerId}` to uniquely identify a session; + // this prevents tldraw from hiding local cursors across multiple sessions under the same account. + userId: contactUrl ? `${contactUrl}-${repo.peerId}` : repo.peerId, + name: contactDoc?.name ?? "Anonymous", + color: contactDoc?.color, + }; +} + +// Parses a userId of the form `${contactUrl}-${peerId}` to recover contactUrl and actorId. +export function splitPresenceUserId(userId: string): { + contactUrl?: AutomergeUrl; + actorId?: string; +} { + if (!userId.startsWith("automerge:")) return { actorId: userId }; + const i = userId.indexOf("-"); + if (i === -1) return { contactUrl: userId as AutomergeUrl }; + return { + contactUrl: userId.slice(0, i) as AutomergeUrl, + actorId: userId.slice(i + 1), + }; +} + +// A tldraw4 document has to be migrated before the canvas mounts: a v5 store +// migrates the snapshot as it loads it, and the store->Automerge sync would +// then persist that silently. So the type is checked first, and an unmigrated +// document gets the prompt instead of an editor. +export function TldrawTool({ + docUrl, + element, +}: { + docUrl: AutomergeUrl; + element: HTMLElement; +}) { + const handle = useDocHandle(docUrl, { suspense: true }); + const readOnly = handle.isReadOnly(); + // Suspending: without it the doc is undefined on the first render, the type + // check wouldn't match, and the canvas would mount on a tldraw4 document and + // migrate it before this ever ran. Still reactive, so the prompt gives way to + // the canvas as soon as a migration retypes the document. + const [doc] = useDocument(docUrl, { suspense: true }); + + if (doc && getDocType(doc) === TLDRAW4_TYPE) { + return ; + } + + return ; +} + +// `config.js` builds the shape utils, tools, overlays and components the canvas +// is made of, so it has to run before anything mounts — including the Automerge +// store, which needs the same shape utils to validate records of a script's +// custom types. Nothing renders until it has. +function TldrawCanvas({ + docUrl, + element, +}: { + docUrl: AutomergeUrl; + element: HTMLElement; +}) { + const repo = useRepo(); + // Suspending: a doc that's undefined on the first render would settle the + // config as the base one — without the script's shape utils — and the store, + // created once, would then fail to validate the script's own shape types. + const [doc] = useDocument(docUrl, { suspense: true }); + const config = useScriptConfig(repo, doc?.docs); + + if (!config) return null; + + // The editor is built from the config, so a rebuilt config only takes effect + // on a fresh editor: the version is the key that remounts it. + return ( + + ); +} + +function baseConfig(): TldrawConfig { + return { + shapeUtils: [...defaultShapeUtils, ...customShapeUtils], + bindingUtils: [...defaultBindingUtils], + assetUtils: [...defaultAssetUtils], + overlayUtils: [...defaultOverlayUtils], + tools: [...defaultTools, ...customTools], + components: { + ShapeWrapper: DiffShapeWrapper, + Toolbar: NewDocToolbar, + MainMenu: TldrFileMainMenu, + }, + options: {}, + }; +} + +// Re-runs `config.js` whenever the files it imports are edited, and bumps the +// version so the canvas remounts onto the new config. +function useScriptConfig(repo: Repo, docs: DocLink[] | undefined) { + const [config, setConfig] = useState<{ + config: TldrawConfig; + version: number; + } | null>(null); + // The doc object is new on every change, so key the effect on the links — + // otherwise every canvas edit would tear the watcher down and rebuild it. + const key = docs?.length ? JSON.stringify(docs) : ""; + + useEffect(() => { + if (!key) { + setConfig({ config: baseConfig(), version: 0 }); + return; + } + + let cancelled = false; + let unwatch: (() => void) | undefined; + let current: { + deps: string[]; + signature: string; + dispose: () => void; + } | null = null; + const settle = (config: TldrawConfig) => + setConfig((previous) => ({ + config, + version: (previous?.version ?? 0) + 1, + })); + + void (async () => { + const { watchScriptTree, runConfigScript, signatureOf } = await import( + "./script.ts" + ); + if (cancelled) return; + + let queue: Promise = Promise.resolve(); + unwatch = watchScriptTree(repo, JSON.parse(key), (tree) => { + queue = queue.then(async () => { + if (cancelled) return; + // Only the files `config.js` imports matter: editing `main.js` mustn't + // remount the editor out from under it. + if ( + current && + signatureOf(current.deps, tree.versions) === current.signature + ) { + return; + } + try { + const result = await runConfigScript(repo, tree.files, baseConfig()); + if (cancelled) { + result.dispose(); + return; + } + current?.dispose(); + current = { + deps: result.deps, + signature: signatureOf(result.deps, tree.versions), + dispose: result.dispose, + }; + settle(result.config); + } catch (error) { + console.error("[tldraw5] config script failed", error); + if (cancelled || current) return; + current = { deps: [], signature: "", dispose: () => {} }; + settle(baseConfig()); + } + }); + }); + })(); + + return () => { + cancelled = true; + unwatch?.(); + current?.dispose(); + }; + }, [repo, key]); + + return config; +} + +function TldrawConfiguredCanvas({ + docUrl, + element, + config, +}: { + docUrl: AutomergeUrl; + element: HTMLElement; + config: TldrawConfig; +}) { + const handle = useDocHandle(docUrl, { suspense: true }); + // A history-pinned handle (url carries heads) is at fixed heads and rejects + // writes, so the whole tool renders read-only. + const readOnly = handle.isReadOnly(); + const contactInfo = useContactInfo(); + const colorScheme = useColorScheme(element); + const store = useAutomergeStore({ + handle, + userId: contactInfo.userId, + readOnly, + shapeUtils: config.shapeUtils as TLAnyShapeUtilConstructor[], + bindingUtils: config.bindingUtils as TLAnyBindingUtilConstructor[], + }); + + useAutomergePresence({ + handle: handle as DocHandle, + store, + userMetadata: contactInfo, + element, + }); + + const baseline = useSubscribe( + element, + { type: "draft:baseline", url: docUrl }, + { heads: null } + ); + const diff = useShapeDiff(handle, baseline.heads ?? undefined); + + // Per-tool atom feeding `DiffShapeWrapper` (read via `useValue`). Created once + // per mount so multiple tldraw tools don't share styling state. + const [statusAtom] = useState(() => + atom("diff status", new Map()) + ); + useEffect(() => { + statusAtom.set(buildStatusMap(diff)); + }, [diff, statusAtom]); + + // Deleted shapes are gone from the live store, so re-insert them as locked + // ghosts (styled `tl-diff-deleted`). They never reach Automerge — see + // `useDeletedGhosts`. + useDeletedGhosts(store, handle, diff); + + const components = config.components as TLComponents; + + const fileContext = useMemo(() => ({ handle, element }), [handle, element]); + + return ( + + + + + + + + ); +} + +// Recomputes the diff against `heads` on every doc change. `baseline.heads` +// only moves when the draft is forked (copy-on-write), so without listening to +// the handle the diff would freeze after the first computation. The bump is +// deferred to a microtask so we never recompute synchronously from inside the +// sync layer's own `handle.change` callback. +function useShapeDiff( + handle: DocHandle, + heads: UrlHeads | undefined +): ShapeDiff | null { + const [tick, setTick] = useState(0); + + useEffect(() => { + let scheduled = false; + const onChange = () => { + if (scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + setTick((t) => t + 1); + }); + }; + handle.on("change", onChange); + return () => { + handle.off("change", onChange); + }; + }, [handle]); + + return useMemo(() => { + if (!heads) return null; + const doc = handle.doc(); + if (!doc) return null; + try { + return diffStore(doc, decodeHeads(heads)); + } catch (error) { + console.warn("[tldraw5/diff] failed to compute diff", error); + return null; + } + // `tick` is intentionally a dependency: it forces recompute on doc change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handle, heads, tick]); +} + +function buildStatusMap(diff: ShapeDiff | null): Map { + const map = new Map(); + if (!diff) return map; + for (const id of diff.added) map.set(id, "added"); + for (const id of diff.changed) map.set(id, "changed"); + for (const record of diff.deleted) map.set(record.id as TLShapeId, "deleted"); + return map; +} + +// Renders deleted shapes by putting their baseline records back into the +// *tldraw* store as locked ghosts. These are applied via `mergeRemoteChanges` +// (source: "remote"), and the only TLStore->Automerge sync path listens for +// source: "user" — so ghosts never sync back to the doc. `diffStore` reads the +// Automerge doc (not the tldraw store), so ghosts also never re-enter the diff. +function useDeletedGhosts( + store: TLStoreWithStatus, + handle: DocHandle, + diff: ShapeDiff | null +) { + const ghostIds = useRef>(new Set()); + + useEffect(() => { + const inner = store.store; + if (!inner) return; + + const desired = new Map(); + if (diff) { + for (const record of diff.deleted) { + desired.set(record.id as TLShapeId, record); + } + } + + // A ghost is only safe to remove if it isn't currently a real record in the + // doc: if a deleted shape was re-added upstream, the sync layer puts the + // real record back at the same id, and removing it then would drop the real + // shape from the view. + const liveStore = (handle.doc()?.store ?? {}) as Record; + const toRemove = [...ghostIds.current].filter( + (id) => !desired.has(id) && !(id in liveStore) + ); + const toAdd: TLRecord[] = []; + for (const [id, record] of desired) { + if (!ghostIds.current.has(id)) { + // Fade the ghost via tldraw's first-class `opacity` prop. tldraw writes + // `opacity` as an inline style on the shape element (Shape.tsx), so a CSS + // class (`.tl-diff-deleted`) can't override it — the fade has to live on + // the record itself. The red "removed" glow stays in CSS as a `filter`. + toAdd.push({ ...record, isLocked: true, opacity: 0.1 } as TLRecord); + } + } + + if (toRemove.length === 0 && toAdd.length === 0) return; + + inner.mergeRemoteChanges(() => { + if (toRemove.length) inner.remove(toRemove); + if (toAdd.length) inner.put(toAdd); + }); + + for (const id of toRemove) ghostIds.current.delete(id); + for (const record of toAdd) ghostIds.current.add(record.id as TLShapeId); + }, [store, handle, diff]); +} + +function TldrawInner(props: { + docUrl: AutomergeUrl; + element: HTMLElement; + readOnly: boolean; +}) { + const key = useMemo(() => `${props.docUrl}-camera`, [props.docUrl]); + + const editor = useEditor(); + const repo = useRepo(); + + usePatchworkDrop(props.element); + useDocumentScript(props.docUrl, editor, repo); + + const onChange = useCallback(() => { + if (!editor) return; + const camstate = editor.getCameraState(); + if (camstate == "moving") { + // todo debounce? + localStorage.setItem(key, JSON.stringify(editor.getCamera())); + } + }, []); + + useEffect(() => { + if (!editor) return; + + // History-pinned views block all canvas editing (the store write-back is + // also disabled in useAutomergeStore). + editor.updateInstanceState({ isReadonly: props.readOnly }); + + // Give the NewDocTool the repo it needs to create embedded documents. + setNewDocToolContext(repo, editor); + + // Handle pasted/dropped files (images, videos) by storing them as + // UnixFileEntry automerge docs and referencing them via service-worker URLs. + editor.registerExternalAssetHandler("file", async ({ file, assetId }) => { + // Create a stable asset ID if one wasn't provided + const id = assetId ?? (`asset:${crypto.randomUUID()}` as TLAssetId); + + // Read the file bytes + const bytes = new Uint8Array(await file.arrayBuffer()); + + // Determine extension and name + const ext = extensionForMimeType(file.type); + const name = + file.name && file.name !== "image.png" + ? file.name + : `Pasted image on ${new Date().toLocaleDateString()}.${ext}`; + + // Create an automerge doc for the file + const fileHandle = repo.create(); + fileHandle.change((doc) => { + doc.content = bytes; + doc.extension = ext; + doc.mimeType = file.type; + doc.name = name; + }); + + // Build the asset using tldraw's helper to get dimensions etc. + const asset = await getAssetInfo(editor, file, id); + if (!asset) throw new Error(`unsupported asset type: ${file.type}`); + + // Point the asset's src at the service-worker URL for this doc + (asset.props as { src?: string }).src = automergeUrlToServiceWorkerUrl( + fileHandle.url + ); + + return asset; + }); + + // Override the tldraw paste handler to avoid "could not migrate content" + // errors when pasting from newer tldraw versions (e.g. tldraw.com may + // run a canary build whose schema sequence versions are ahead of ours). + editor.registerExternalContentHandler( + "tldraw", + ({ point, content }: { point?: VecLike; content: TLContent }) => { + editor.run(() => { + const selectionBoundsBefore = editor.getSelectionPageBounds(); + editor.markHistoryStoppingPoint("paste"); + + for (const shape of content.shapes) { + if (content.rootShapeIds.includes(shape.id)) { + shape.isLocked = false; + } + } + + // Replace the pasted content's schema with ours so that + // migrateStoreSnapshot sees matching versions and skips migration + // rather than failing on unknown future sequence versions. + content.schema = editor.store.schema.serialize(); + + editor.putContentOntoCurrentPage(content, { + point, + select: true, + }); + + const selectedBoundsAfter = editor.getSelectionPageBounds(); + if ( + selectionBoundsBefore && + selectedBoundsAfter && + selectionBoundsBefore.collides(selectedBoundsAfter) + ) { + editor.updateInstanceState({ isChangingStyle: true }); + } + }); + } + ); + + const existing = localStorage.getItem(key); + if (existing) { + try { + const cam = JSON.parse(existing); + editor.setCamera(cam); + } catch { + localStorage.removeItem(key); + } + } + editor.on("change", onChange); + return () => void editor.off("change", onChange); + }, [editor, props.readOnly]); + return null; +} + +// Runs the document's `script/main.js`, if it has one, on canvas mount and +// again whenever the script's own files are edited — the running script is +// aborted first, so an edit takes effect on the canvas immediately. +function useDocumentScript( + docUrl: AutomergeUrl, + editor: Editor | null, + repo: Repo +) { + const [doc] = useDocument(docUrl); + // Keyed on the links, not the doc object: `useDocument` returns a fresh object + // on every change, so keying on identity would abort the script's signal and + // tear down its listeners on every stroke. + const key = doc?.docs?.length ? JSON.stringify(doc.docs) : ""; + + useEffect(() => { + if (!editor || !key) return; + + let cancelled = false; + let unwatch: (() => void) | undefined; + let current: { + deps: string[]; + signature: string; + dispose: () => void; + } | null = null; + + void (async () => { + const { watchScriptTree, hasScript, runScript, signatureOf } = + await import("./script.ts"); + if (cancelled) return; + + let queue: Promise = Promise.resolve(); + unwatch = watchScriptTree(repo, JSON.parse(key), (tree) => { + queue = queue.then(async () => { + if (cancelled || !hasScript(tree.files)) return; + // Nothing the script was built from moved, so leave it running. + if ( + current && + signatureOf(current.deps, tree.versions) === current.signature + ) { + return; + } + current?.dispose(); + current = null; + try { + const result = await runScript(repo, tree.files, editor); + if (cancelled) { + result.dispose(); + return; + } + current = { + deps: result.deps, + signature: signatureOf(result.deps, tree.versions), + dispose: result.dispose, + }; + } catch (error) { + console.error("[tldraw5] document script failed", error); + } + }); + }); + })(); + + return () => { + cancelled = true; + unwatch?.(); + current?.dispose(); + }; + }, [editor, key, repo]); +} + +// Accept documents dragged in from the folder-tree-view / sideboard and other +// Patchwork tools, embedding each as a `patchwork-doc` shape at the drop point. +function usePatchworkDrop(element: HTMLElement) { + const editor = useEditor(); + const repo = useRepo(); + + useEffect(() => { + if (!editor || !element) return; + + // If the drop lands inside an already-embedded , let that + // inner tool handle it instead of creating a nested embed. The outer + // hosting this canvas appears at/after `element` in the + // path, so we stop the walk once we reach `element`. + const isInsideEmbeddedPatchworkView = (e: DragEvent) => { + for (const el of e.composedPath()) { + if (el === element) break; + if ((el as Element).tagName?.toLowerCase() === "patchwork-view") return true; + } + return false; + }; + + const allowDrop = (e: DragEvent) => { + if (e.dataTransfer && isPatchworkDrag(e.dataTransfer.types)) e.preventDefault(); + }; + + // A dropped `.tldraw` archive becomes its own Patchwork document, embedded + // at the drop point like any other dragged-in document. + const handleArchiveDrop = (e: DragEvent, file: File) => { + e.preventDefault(); + e.stopImmediatePropagation(); + + const dropPoint = editor.screenToPage({ x: e.clientX, y: e.clientY }); + + void (async () => { + try { + const url = await importArchiveFile(file, repo); + const shapeId = makeShapeId(url); + if (editor.getShape(shapeId)) { + editor.select(shapeId); + return; + } + editor.createShape({ + id: shapeId, + type: PATCHWORK_DOC_SHAPE_TYPE, + x: dropPoint.x, + y: dropPoint.y, + rotation: 0, + parentId: editor.getCurrentPageId(), + props: { + w: 640, + h: 480, + docUrl: url, + docName: file.name, + docType: "tldraw5", + toolId: "tldraw5", + }, + }); + editor.setSelectedShapes([shapeId]); + } catch (error) { + console.error("[tldraw5] failed to import dropped archive", error); + } + })(); + }; + + const handleDrop = (e: DragEvent) => { + const archive = Array.from(e.dataTransfer?.files ?? []).find( + isTldrArchiveFile + ); + if (archive) { + if (isInsideEmbeddedPatchworkView(e)) return; + handleArchiveDrop(e, archive); + return; + } + + if (!e.dataTransfer || !isPatchworkDrag(e.dataTransfer.types)) return; + e.preventDefault(); + if (isInsideEmbeddedPatchworkView(e)) return; + e.stopImmediatePropagation(); + + const docs = parseDroppedDocs(e.dataTransfer); + if (docs.length === 0) return; + + const dropPoint = editor.screenToPage({ x: e.clientX, y: e.clientY }); + const STAGGER = 24; + + docs.forEach((item, i) => { + const shapeId = makeShapeId(item.url); + + // Already embedded: select it rather than creating a duplicate. + if (editor.getShape(shapeId)) { + editor.select(shapeId); + return; + } + + const knownType = item.type ?? ""; + editor.createShape({ + id: shapeId, + type: PATCHWORK_DOC_SHAPE_TYPE, + x: dropPoint.x + i * STAGGER, + y: dropPoint.y + i * STAGGER, + rotation: 0, + parentId: editor.getCurrentPageId(), + props: { + w: 640, + h: 480, + docUrl: item.url, + docName: item.name ?? "Loading\u2026", + docType: knownType, + toolId: getDefaultToolId(knownType), + }, + }); + + // Resolve datatype/name from the doc when the drag payload didn't + // include them (e.g. bare-URL drags). + if (!item.type || !item.name) { + void (async () => { + try { + const handle = await repo.find<{ "@patchwork"?: { type?: string } }>(item.url); + const doc = handle.doc(); + const datatypeId = doc?.["@patchwork"]?.type ?? knownType; + if (!editor.getShape(shapeId)) return; + editor.updateShape({ + id: shapeId, + type: PATCHWORK_DOC_SHAPE_TYPE, + props: { + docName: item.name ?? datatypeId ?? item.url, + docType: datatypeId, + toolId: getDefaultToolId(datatypeId), + }, + }); + } catch (err) { + console.error("[tldraw5] failed to resolve dropped doc", err); + } + })(); + } + }); + + editor.setSelectedShapes(docs.map((d) => makeShapeId(d.url))); + }; + + element.addEventListener("dragenter", allowDrop, { capture: true }); + element.addEventListener("dragover", allowDrop, { capture: true }); + element.addEventListener("drop", handleDrop, { capture: true }); + return () => { + element.removeEventListener("dragenter", allowDrop, { capture: true }); + element.removeEventListener("dragover", allowDrop, { capture: true }); + element.removeEventListener("drop", handleDrop, { capture: true }); + }; + }, [editor, repo, element]); +} + +export function render(handle: any, element: any) { + const root = createRoot(element); + root.render( + + + + ); + return () => root.unmount(); +} diff --git a/tldraw5/src/wasm.d.ts b/tldraw5/src/wasm.d.ts new file mode 100644 index 0000000..112d429 --- /dev/null +++ b/tldraw5/src/wasm.d.ts @@ -0,0 +1,4 @@ +declare module "*.wasm" { + const bytes: Uint8Array; + export default bytes; +} diff --git a/tldraw5/src/zip.ts b/tldraw5/src/zip.ts new file mode 100644 index 0000000..c730843 --- /dev/null +++ b/tldraw5/src/zip.ts @@ -0,0 +1,140 @@ +// Minimal zip reader/writer for the `.tldraw` archive format. Writes stored +// (uncompressed) entries with a fixed DOS timestamp so archives are +// deterministic; reads both stored and deflated entries. + +export type ZipEntries = Map; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c >>> 0; + } + return table; +})(); + +function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) { + c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +async function inflateRaw(data: Uint8Array): Promise { + const stream = new Blob([data as BlobPart]) + .stream() + .pipeThrough(new DecompressionStream("deflate-raw")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +export async function unzip(bytes: Uint8Array): Promise { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + let eocd = -1; + for (let i = bytes.length - 22; i >= 0; i--) { + if (view.getUint32(i, true) === 0x06054b50) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error("not a zip archive"); + + const count = view.getUint16(eocd + 10, true); + let offset = view.getUint32(eocd + 16, true); + + const entries: ZipEntries = new Map(); + const decoder = new TextDecoder(); + + for (let i = 0; i < count; i++) { + if (view.getUint32(offset, true) !== 0x02014b50) { + throw new Error("corrupt zip central directory"); + } + const method = view.getUint16(offset + 10, true); + const compressedSize = view.getUint32(offset + 20, true); + const nameLength = view.getUint16(offset + 28, true); + const extraLength = view.getUint16(offset + 30, true); + const commentLength = view.getUint16(offset + 32, true); + const localOffset = view.getUint32(offset + 42, true); + const name = decoder.decode( + bytes.subarray(offset + 46, offset + 46 + nameLength) + ); + + const localNameLength = view.getUint16(localOffset + 26, true); + const localExtraLength = view.getUint16(localOffset + 28, true); + const start = localOffset + 30 + localNameLength + localExtraLength; + const data = bytes.subarray(start, start + compressedSize); + + if (method === 0) { + entries.set(name, data); + } else if (method === 8) { + entries.set(name, await inflateRaw(data)); + } else { + throw new Error(`unsupported zip compression method ${method}`); + } + + offset += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} + +export function zip(entries: ZipEntries): Uint8Array { + const encoder = new TextEncoder(); + const locals: Uint8Array[] = []; + const centrals: Uint8Array[] = []; + let offset = 0; + + for (const [name, data] of entries) { + const nameBytes = encoder.encode(name); + const crc = crc32(data); + const isDirectory = name.endsWith("/"); + + const local = new Uint8Array(30 + nameBytes.length + data.length); + const localView = new DataView(local.buffer); + localView.setUint32(0, 0x04034b50, true); + localView.setUint16(4, 20, true); + localView.setUint32(14, crc, true); + localView.setUint32(18, data.length, true); + localView.setUint32(22, data.length, true); + localView.setUint16(26, nameBytes.length, true); + local.set(nameBytes, 30); + local.set(data, 30 + nameBytes.length); + locals.push(local); + + const central = new Uint8Array(46 + nameBytes.length); + const centralView = new DataView(central.buffer); + centralView.setUint32(0, 0x02014b50, true); + centralView.setUint16(4, 20, true); + centralView.setUint16(6, 20, true); + centralView.setUint32(16, crc, true); + centralView.setUint32(20, data.length, true); + centralView.setUint32(24, data.length, true); + centralView.setUint16(28, nameBytes.length, true); + centralView.setUint32(38, isDirectory ? 0x10 : 0, true); + centralView.setUint32(42, offset, true); + central.set(nameBytes, 46); + centrals.push(central); + + offset += local.length; + } + + const centralSize = centrals.reduce((n, c) => n + c.length, 0); + const end = new Uint8Array(22); + const endView = new DataView(end.buffer); + endView.setUint32(0, 0x06054b50, true); + endView.setUint16(8, centrals.length, true); + endView.setUint16(10, centrals.length, true); + endView.setUint32(12, centralSize, true); + endView.setUint32(16, offset, true); + + const total = offset + centralSize + end.length; + const out = new Uint8Array(total); + let cursor = 0; + for (const chunk of [...locals, ...centrals, end]) { + out.set(chunk, cursor); + cursor += chunk.length; + } + return out; +} diff --git a/tldraw5/tsconfig.app.json b/tldraw5/tsconfig.app.json new file mode 100644 index 0000000..e8ca209 --- /dev/null +++ b/tldraw5/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "react", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tldraw5/tsconfig.json b/tldraw5/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tldraw5/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tldraw5/tsconfig.node.json b/tldraw5/tsconfig.node.json new file mode 100644 index 0000000..eef78cd --- /dev/null +++ b/tldraw5/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["esbuild"] +}