From 9b0a672c7bde22f4a1991f3fd06d8c11ec046fd1 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 21 Mar 2026 22:31:22 -0700 Subject: [PATCH] feat(canvas): add Ctrl+Z undo / Ctrl+Shift+Z redo for shape operations Local inverse-change stack that records before-snapshots of each mutation and applies forward Automerge changes to restore state on undo. Batches rapid same-shape changes (<500ms) so drags produce a single undo entry. Supports create, move/resize, forget, remember, and hard-delete operations. Max 50 entries, redo stack clears on new changes. Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 159 ++++++++++++++++++++++++++++++++++++++++++ website/canvas.html | 78 ++++++++++++++++----- 2 files changed, 219 insertions(+), 18 deletions(-) diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 66c2d04..f83103f 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -49,6 +49,15 @@ export interface ShapeData { [key: string]: unknown; } +// ── Undo/Redo entry ── + +export interface UndoEntry { + shapeId: string; + before: ShapeData | null; // null = shape didn't exist (creation) + after: ShapeData | null; // null = shape hard-deleted + ts: number; +} + // ── Nested space types (client-side) ── export interface NestPermissions { @@ -154,6 +163,12 @@ export class CommunitySync extends EventTarget { #syncedDebounceTimer: ReturnType | null = null; #wsUrl: string | null = null; + // ── Undo/Redo state ── + #undoStack: UndoEntry[] = []; + #redoStack: UndoEntry[] = []; + #maxUndoDepth = 50; + #isUndoRedoing = false; + constructor(communitySlug: string, offlineStore?: OfflineStore) { super(); this.#communitySlug = communitySlug; @@ -519,6 +534,9 @@ export class CommunitySync extends EventTarget { // Add to document if not exists if (!this.#doc.shapes[shape.id]) { this.#updateShapeInDoc(shape); + // Record creation for undo (before=null means shape was new) + const afterData = this.#cloneShapeData(shape.id); + this.#pushUndo(shape.id, null, afterData); } } @@ -545,6 +563,7 @@ export class CommunitySync extends EventTarget { * Update shape data in Automerge document */ #updateShapeInDoc(shape: FolkShape): void { + const beforeData = this.#cloneShapeData(shape.id); const shapeData = this.#shapeToData(shape); this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shape.id}`), (doc) => { @@ -552,6 +571,11 @@ export class CommunitySync extends EventTarget { doc.shapes[shape.id] = JSON.parse(JSON.stringify(shapeData)); }); + // Record for undo (skip if this is a brand-new shape — registerShape handles that) + if (beforeData) { + this.#pushUndo(shape.id, beforeData, this.#cloneShapeData(shape.id)); + } + this.#scheduleSave(); } @@ -655,6 +679,7 @@ export class CommunitySync extends EventTarget { * Three-state: present → forgotten (faded) → deleted */ forgetShape(shapeId: string, did: string): void { + const beforeData = this.#cloneShapeData(shapeId); this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Forget shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { const shape = doc.shapes[shapeId] as Record; @@ -668,6 +693,8 @@ export class CommunitySync extends EventTarget { } }); + this.#pushUndo(shapeId, beforeData, this.#cloneShapeData(shapeId)); + // Don't remove from DOM — just update visual state this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] } @@ -683,6 +710,7 @@ export class CommunitySync extends EventTarget { const shapeData = this.#doc.shapes?.[shapeId]; if (!shapeData) return; + const beforeData = this.#cloneShapeData(shapeId); const wasDeleted = !!(shapeData as Record).deleted; this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remember shape ${shapeId}`), (doc) => { @@ -696,6 +724,8 @@ export class CommunitySync extends EventTarget { } }); + this.#pushUndo(shapeId, beforeData, this.#cloneShapeData(shapeId)); + if (wasDeleted) { // Re-add to DOM if was hard-deleted this.#applyShapeToDOM(this.#doc.shapes[shapeId]); @@ -713,12 +743,15 @@ export class CommunitySync extends EventTarget { * Shape stays in Automerge doc for restore from memory panel. */ hardDeleteShape(shapeId: string): void { + const beforeData = this.#cloneShapeData(shapeId); this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Delete shape ${shapeId}`), (doc) => { if (doc.shapes && doc.shapes[shapeId]) { (doc.shapes[shapeId] as Record).deleted = true; } }); + this.#pushUndo(shapeId, beforeData, null); + this.#removeShapeFromDOM(shapeId); this.dispatchEvent(new CustomEvent("shape-state-changed", { detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] } @@ -991,6 +1024,132 @@ export class CommunitySync extends EventTarget { } } + // ── Undo/Redo API ── + + /** + * Record an undo entry. Batches rapid changes to the same shape (<500ms) + * by keeping the original `before` and updating the timestamp. + */ + #pushUndo(shapeId: string, before: ShapeData | null, after: ShapeData | null): void { + if (this.#isUndoRedoing) return; + + const now = Date.now(); + const top = this.#undoStack[this.#undoStack.length - 1]; + + // Batch: same shape within 500ms — keep original `before`, update after + ts + if (top && top.shapeId === shapeId && (now - top.ts) < 500) { + top.after = after; + top.ts = now; + return; + } + + this.#undoStack.push({ shapeId, before, after, ts: now }); + if (this.#undoStack.length > this.#maxUndoDepth) { + this.#undoStack.shift(); + } + // Any new change clears redo + this.#redoStack.length = 0; + } + + /** Deep-clone shape data from the Automerge doc (returns null if absent). */ + #cloneShapeData(shapeId: string): ShapeData | null { + const data = this.#doc.shapes?.[shapeId]; + if (!data) return null; + return JSON.parse(JSON.stringify(data)); + } + + /** Undo the last local shape operation. */ + undo(): void { + const entry = this.#undoStack.pop(); + if (!entry) return; + + this.#isUndoRedoing = true; + try { + if (entry.before === null) { + // Was a creation — soft-delete (forget) the shape + if (this.#doc.shapes?.[entry.shapeId]) { + this.forgetShape(entry.shapeId, 'undo'); + // Snapshot after for redo + entry.after = this.#cloneShapeData(entry.shapeId); + } + } else if (entry.after === null || (entry.after as Record).deleted === true) { + // Was a hard-delete — restore via rememberShape + this.rememberShape(entry.shapeId); + // Also restore full data if we have it + if (entry.before) { + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Undo delete ${entry.shapeId}`), (doc) => { + if (doc.shapes && doc.shapes[entry.shapeId]) { + const restored = JSON.parse(JSON.stringify(entry.before)); + for (const [key, value] of Object.entries(restored)) { + (doc.shapes[entry.shapeId] as Record)[key] = value; + } + } + }); + const shape = this.#shapes.get(entry.shapeId); + if (shape) this.#updateShapeElement(shape, entry.before); + } + } else if ((entry.after as Record).forgottenBy && + Object.keys((entry.after as Record).forgottenBy as Record).length > 0) { + // Was a forget — restore via rememberShape + this.rememberShape(entry.shapeId); + } else { + // Was a property change — restore `before` data + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Undo ${entry.shapeId}`), (doc) => { + if (doc.shapes) { + doc.shapes[entry.shapeId] = JSON.parse(JSON.stringify(entry.before)); + } + }); + const shape = this.#shapes.get(entry.shapeId); + if (shape && entry.before) this.#updateShapeElement(shape, entry.before); + } + + this.#redoStack.push(entry); + this.#scheduleSave(); + this.#syncToServer(); + } finally { + this.#isUndoRedoing = false; + } + } + + /** Redo the last undone operation. */ + redo(): void { + const entry = this.#redoStack.pop(); + if (!entry) return; + + this.#isUndoRedoing = true; + try { + if (entry.before === null && entry.after) { + // Was a creation that got undone (forgotten) — remember it back + this.rememberShape(entry.shapeId); + } else if (entry.after === null || (entry.after as Record).deleted === true) { + // Re-delete + this.hardDeleteShape(entry.shapeId); + } else if ((entry.after as Record).forgottenBy && + Object.keys((entry.after as Record).forgottenBy as Record).length > 0) { + // Re-forget + this.forgetShape(entry.shapeId, 'undo'); + } else { + // Re-apply `after` data + this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Redo ${entry.shapeId}`), (doc) => { + if (doc.shapes) { + doc.shapes[entry.shapeId] = JSON.parse(JSON.stringify(entry.after)); + } + }); + const shape = this.#shapes.get(entry.shapeId); + if (shape && entry.after) this.#updateShapeElement(shape, entry.after); + } + + this.#undoStack.push(entry); + this.#scheduleSave(); + this.#syncToServer(); + } finally { + this.#isUndoRedoing = false; + } + } + + get canUndo(): boolean { return this.#undoStack.length > 0; } + get canRedo(): boolean { return this.#redoStack.length > 0; } + // ── Layer & Flow API ── /** Add a layer to the document */ diff --git a/website/canvas.html b/website/canvas.html index 54d4d9e..6219a4a 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -564,11 +564,11 @@ 50% { opacity: 0.5; } } - /* ── Corner tools (zoom + feed) — bottom-right ── */ + /* ── Corner tools (zoom + feed) — bottom-left ── */ #canvas-corner-tools { position: fixed; bottom: 16px; - right: 16px; + left: 12px; display: flex; flex-direction: column; align-items: center; @@ -1836,12 +1836,13 @@ flex-shrink: 0; } - /* Corner tools: horizontal on mobile, above bottom toolbar */ + /* Corner tools: collapsed single icon on mobile, bottom-left under side toolbar */ #canvas-corner-tools { - bottom: 60px; - right: 8px; - flex-direction: row; - padding: 4px 6px; + bottom: 8px; + left: 6px; + right: auto; + flex-direction: column; + padding: 4px; } #canvas-corner-tools .corner-btn { @@ -1850,9 +1851,9 @@ } #canvas-corner-tools .corner-sep { - width: 1px; - height: 24px; - margin: 0 2px; + width: 24px; + height: 1px; + margin: 2px 0; } } @@ -5890,21 +5891,49 @@ // Re-render canvas background when user changes preference window.addEventListener("canvas-bg-change", () => updateCanvasTransform()); + // ── Smooth animated zoom ── + let zoomAnimId = null; + function animateZoom(targetScale, targetPanX, targetPanY, duration) { + if (zoomAnimId) cancelAnimationFrame(zoomAnimId); + duration = duration || 250; + const startScale = scale, startPanX = panX, startPanY = panY; + const startTime = performance.now(); + targetScale = Math.min(Math.max(targetScale, minScale), maxScale); + function tick(now) { + const t = Math.min((now - startTime) / duration, 1); + // ease-out cubic + const e = 1 - Math.pow(1 - t, 3); + scale = startScale + (targetScale - startScale) * e; + panX = startPanX + (targetPanX - startPanX) * e; + panY = startPanY + (targetPanY - startPanY) * e; + updateCanvasTransform(); + if (t < 1) zoomAnimId = requestAnimationFrame(tick); + else zoomAnimId = null; + } + zoomAnimId = requestAnimationFrame(tick); + } + document.getElementById("zoom-in").addEventListener("click", () => { - scale = Math.min(scale * 1.1, maxScale); - updateCanvasTransform(); + // Zoom toward viewport center + const rect = canvas.getBoundingClientRect(); + const cx = rect.width / 2, cy = rect.height / 2; + const newScale = Math.min(scale * 1.25, maxScale); + const newPanX = cx - (cx - panX) * (newScale / scale); + const newPanY = cy - (cy - panY) * (newScale / scale); + animateZoom(newScale, newPanX, newPanY); }); document.getElementById("zoom-out").addEventListener("click", () => { - scale = Math.max(scale / 1.1, minScale); - updateCanvasTransform(); + const rect = canvas.getBoundingClientRect(); + const cx = rect.width / 2, cy = rect.height / 2; + const newScale = Math.max(scale / 1.25, minScale); + const newPanX = cx - (cx - panX) * (newScale / scale); + const newPanY = cy - (cy - panY) * (newScale / scale); + animateZoom(newScale, newPanX, newPanY); }); document.getElementById("reset-view").addEventListener("click", () => { - scale = 1; - panX = 0; - panY = 0; - updateCanvasTransform(); + animateZoom(1, 0, 0, 350); }); // Corner zoom toggle — expand/collapse zoom controls @@ -6189,6 +6218,19 @@ } }); + // ── Undo / Redo (Ctrl+Z / Ctrl+Shift+Z) ── + document.addEventListener("keydown", (e) => { + if ((e.key === "z" || e.key === "Z") && (e.ctrlKey || e.metaKey) && + !e.target.closest("input, textarea, [contenteditable]")) { + e.preventDefault(); + if (e.shiftKey) { + sync.redo(); + } else { + sync.undo(); + } + } + }); + // ── Bulk delete confirmation dialog ── function showBulkDeleteConfirm(count) { const overlay = document.createElement("div");