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:
parent
6491681e3e
commit
9b0a672c7b
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue