Skip to content

Commit 2eec2df

Browse files
committed
Add Undo/Redo
1 parent d88cab9 commit 2eec2df

10 files changed

Lines changed: 291 additions & 6 deletions

File tree

src/document/commands/image.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { sendImageToWorker } from "../../domain/colorWorkerClient.js";
22
import { getPixelData, loadPixels, measureImage } from "../../domain/imageSource.js";
33
import * as pipelineWorker from "../../domain/pipelineWorkerClient.js";
44
import type { ColorSettings, SeedSettings } from "../../settings/types.js";
5+
import { clearHistory } from "../history.js";
56
import { DeltaOperation, signals } from "../signals.js";
67
import { store } from "../store.js";
78
import {
@@ -38,6 +39,8 @@ export async function setImage(src: string): Promise<void> {
3839
signals.modifiers.emit();
3940
// Derived signals (points/triangles/document) fire when the pipeline worker returns.
4041
evaluatePoints();
42+
// A new image is a new project context; do not undo across image swaps.
43+
clearHistory();
4144
}
4245

4346
export async function restoreDocument(doc: Partial<DocumentData>): Promise<void> {
@@ -49,6 +52,43 @@ export async function restoreDocument(doc: Partial<DocumentData>): Promise<void>
4952
pipelineWorker.setImage(getPixelData().data, data.image.width, data.image.height);
5053
sendImageToWorker(getPixelData().data, data.image.width, data.image.height);
5154
}
55+
emitRestored(data);
56+
// A loaded/imported document starts a fresh history baseline.
57+
clearHistory();
58+
}
59+
60+
/** Source fields restored by the in-session undo/redo history (image is unchanged). */
61+
export interface RestoredSource {
62+
seed: number;
63+
seedSettings: SeedSettings;
64+
colorSettings: ColorSettings;
65+
stack: StackEntry[];
66+
}
67+
68+
// Restore a trusted source snapshot from undo/redo. The image is unchanged (history is
69+
// cleared whenever it changes), so the worker pixel caches stay valid and we skip the
70+
// costly reload/setImage. The snapshot is already current-version and validated, so it
71+
// bypasses normalizeDocument (which would, among other things, force every group
72+
// collapsed). Geometry is recomputed from the restored source.
73+
export function restoreSource(source: RestoredSource): void {
74+
const image = store.data().image;
75+
store.replace({
76+
...emptyDocument(),
77+
image,
78+
seed: source.seed,
79+
seedSettings: source.seedSettings,
80+
colorSettings: source.colorSettings,
81+
stack: source.stack,
82+
});
83+
signals.modifiers.emit();
84+
// Deliberately NOT emitting signals.image: the image is unchanged, and that signal
85+
// resets the preview camera (setImageFrame -> resetView). Geometry and points refresh
86+
// through the pipeline run below (signals.points / signals.triangles), which leaves the
87+
// camera where the user left it. evaluatePoints handles the no-image case too.
88+
evaluatePoints();
89+
}
90+
91+
function emitRestored(data: DocumentData): void {
5292
signals.image.emit({ image: data.image });
5393
signals.modifiers.emit();
5494
if (data.image) {

src/document/history.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import type { ColorSettings, SeedSettings } from "../settings/types.js";
2+
import { restoreSource } from "./commands/image.js";
3+
import { signals } from "./signals.js";
4+
import { store } from "./store.js";
5+
import type { StackEntry } from "./types.js";
6+
7+
// In-session undo/redo over the document SOURCE only (image, seed, settings, stack).
8+
// The architecture makes this cheap: derived geometry is recomputed from source, so a
9+
// snapshot never needs to store points or render buffers. History is driven off
10+
// `signals.document` (the universal "an edit committed" signal): we keep a `baseline`
11+
// source snapshot and, on each commit, push the previous baseline onto the undo stack.
12+
// Continuous canvas drags fire `signals.document` every frame, so they are bracketed by
13+
// beginGesture/endGesture to collapse into a single step. Image swaps and project loads
14+
// clear history (see commands/image.ts), so within a session the image is invariant -
15+
// snapshots omit it (the data URL is large) and restore reattaches the live image.
16+
17+
const HISTORY_LIMIT = 100;
18+
19+
interface SourceSnapshot {
20+
seed: number;
21+
seedSettings: SeedSettings;
22+
colorSettings: ColorSettings;
23+
stack: StackEntry[];
24+
}
25+
26+
const clone = <T>(value: T): T =>
27+
typeof structuredClone === "function"
28+
? structuredClone(value)
29+
: JSON.parse(JSON.stringify(value));
30+
31+
function snapshot(): SourceSnapshot {
32+
const data = store.data();
33+
return clone({
34+
seed: data.seed,
35+
seedSettings: data.seedSettings,
36+
colorSettings: data.colorSettings,
37+
stack: data.stack,
38+
});
39+
}
40+
41+
// Group `collapsed` is a view flag (the app even discards it on load), so toggling a
42+
// folder must not create an undo step. Compare snapshots with every `collapsed`
43+
// normalized out; `muted` and everything else stays significant.
44+
function comparisonKey(snap: SourceSnapshot): string {
45+
const stack = snap.stack.map((entry) =>
46+
entry.type === "group" ? { ...entry, group: { ...entry.group, collapsed: false } } : entry,
47+
);
48+
return JSON.stringify({ ...snap, stack });
49+
}
50+
51+
const undoStack: SourceSnapshot[] = [];
52+
const redoStack: SourceSnapshot[] = [];
53+
let baseline: SourceSnapshot = snapshot();
54+
let gestureDepth = 0;
55+
const listeners = new Set<() => void>();
56+
57+
function notify(): void {
58+
for (const cb of listeners) cb();
59+
}
60+
61+
// Push `baseline` onto the undo stack if the live source differs, then adopt the live
62+
// source as the new baseline. A no-op when nothing changed (early-returning commands,
63+
// or a pure folder collapse/expand).
64+
function reconcile(): void {
65+
const current = snapshot();
66+
if (comparisonKey(current) === comparisonKey(baseline)) {
67+
baseline = current;
68+
return;
69+
}
70+
undoStack.push(baseline);
71+
if (undoStack.length > HISTORY_LIMIT) undoStack.shift();
72+
redoStack.length = 0;
73+
baseline = current;
74+
notify();
75+
}
76+
77+
export function initHistory(): void {
78+
baseline = snapshot();
79+
signals.document.on(() => {
80+
if (gestureDepth > 0) return;
81+
reconcile();
82+
});
83+
}
84+
85+
export function beginGesture(): void {
86+
gestureDepth++;
87+
}
88+
89+
export function endGesture(): void {
90+
if (gestureDepth === 0) return;
91+
gestureDepth--;
92+
if (gestureDepth === 0) reconcile();
93+
}
94+
95+
export function canUndo(): boolean {
96+
return undoStack.length > 0;
97+
}
98+
99+
export function canRedo(): boolean {
100+
return redoStack.length > 0;
101+
}
102+
103+
export function undo(): void {
104+
const target = undoStack.pop();
105+
if (!target) return;
106+
redoStack.push(baseline);
107+
apply(target);
108+
}
109+
110+
export function redo(): void {
111+
const target = redoStack.pop();
112+
if (!target) return;
113+
undoStack.push(baseline);
114+
apply(target);
115+
}
116+
117+
// Restore a snapshot. The user's current folder expansion is preserved across undo
118+
// (collapsed is not historical), so live `collapsed` flags are overlaid onto the
119+
// restored stack by group uuid.
120+
function apply(target: SourceSnapshot): void {
121+
baseline = target;
122+
const collapsedByUuid = new Map<string, boolean>();
123+
for (const entry of store.data().stack) {
124+
if (entry.type === "group") collapsedByUuid.set(entry.group.uuid, entry.group.collapsed);
125+
}
126+
const stack = clone(target.stack);
127+
for (const entry of stack) {
128+
if (entry.type !== "group") continue;
129+
const collapsed = collapsedByUuid.get(entry.group.uuid);
130+
if (collapsed !== undefined) entry.group.collapsed = collapsed;
131+
}
132+
restoreSource({
133+
seed: target.seed,
134+
seedSettings: clone(target.seedSettings),
135+
colorSettings: clone(target.colorSettings),
136+
stack,
137+
});
138+
notify();
139+
}
140+
141+
export function clearHistory(): void {
142+
undoStack.length = 0;
143+
redoStack.length = 0;
144+
baseline = snapshot();
145+
notify();
146+
}
147+
148+
export function onHistoryChange(listener: () => void): void {
149+
listeners.add(listener);
150+
}

src/i18n/locales/en.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@
2020
"label": "Curve density",
2121
"tip": "Subdivision of Catmull-Rom curves."
2222
},
23+
"undo": {
24+
"label": "Undo",
25+
"tip": "Undo the last change (Ctrl+Z)."
26+
},
27+
"redo": {
28+
"label": "Redo",
29+
"tip": "Redo the last undone change (Ctrl+Shift+Z)."
30+
},
2331
"loadImage": {
2432
"label": "Load image",
2533
"tip": "Pick a photo to polygonize."

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { mountStatsOverlay } from "./ui/stats.js";
1414
import { openHelp } from "./ui/help.js";
1515
import { startAutosave } from "./persistence/autosave.js";
1616
import { autoload } from "./persistence/autoload.js";
17+
import { initHistory } from "./document/history.js";
1718
import { signals } from "./document/signals.js";
1819
import { store } from "./document/store.js";
1920
import { initPipelineWorker } from "./domain/pipelineWorkerClient.js";
@@ -75,6 +76,7 @@ async function main(): Promise<void> {
7576
}
7677
});
7778

79+
initHistory();
7880
startAutosave();
7981
await autoload();
8082

src/preview/overlayLayer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ export class OverlayLayer {
148148
return line;
149149
}
150150

151-
#makePathDots(points: Vec2[], color: number, size = 12, round = true): Points {
151+
#makePathDots(points: Vec2[], color: number, size = 15, round = true): Points {
152152
const positions = new Float32Array(points.length * 3);
153153
points.forEach((p, i) => {
154154
positions[i * 3] = p.x;

src/ui/hotkeys.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { redo, undo } from "../document/history.js";
12
import { t, type TKey } from "../i18n/index.js";
23
import type { Preview } from "../preview/preview.js";
34
import { getViewSettings, updateViewSettings } from "../settings/store.js";
@@ -30,15 +31,34 @@ interface HotkeyContext {
3031

3132
export function attachHotkeys(tools: ToolController, preview: Preview): void {
3233
window.addEventListener("keydown", (e) => {
33-
if (e.ctrlKey || e.metaKey || e.altKey) return;
34+
// Let native undo/redo and text editing win inside form fields.
3435
if (isTypingTarget(e.target)) return;
36+
if (e.ctrlKey || e.metaKey) {
37+
handleUndoRedo(e);
38+
return;
39+
}
40+
if (e.altKey) return;
3541
const action = resolveAction(e);
3642
if (!action) return;
3743
action({ tools, preview });
3844
e.preventDefault();
3945
});
4046
}
4147

48+
// Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z and Ctrl/Cmd+Y = redo. Other modifier combos
49+
// fall through to the browser.
50+
function handleUndoRedo(e: KeyboardEvent): void {
51+
if (e.altKey) return;
52+
if (e.code === "KeyZ") {
53+
if (e.shiftKey) redo();
54+
else undo();
55+
e.preventDefault();
56+
} else if (e.code === "KeyY" && !e.shiftKey) {
57+
redo();
58+
e.preventDefault();
59+
}
60+
}
61+
4262
type Action = (ctx: HotkeyContext) => void;
4363

4464
function resolveAction(e: KeyboardEvent): Action | null {

src/ui/icons.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ export const ICONS = {
4949
<path d="M3 12.5V9h3.5"/>
5050
</svg>`,
5151

52+
undo: `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
53+
<path d="M4 7h6.5a3 3 0 0 1 0 6H6"/>
54+
<path d="M6.5 4 3.5 7l3 3"/>
55+
</svg>`,
56+
57+
redo: `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
58+
<path d="M12 7H5.5a3 3 0 0 0 0 6H10"/>
59+
<path d="M9.5 4l3 3-3 3"/>
60+
</svg>`,
61+
5262
help: `<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
5363
<circle cx="8" cy="8" r="6"/>
5464
<path d="M6.2 6.2a1.8 1.8 0 1 1 2.6 1.7c-.6.3-.8.6-.8 1.3"/>

src/ui/pickModifier.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export function attachPickModifier(preview: Preview): void {
3535
let hoverUuid: ModifierUUID | null = null;
3636
let nearbyKey = "";
3737
let pending: { x: number; y: number } | null = null;
38+
let lastPointer: { x: number; y: number } | null = null;
39+
// Force the overlay to redraw on the next recompute even if the hovered modifier's
40+
// identity is unchanged (its geometry may have moved under a stationary cursor, e.g.
41+
// undo/redo or a panel edit).
42+
let forceRedraw = false;
3843
let frame = 0;
3944

4045
const cursorMode = (): boolean => activeKind === null && getSelected() === null;
@@ -83,6 +88,8 @@ export function attachPickModifier(preview: Preview): void {
8388
if (!pending) return;
8489
const { x, y } = pending;
8590
pending = null;
91+
const force = forceRedraw;
92+
forceRedraw = false;
8693

8794
if (!cursorMode()) {
8895
clearHover();
@@ -114,7 +121,7 @@ export function attachPickModifier(preview: Preview): void {
114121
}
115122
}
116123

117-
if (best && best.uuid !== hoverUuid) {
124+
if (best && (force || best.uuid !== hoverUuid)) {
118125
hoverUuid = best.uuid;
119126
preview.setHoverPath(best.outline, best.closed, best.color);
120127
canvas.style.cursor = "pointer";
@@ -126,23 +133,28 @@ export function attachPickModifier(preview: Preview): void {
126133

127134
const others = nearby.filter((e) => e.uuid !== best?.uuid);
128135
const key = others.map((e) => e.uuid).join(",");
129-
if (key !== nearbyKey) {
136+
if (force || key !== nearbyKey) {
130137
nearbyKey = key;
131138
preview.setNearbyPaths(
132139
others.map((e) => ({ outline: e.outline, closed: e.closed, color: e.color })),
133140
);
134141
}
135142
};
136143

137-
const schedule = (e: PointerEvent): void => {
138-
pending = { x: e.clientX, y: e.clientY };
144+
const scheduleFrame = (): void => {
139145
if (frame) return;
140146
frame = requestAnimationFrame(() => {
141147
frame = 0;
142148
recompute();
143149
});
144150
};
145151

152+
const schedule = (e: PointerEvent): void => {
153+
lastPointer = { x: e.clientX, y: e.clientY };
154+
pending = lastPointer;
155+
scheduleFrame();
156+
};
157+
146158
canvas.addEventListener("pointermove", schedule);
147159
canvas.addEventListener("pointerleave", clearHover);
148160
canvas.addEventListener("click", (e) => {
@@ -160,6 +172,14 @@ export function attachPickModifier(preview: Preview): void {
160172
});
161173
signals.modifiers.on(() => {
162174
dirty = true;
175+
// Geometry may have changed under a stationary cursor (undo/redo, panel edits).
176+
// Refresh an already-visible highlight in place so its outline tracks the change
177+
// without waiting for the next pointer move.
178+
if (lastPointer && cursorMode() && (hoverUuid !== null || nearbyKey !== "")) {
179+
pending = lastPointer;
180+
forceRedraw = true;
181+
scheduleFrame();
182+
}
163183
});
164184
}
165185

0 commit comments

Comments
 (0)