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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 22:31:22 -07:00
parent 6491681e3e
commit 9b0a672c7b
2 changed files with 219 additions and 18 deletions

View File

@ -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<typeof setTimeout> | 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<string, unknown>;
@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>)[key] = value;
}
}
});
const shape = this.#shapes.get(entry.shapeId);
if (shape) this.#updateShapeElement(shape, entry.before);
}
} else if ((entry.after as Record<string, unknown>).forgottenBy &&
Object.keys((entry.after as Record<string, unknown>).forgottenBy as Record<string, unknown>).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<string, unknown>).deleted === true) {
// Re-delete
this.hardDeleteShape(entry.shapeId);
} else if ((entry.after as Record<string, unknown>).forgottenBy &&
Object.keys((entry.after as Record<string, unknown>).forgottenBy as Record<string, unknown>).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 */

View File

@ -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");