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;
|
[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) ──
|
// ── Nested space types (client-side) ──
|
||||||
|
|
||||||
export interface NestPermissions {
|
export interface NestPermissions {
|
||||||
|
|
@ -154,6 +163,12 @@ export class CommunitySync extends EventTarget {
|
||||||
#syncedDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
#syncedDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
#wsUrl: string | null = null;
|
#wsUrl: string | null = null;
|
||||||
|
|
||||||
|
// ── Undo/Redo state ──
|
||||||
|
#undoStack: UndoEntry[] = [];
|
||||||
|
#redoStack: UndoEntry[] = [];
|
||||||
|
#maxUndoDepth = 50;
|
||||||
|
#isUndoRedoing = false;
|
||||||
|
|
||||||
constructor(communitySlug: string, offlineStore?: OfflineStore) {
|
constructor(communitySlug: string, offlineStore?: OfflineStore) {
|
||||||
super();
|
super();
|
||||||
this.#communitySlug = communitySlug;
|
this.#communitySlug = communitySlug;
|
||||||
|
|
@ -519,6 +534,9 @@ export class CommunitySync extends EventTarget {
|
||||||
// Add to document if not exists
|
// Add to document if not exists
|
||||||
if (!this.#doc.shapes[shape.id]) {
|
if (!this.#doc.shapes[shape.id]) {
|
||||||
this.#updateShapeInDoc(shape);
|
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
|
* Update shape data in Automerge document
|
||||||
*/
|
*/
|
||||||
#updateShapeInDoc(shape: FolkShape): void {
|
#updateShapeInDoc(shape: FolkShape): void {
|
||||||
|
const beforeData = this.#cloneShapeData(shape.id);
|
||||||
const shapeData = this.#shapeToData(shape);
|
const shapeData = this.#shapeToData(shape);
|
||||||
|
|
||||||
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Update shape ${shape.id}`), (doc) => {
|
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));
|
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();
|
this.#scheduleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -655,6 +679,7 @@ export class CommunitySync extends EventTarget {
|
||||||
* Three-state: present → forgotten (faded) → deleted
|
* Three-state: present → forgotten (faded) → deleted
|
||||||
*/
|
*/
|
||||||
forgetShape(shapeId: string, did: string): void {
|
forgetShape(shapeId: string, did: string): void {
|
||||||
|
const beforeData = this.#cloneShapeData(shapeId);
|
||||||
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Forget shape ${shapeId}`), (doc) => {
|
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Forget shape ${shapeId}`), (doc) => {
|
||||||
if (doc.shapes && doc.shapes[shapeId]) {
|
if (doc.shapes && doc.shapes[shapeId]) {
|
||||||
const shape = doc.shapes[shapeId] as Record<string, unknown>;
|
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
|
// Don't remove from DOM — just update visual state
|
||||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||||
detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] }
|
detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] }
|
||||||
|
|
@ -683,6 +710,7 @@ export class CommunitySync extends EventTarget {
|
||||||
const shapeData = this.#doc.shapes?.[shapeId];
|
const shapeData = this.#doc.shapes?.[shapeId];
|
||||||
if (!shapeData) return;
|
if (!shapeData) return;
|
||||||
|
|
||||||
|
const beforeData = this.#cloneShapeData(shapeId);
|
||||||
const wasDeleted = !!(shapeData as Record<string, unknown>).deleted;
|
const wasDeleted = !!(shapeData as Record<string, unknown>).deleted;
|
||||||
|
|
||||||
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Remember shape ${shapeId}`), (doc) => {
|
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) {
|
if (wasDeleted) {
|
||||||
// Re-add to DOM if was hard-deleted
|
// Re-add to DOM if was hard-deleted
|
||||||
this.#applyShapeToDOM(this.#doc.shapes[shapeId]);
|
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.
|
* Shape stays in Automerge doc for restore from memory panel.
|
||||||
*/
|
*/
|
||||||
hardDeleteShape(shapeId: string): void {
|
hardDeleteShape(shapeId: string): void {
|
||||||
|
const beforeData = this.#cloneShapeData(shapeId);
|
||||||
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Delete shape ${shapeId}`), (doc) => {
|
this.#doc = Automerge.change(this.#doc, makeChangeMessage(`Delete shape ${shapeId}`), (doc) => {
|
||||||
if (doc.shapes && doc.shapes[shapeId]) {
|
if (doc.shapes && doc.shapes[shapeId]) {
|
||||||
(doc.shapes[shapeId] as Record<string, unknown>).deleted = true;
|
(doc.shapes[shapeId] as Record<string, unknown>).deleted = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.#pushUndo(shapeId, beforeData, null);
|
||||||
|
|
||||||
this.#removeShapeFromDOM(shapeId);
|
this.#removeShapeFromDOM(shapeId);
|
||||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||||
detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] }
|
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 ──
|
// ── Layer & Flow API ──
|
||||||
|
|
||||||
/** Add a layer to the document */
|
/** Add a layer to the document */
|
||||||
|
|
|
||||||
|
|
@ -564,11 +564,11 @@
|
||||||
50% { opacity: 0.5; }
|
50% { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Corner tools (zoom + feed) — bottom-right ── */
|
/* ── Corner tools (zoom + feed) — bottom-left ── */
|
||||||
#canvas-corner-tools {
|
#canvas-corner-tools {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
right: 16px;
|
left: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -1836,12 +1836,13 @@
|
||||||
flex-shrink: 0;
|
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 {
|
#canvas-corner-tools {
|
||||||
bottom: 60px;
|
bottom: 8px;
|
||||||
right: 8px;
|
left: 6px;
|
||||||
flex-direction: row;
|
right: auto;
|
||||||
padding: 4px 6px;
|
flex-direction: column;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#canvas-corner-tools .corner-btn {
|
#canvas-corner-tools .corner-btn {
|
||||||
|
|
@ -1850,9 +1851,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#canvas-corner-tools .corner-sep {
|
#canvas-corner-tools .corner-sep {
|
||||||
width: 1px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 1px;
|
||||||
margin: 0 2px;
|
margin: 2px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5890,21 +5891,49 @@
|
||||||
// Re-render canvas background when user changes preference
|
// Re-render canvas background when user changes preference
|
||||||
window.addEventListener("canvas-bg-change", () => updateCanvasTransform());
|
window.addEventListener("canvas-bg-change", () => updateCanvasTransform());
|
||||||
|
|
||||||
document.getElementById("zoom-in").addEventListener("click", () => {
|
// ── Smooth animated zoom ──
|
||||||
scale = Math.min(scale * 1.1, maxScale);
|
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();
|
updateCanvasTransform();
|
||||||
|
if (t < 1) zoomAnimId = requestAnimationFrame(tick);
|
||||||
|
else zoomAnimId = null;
|
||||||
|
}
|
||||||
|
zoomAnimId = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("zoom-in").addEventListener("click", () => {
|
||||||
|
// 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", () => {
|
document.getElementById("zoom-out").addEventListener("click", () => {
|
||||||
scale = Math.max(scale / 1.1, minScale);
|
const rect = canvas.getBoundingClientRect();
|
||||||
updateCanvasTransform();
|
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", () => {
|
document.getElementById("reset-view").addEventListener("click", () => {
|
||||||
scale = 1;
|
animateZoom(1, 0, 0, 350);
|
||||||
panX = 0;
|
|
||||||
panY = 0;
|
|
||||||
updateCanvasTransform();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Corner zoom toggle — expand/collapse zoom controls
|
// 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 ──
|
// ── Bulk delete confirmation dialog ──
|
||||||
function showBulkDeleteConfirm(count) {
|
function showBulkDeleteConfirm(count) {
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue