diff --git a/package-lock.json b/package-lock.json index 0f0424a..c71fd2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.33.1", + "@automerge/automerge": "^3.1.1", + "@automerge/automerge-repo": "^2.2.0", + "@automerge/automerge-repo-react-hooks": "^2.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", "@tldraw/assets": "^3.6.0", @@ -22,6 +25,7 @@ "@uiw/react-md-editor": "^4.0.5", "@vercel/analytics": "^1.2.2", "ai": "^4.1.0", + "automerge": "^0.14.2", "cherry-markdown": "^0.8.57", "cloudflare-workers-unfurl": "^0.0.7", "gray-matter": "^4.0.3", @@ -182,6 +186,56 @@ "undici-types": "~5.26.4" } }, + "node_modules/@automerge/automerge": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-3.1.1.tgz", + "integrity": "sha512-x7tZiMBLk4/SKYimVEVl1/wPntT9buGvLOWCey9ZcH8JUsB0dgm49C0S7Ojzgvflcs2hc/YjiXRPcFeFkinIgw==", + "license": "MIT" + }, + "node_modules/@automerge/automerge-repo": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.2.0.tgz", + "integrity": "sha512-/4cAxnUDPykqdSMJJiJvPlR2VY0JHJnvEoM7fO8qVXwQors34S0VkJEScXRJZExcVhWGhFTwCTWvUbb6hhWQIQ==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "2.2.8 - 3", + "bs58check": "^3.0.1", + "cbor-x": "^1.3.0", + "debug": "^4.3.4", + "eventemitter3": "^5.0.1", + "fast-sha256": "^1.3.0", + "uuid": "^9.0.0", + "xstate": "^5.9.1" + } + }, + "node_modules/@automerge/automerge-repo-react-hooks": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo-react-hooks/-/automerge-repo-react-hooks-2.2.0.tgz", + "integrity": "sha512-zHP44jXSCmV1DyyKdTKgv8nz7yrdCT7VFXs/QrF3YRhE/3czhhvbXo2zahGvOoM+jryH0eJxHTrJ7jMEtt/Ung==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "2.2.8 - 3", + "@automerge/automerge-repo": "2.2.0", + "eventemitter3": "^5.0.1", + "react-usestateref": "^1.0.8" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@automerge/automerge-repo-react-hooks/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/@automerge/automerge-repo/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", @@ -473,6 +527,84 @@ "license": "MIT", "optional": true }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", + "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", + "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", + "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", + "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", + "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", + "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@cloudflare/intl-types": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@cloudflare/intl-types/-/intl-types-1.5.6.tgz", @@ -1838,6 +1970,18 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4384,6 +4528,28 @@ "node": ">= 4.5.0" } }, + "node_modules/automerge": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/automerge/-/automerge-0.14.2.tgz", + "integrity": "sha512-shiwuJHCbNRI23WZyIECLV4Ovf3WiAFJ7P9BH4l5gON1In/UUbjcSJKRygtIirObw2UQumeYxp3F2XBdSvQHnA==", + "license": "MIT", + "dependencies": { + "immutable": "^3.8.2", + "transit-immutable-js": "^0.7.0", + "transit-js": "^0.8.861", + "uuid": "^3.4.0" + } + }, + "node_modules/automerge/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -4400,6 +4566,12 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -4508,6 +4680,25 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, "node_modules/btoa": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", @@ -4608,6 +4799,37 @@ "license": "MIT", "optional": true }, + "node_modules/cbor-extract": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", + "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", + "@cbor-extract/cbor-extract-linux-x64": "2.2.0", + "@cbor-extract/cbor-extract-win32-x64": "2.2.0" + } + }, + "node_modules/cbor-x": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz", + "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.0" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -6530,6 +6752,12 @@ "license": "MIT", "optional": true }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", @@ -7390,6 +7618,15 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -9155,6 +9392,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -9866,6 +10118,15 @@ } } }, + "node_modules/react-usestateref": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/react-usestateref/-/react-usestateref-1.0.9.tgz", + "integrity": "sha512-t8KLsI7oje0HzfzGhxFXzuwbf1z9vhBM1ptHLUIHhYqZDKFuI5tzdhEVxSNzUkYxwF8XdpOErzHlKxvP7sTERw==", + "license": "ISC", + "peerDependencies": { + "react": ">16.0.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -11097,6 +11358,25 @@ "node": ">=12" } }, + "node_modules/transit-immutable-js": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/transit-immutable-js/-/transit-immutable-js-0.7.0.tgz", + "integrity": "sha512-Qga6EDAmVVoDwzPZ2fBvi3aj5E0V6NLy/spCFvspVd4BfY2/3Jr5yyynbSTC2SAEipkJ4MHVH75xNcTOlz9UaQ==", + "license": "MIT", + "peerDependencies": { + "immutable": ">= 3", + "transit-js": ">= 0.8" + } + }, + "node_modules/transit-js": { + "version": "0.8.874", + "resolved": "https://registry.npmjs.org/transit-js/-/transit-js-0.8.874.tgz", + "integrity": "sha512-IDJJGKRzUbJHmN0P15HBBa05nbKor3r2MmG6aSt0UxXIlJZZKcddTk67/U7WyAeW9Hv/VYI02IqLzolsC4sbPA==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11526,7 +11806,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -12457,6 +12736,16 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/xstate": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.1.tgz", + "integrity": "sha512-i9ZpNnm/XhCOMUxae1suT8PjYNTStZWbhmuKt4xeTPaYG5TS0Fz0i+Ka5yxoNPpaHW3VW6JIowrwFgSTZONxig==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 6fe2cdc..aba4fa3 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "license": "ISC", "dependencies": { "@anthropic-ai/sdk": "^0.33.1", + "@automerge/automerge": "^3.1.1", + "@automerge/automerge-repo": "^2.2.0", + "@automerge/automerge-repo-react-hooks": "^2.2.0", "@daily-co/daily-js": "^0.60.0", "@daily-co/daily-react": "^0.20.0", "@tldraw/assets": "^3.6.0", @@ -29,6 +32,7 @@ "@uiw/react-md-editor": "^4.0.5", "@vercel/analytics": "^1.2.2", "ai": "^4.1.0", + "automerge": "^0.14.2", "cherry-markdown": "^0.8.57", "cloudflare-workers-unfurl": "^0.0.7", "gray-matter": "^4.0.3", diff --git a/src/AutomergeToTLStore.ts b/src/AutomergeToTLStore.ts new file mode 100644 index 0000000..32a4bc7 --- /dev/null +++ b/src/AutomergeToTLStore.ts @@ -0,0 +1,121 @@ +import { TLRecord, RecordId, TLStore } from "@tldraw/tldraw" +import { Patch } from "@automerge/automerge" + +export function applyAutomergePatchesToTLStore( + patches: Patch[], + store: TLStore +) { + const toRemove: TLRecord["id"][] = [] + const updatedObjects: { [id: string]: TLRecord } = {} + + patches.forEach((patch) => { + if (!isStorePatch(patch)) return + + const id = pathToId(patch.path) + const record = + updatedObjects[id] || JSON.parse(JSON.stringify(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": { + const id = pathToId(patch.path) + toRemove.push(id as TLRecord["id"]) + break + } + default: { + console.log("Unsupported patch:", patch) + } + } + }) + const toPut = Object.values(updatedObjects) + + // put / remove the records in the store + console.log({ patches, toPut }) + store.mergeRemoteChanges(() => { + if (toRemove.length) store.remove(toRemove) + if (toPut.length) store.put(toPut) + }) +} + +const isStorePatch = (patch: Patch): boolean => { + return patch.path[0] === "store" && patch.path.length > 1 +} + +// path: ["store", "camera:page:page", "x"] => "camera:page:page" +const pathToId = (path: (string | number)[]): RecordId => { + return path[1] as RecordId +} + +const applyInsertToObject = (patch: any, 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: any, 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: any, 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 +} \ No newline at end of file diff --git a/src/TLDrawAutomergeExample.tsx b/src/TLDrawAutomergeExample.tsx new file mode 100644 index 0000000..a898ad0 --- /dev/null +++ b/src/TLDrawAutomergeExample.tsx @@ -0,0 +1,49 @@ +import { type DocHandle } from "@automerge/automerge-repo" +import { type TLStoreSnapshot, Tldraw, track, useEditor } from "@tldraw/tldraw" +import "@tldraw/tldraw/tldraw.css" + +import { useAutomergeStore } from "./useAutomergeStore" + +interface TLDrawAutomergeExampleProps { + handle: DocHandle + userId: string +} + +export function TLDrawAutomergeExample({ + handle, + userId, +}: TLDrawAutomergeExampleProps) { + const store = useAutomergeStore({ handle, userId }) + + return ( +
+ +
+ ) +} + +const NameEditor = track(() => { + const editor = useEditor() + + return ( +
+ { + editor.user.updateUserPreferences({ + color: e.currentTarget.value, + }) + }} + /> + { + editor.user.updateUserPreferences({ + name: e.currentTarget.value, + }) + }} + /> +
+ ) +}) \ No newline at end of file diff --git a/src/TLStoreToAutomerge.ts b/src/TLStoreToAutomerge.ts new file mode 100644 index 0000000..ff4c42d --- /dev/null +++ b/src/TLStoreToAutomerge.ts @@ -0,0 +1,69 @@ +import { RecordsDiff, TLRecord } from "@tldraw/tldraw" +import _ from "lodash" + +export function applyTLStoreChangesToAutomerge( + doc: any, + changes: RecordsDiff +) { + Object.values(changes.added).forEach((record) => { + doc.store[record.id] = 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) { + // eslint-disable-line + if (_.isArray(objectB)) { + if (!_.isArray(objectA)) { + // if objectA is not an array, replace it with objectB + objectA = objectB.slice() + } else { + // compare and update array elements + for (let i = 0; i < objectB.length; i++) { + if (i >= objectA.length) { + objectA.push(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] = 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] = 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] = value + } + } + }) + _.forIn(objectA, (_: any, key: string) => { + if ((objectB as any)[key] === undefined) { + // if key is not in objectB, remove it + delete objectA[key] + } + }) + } +} \ No newline at end of file diff --git a/src/default_store.ts b/src/default_store.ts new file mode 100644 index 0000000..e4a44f1 --- /dev/null +++ b/src/default_store.ts @@ -0,0 +1,141 @@ +export const DEFAULT_STORE = { + store: { + "document:document": { + gridSize: 10, + name: "", + meta: {}, + id: "document:document", + typeName: "document", + }, + "pointer:pointer": { + id: "pointer:pointer", + typeName: "pointer", + x: 0, + y: 0, + lastActivityTimestamp: 0, + meta: {}, + }, + "page:page": { + meta: {}, + id: "page:page", + name: "Page 1", + index: "a1", + typeName: "page", + }, + "camera:page:page": { + x: 0, + y: 0, + z: 1, + meta: {}, + id: "camera:page:page", + typeName: "camera", + }, + "instance_page_state:page:page": { + editingShapeId: null, + croppingShapeId: null, + selectedShapeIds: [], + hoveredShapeId: null, + erasingShapeIds: [], + hintingShapeIds: [], + focusedGroupId: null, + meta: {}, + id: "instance_page_state:page:page", + pageId: "page:page", + typeName: "instance_page_state", + }, + "instance:instance": { + followingUserId: null, + opacityForNextShape: 1, + stylesForNextShape: {}, + brush: null, + scribble: null, + cursor: { + type: "default", + rotation: 0, + }, + isFocusMode: false, + exportBackground: true, + isDebugMode: false, + isToolLocked: false, + screenBounds: { + x: 0, + y: 0, + w: 720, + h: 400, + }, + zoomBrush: null, + isGridMode: false, + isPenMode: false, + chatMessage: "", + isChatting: false, + highlightedUserIds: [], + canMoveCamera: true, + isFocused: true, + devicePixelRatio: 2, + isCoarsePointer: false, + isHoveringCanvas: false, + openMenus: [], + isChangingStyle: false, + isReadonly: false, + meta: {}, + id: "instance:instance", + currentPageId: "page:page", + typeName: "instance", + }, + }, + schema: { + schemaVersion: 1, + storeVersion: 4, + recordVersions: { + asset: { + version: 1, + subTypeKey: "type", + subTypeVersions: { + image: 2, + video: 2, + bookmark: 0, + }, + }, + camera: { + version: 1, + }, + document: { + version: 2, + }, + instance: { + version: 21, + }, + instance_page_state: { + version: 5, + }, + page: { + version: 1, + }, + shape: { + version: 3, + subTypeKey: "type", + subTypeVersions: { + group: 0, + text: 1, + bookmark: 1, + draw: 1, + geo: 7, + note: 4, + line: 1, + frame: 0, + arrow: 1, + highlight: 0, + embed: 4, + image: 2, + video: 1, + }, + }, + instance_presence: { + version: 5, + }, + pointer: { + version: 1, + }, + }, + }, + } \ No newline at end of file diff --git a/src/useAutomergeStore.ts b/src/useAutomergeStore.ts new file mode 100644 index 0000000..4fe7607 --- /dev/null +++ b/src/useAutomergeStore.ts @@ -0,0 +1,183 @@ +import { + TLAnyShapeUtilConstructor, + TLRecord, + TLStoreWithStatus, + createTLStore, + defaultShapeUtils, + HistoryEntry, + getUserPreferences, + setUserPreferences, + defaultUserPreferences, + createPresenceStateDerivation, + InstancePresenceRecordType, + computed, + react, + TLStoreSnapshot, + sortById, + } from "@tldraw/tldraw" + import { useEffect, useState } from "react" + import { DocHandle, DocHandleChangePayload } from "@automerge/automerge-repo" + import { + useLocalAwareness, + useRemoteAwareness, + } from "@automerge/automerge-repo-react-hooks" + + import { applyAutomergePatchesToTLStore } from "./AutomergeToTLStore.js" + import { applyTLStoreChangesToAutomerge } from "./TLStoreToAutomerge.js" + + export function useAutomergeStore({ + handle, + shapeUtils = [], + }: { + handle: DocHandle + userId: string + shapeUtils?: TLAnyShapeUtilConstructor[] + }): TLStoreWithStatus { + const [store] = useState(() => { + const store = createTLStore({ + shapeUtils: [...defaultShapeUtils, ...shapeUtils], + }) + 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 + } + + unsubs.push( + store.listen(syncStoreChangesToAutomergeDoc, { + source: "user", + scope: "document", + }) + ) + + /* Automerge to TLDraw */ + const syncAutomergeDocChangesToStore = ({ + patches, + }: DocHandleChangePayload) => { + if (preventPatchApplications) return + + applyAutomergePatchesToTLStore(patches, store) + } + + handle.on("change", syncAutomergeDocChangesToStore) + unsubs.push(() => handle.off("change", syncAutomergeDocChangesToStore)) + + /* Defer rendering until the document is ready */ + // TODO: need to think through the various status possibilities here and how they map + handle.whenReady().then(() => { + const doc = handle.docSync() + if (!doc) throw new Error("Document not found") + if (!doc.store) throw new Error("Document store not initialized") + + store.mergeRemoteChanges(() => { + store.loadSnapshot({ + store: JSON.parse(JSON.stringify(doc.store)), + schema: doc.schema, + }) + }) + + setStoreWithStatus({ + store, + status: "synced-remote", + connectionStatus: "online", + }) + }) + + return () => { + unsubs.forEach((fn) => fn()) + unsubs.length = 0 + } + }, [handle, store]) + + return storeWithStatus + } + + export function useAutomergePresence({ handle, store, userMetadata }: + { handle: DocHandle, store: TLStoreWithStatus, userMetadata: any }) { + + const innerStore = store?.store + + const { userId, name, color } = userMetadata + + const [, updateLocalState] = useLocalAwareness({ + handle, + userId, + initialState: {}, + }) + + const [peerStates] = useRemoteAwareness({ + handle, + localUserId: userId, + }) + + /* ----------- Presence stuff ----------- */ + useEffect(() => { + if (!innerStore) return + + const toPut: TLRecord[] = + Object.values(peerStates) + .filter((record) => record && Object.keys(record).length !== 0) + + // put / remove the records in the store + 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 + /* ----------- Presence stuff ----------- */ + setUserPreferences({ id: userId, color, name }) + + const userPreferences = computed<{ + id: string + color: string + name: string + }>("userPreferences", () => { + const user = getUserPreferences() + return { + id: user.id, + color: user.color ?? defaultUserPreferences.color, + name: user.name ?? defaultUserPreferences.name, + } + }) + + const presenceId = InstancePresenceRecordType.createId(userId) + const presenceDerivation = createPresenceStateDerivation( + userPreferences, + presenceId + )(innerStore) + + return react("when presence changes", () => { + const presence = presenceDerivation.get() + requestAnimationFrame(() => { + updateLocalState(presence) + }) + }) + }, [innerStore, userId, updateLocalState]) + /* ----------- End presence stuff ----------- */ + + } \ No newline at end of file