Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 218 additions & 89 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions tldraw4/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
144 changes: 30 additions & 114 deletions tldraw4/src/lith/AutomergeToTLStore.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }
) {
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<string>();
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<any> => {
return path[1] as RecordId<any>;
};

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;
};
9 changes: 5 additions & 4 deletions tldraw4/src/lith/useAutomergeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ export function useAutomergeStore({
/* Automerge to TLDraw */
const syncAutomergeDocChangesToStore = ({
patches,
}: DocHandleChangePayload<any>) => {
doc,
}: DocHandleChangePayload<TLStoreSnapshot>) => {
if (preventPatchApplications) return;
applyAutomergePatchesToTLStore(patches, store);
applyAutomergePatchesToTLStore(patches, store, doc ?? handle.doc());
};

handle.on("change", syncAutomergeDocChangesToStore);
Expand All @@ -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),
});
});

Expand Down
7 changes: 5 additions & 2 deletions tldraw4/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@ 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;
},
},
{
type: "patchwork:tool",
id: "tldraw4",
name: "tldraw",
name: "tldraw4",
supportedDatatypes: ["tldraw4"],
async load(): Promise<ToolImplementation> {
const { render } = await import("./tool.tsx");
Expand Down
26 changes: 26 additions & 0 deletions tldraw5/.gitignore
Original file line number Diff line number Diff line change
@@ -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/
3 changes: 3 additions & 0 deletions tldraw5/esbuild/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import options from "./options.ts";
import * as esbuild from "esbuild";
esbuild.build(options);
3 changes: 3 additions & 0 deletions tldraw5/esbuild/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import options from "./options.ts";
import * as esbuild from "esbuild";
export default await esbuild.context(options);
37 changes: 37 additions & 0 deletions tldraw5/esbuild/options.ts
Original file line number Diff line number Diff line change
@@ -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;
29 changes: 29 additions & 0 deletions tldraw5/esbuild/plugin-pushwork-sync.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions tldraw5/esbuild/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import context from "./context.ts";
await context.watch();
65 changes: 65 additions & 0 deletions tldraw5/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading