feat(canvas): undo/redo command pattern + history slice
Adds the foundation for canvas undo/redo. UI wiring, keyboard shortcuts,
toolbar buttons and agent-tools refactor land in follow-up commits.
useSimulatorStore.ts:
- New CanvasCommand type — { description, execute, undo }.
- HISTORY_MAX = 50 (oldest entries dropped on overflow).
- New state: history[] + historyIndex (-1 = empty).
- New APIs: pushCommand(cmd, {applyNow?}), undo, redo, canUndo, canRedo,
clearHistory.
- New "recorded" actions that wrap raw mutators with a CanvasCommand:
recordAddComponent, recordRemoveComponent, recordMove, recordRotate,
recordSetProperty, recordAddWire, recordRemoveWire, recordUpdateWire.
- recordRemoveComponent captures both the component AND any wires that
cascade with it, so undo restores both atomically.
- recordMove also re-runs updateWirePositions on undo/redo so wire
endpoints follow the component back/forward.
- setComponents and setWires (project-load / clear paths) now call
clearHistory inline — leaving stale commands pointing at IDs that no
longer exist would crash on undo.
Why custom Command pattern over zundo / travels:
- The store has 30+ ephemeral fields (simulator instances, serialOutput
growing byte-by-byte, hexEpoch counter, wireInProgress that ticks 60×/s
on drag). Snapshot/diff middleware would either burn memory tracking
them or need a fragile partialize allow-list.
- Per-op descriptions ("Add LED", "Move resistor") for tooltips come for
free with this approach; zundo/travels would need to infer them.
Tests: 15/15 in src/__tests__/undo-redo.test.ts — covers cap-at-50,
redo-truncation, cascade undo of remove-component, move/rotate/property
round trips, bulk-setter clearing.
Raw mutators (addComponent / removeComponent / updateComponent / addWire /
removeWire / updateWire) are unchanged. UI handlers can keep using them
during live drags for preview frames without spamming history; the
record* actions are what drag-end, click-finish and agent tools should
call going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5114c40af5
commit
99ed22ba5d
|
|
@ -0,0 +1,227 @@
|
||||||
|
/**
|
||||||
|
* Unit tests for the undo/redo history slice in useSimulatorStore.
|
||||||
|
*
|
||||||
|
* The store is a singleton, so each test resets it to a known baseline
|
||||||
|
* via the existing `setComponents([])` / `setWires([])` mutators (which
|
||||||
|
* also clear history — see beforeEach).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useSimulatorStore } from '../store/useSimulatorStore';
|
||||||
|
import type { Wire } from '../types/wire';
|
||||||
|
|
||||||
|
// Stub out anything that touches a custom-element or a real simulator —
|
||||||
|
// these tests only exercise the store's data layer.
|
||||||
|
vi.mock('../utils/pinPositionCalculator', () => ({
|
||||||
|
// updateWirePositions calls this; returning null = "fall back to raw x/y".
|
||||||
|
calculatePinPosition: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const led = (id: string, x = 0, y = 0) => ({
|
||||||
|
id,
|
||||||
|
metadataId: 'led',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
properties: { color: 'red' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const wire = (id: string, fromId: string, toId: string): Wire => ({
|
||||||
|
id,
|
||||||
|
start: { componentId: fromId, pinName: 'A', x: 0, y: 0 },
|
||||||
|
end: { componentId: toId, pinName: 'C', x: 0, y: 0 },
|
||||||
|
waypoints: [],
|
||||||
|
color: '#22c55e',
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Wipe the canvas + history before every test.
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.setComponents([]);
|
||||||
|
s.setWires([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('history primitives', () => {
|
||||||
|
it('starts empty', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
expect(s.history).toEqual([]);
|
||||||
|
expect(s.historyIndex).toBe(-1);
|
||||||
|
expect(s.canUndo()).toBe(false);
|
||||||
|
expect(s.canRedo()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at HISTORY_MAX (50)', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
s.recordAddComponent(led(`led-${i}`));
|
||||||
|
}
|
||||||
|
const after = useSimulatorStore.getState();
|
||||||
|
expect(after.history.length).toBe(50);
|
||||||
|
expect(after.historyIndex).toBe(49);
|
||||||
|
// The 10 oldest commands were dropped — undoing 50 times should NOT
|
||||||
|
// restore the canvas to fully empty.
|
||||||
|
for (let i = 0; i < 50; i++) after.undo();
|
||||||
|
const afterUndo = useSimulatorStore.getState();
|
||||||
|
// The first 10 leds were never undoable — they stay on the canvas.
|
||||||
|
expect(afterUndo.components.length).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates the redo branch when a new command pushes mid-history', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.recordAddComponent(led('a'));
|
||||||
|
s.recordAddComponent(led('b'));
|
||||||
|
s.recordAddComponent(led('c'));
|
||||||
|
s.undo(); // c removed, history still has 3 entries, index=1
|
||||||
|
s.undo(); // b removed, index=0
|
||||||
|
expect(useSimulatorStore.getState().historyIndex).toBe(0);
|
||||||
|
expect(useSimulatorStore.getState().history.length).toBe(3);
|
||||||
|
s.recordAddComponent(led('d')); // truncates b/c, pushes d
|
||||||
|
const after = useSimulatorStore.getState();
|
||||||
|
expect(after.history.length).toBe(2); // a + d
|
||||||
|
expect(after.historyIndex).toBe(1);
|
||||||
|
expect(after.components.map((c) => c.id).sort()).toEqual(['a', 'd']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordAddComponent', () => {
|
||||||
|
it('adds the component and pushes a command', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.recordAddComponent(led('x'));
|
||||||
|
expect(useSimulatorStore.getState().components).toHaveLength(1);
|
||||||
|
expect(useSimulatorStore.getState().history).toHaveLength(1);
|
||||||
|
expect(useSimulatorStore.getState().canUndo()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo removes it; redo restores it', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.recordAddComponent(led('x'));
|
||||||
|
s.undo();
|
||||||
|
expect(useSimulatorStore.getState().components).toHaveLength(0);
|
||||||
|
expect(useSimulatorStore.getState().canUndo()).toBe(false);
|
||||||
|
expect(useSimulatorStore.getState().canRedo()).toBe(true);
|
||||||
|
s.redo();
|
||||||
|
expect(useSimulatorStore.getState().components).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordRemoveComponent (cascade)', () => {
|
||||||
|
it('undo restores BOTH the component and its connected wires', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.addComponent(led('a'));
|
||||||
|
s.addComponent(led('b'));
|
||||||
|
s.addWire(wire('w1', 'a', 'b'));
|
||||||
|
s.addWire(wire('w2', 'a', 'b'));
|
||||||
|
// Remove 'a' — cascade should kill both wires.
|
||||||
|
s.recordRemoveComponent('a');
|
||||||
|
expect(useSimulatorStore.getState().components.map((c) => c.id)).toEqual(['b']);
|
||||||
|
expect(useSimulatorStore.getState().wires).toEqual([]);
|
||||||
|
s.undo();
|
||||||
|
const after = useSimulatorStore.getState();
|
||||||
|
expect(after.components.map((c) => c.id).sort()).toEqual(['a', 'b']);
|
||||||
|
expect(after.wires.map((w) => w.id).sort()).toEqual(['w1', 'w2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no-ops cleanly on a missing id', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.recordRemoveComponent('does-not-exist');
|
||||||
|
expect(useSimulatorStore.getState().history).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordMove', () => {
|
||||||
|
it('captures from/to, undo restores from, redo restores to', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.addComponent(led('m', 100, 100));
|
||||||
|
// Simulate a drag — UI mutated to (200,200) directly via raw mutator.
|
||||||
|
s.updateComponent('m', { x: 200, y: 200 });
|
||||||
|
// Drag-end records the diff.
|
||||||
|
s.recordMove('m', { x: 100, y: 100 }, { x: 200, y: 200 });
|
||||||
|
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 200, y: 200 });
|
||||||
|
s.undo();
|
||||||
|
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 100, y: 100 });
|
||||||
|
s.redo();
|
||||||
|
expect(useSimulatorStore.getState().components[0]).toMatchObject({ x: 200, y: 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordRotate', () => {
|
||||||
|
it('flips rotation property both directions', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.addComponent(led('r'));
|
||||||
|
s.recordRotate('r', 0, 90);
|
||||||
|
s.updateComponent('r', { properties: { color: 'red', rotation: 90 } });
|
||||||
|
s.undo();
|
||||||
|
expect(useSimulatorStore.getState().components[0].properties.rotation).toBe(0);
|
||||||
|
s.redo();
|
||||||
|
expect(useSimulatorStore.getState().components[0].properties.rotation).toBe(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordSetProperty', () => {
|
||||||
|
it('undo/redo flips a property value', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.addComponent(led('p'));
|
||||||
|
s.updateComponent('p', { properties: { color: 'green' } });
|
||||||
|
s.recordSetProperty('p', 'color', 'red', 'green');
|
||||||
|
s.undo();
|
||||||
|
expect(useSimulatorStore.getState().components[0].properties.color).toBe('red');
|
||||||
|
s.redo();
|
||||||
|
expect(useSimulatorStore.getState().components[0].properties.color).toBe('green');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recordAddWire / recordRemoveWire', () => {
|
||||||
|
it('add: undo removes, redo restores', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.addComponent(led('a'));
|
||||||
|
s.addComponent(led('b'));
|
||||||
|
s.recordAddWire(wire('w', 'a', 'b'));
|
||||||
|
expect(useSimulatorStore.getState().wires).toHaveLength(1);
|
||||||
|
s.undo();
|
||||||
|
expect(useSimulatorStore.getState().wires).toHaveLength(0);
|
||||||
|
s.redo();
|
||||||
|
expect(useSimulatorStore.getState().wires).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove: undo brings back the wire intact', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.addComponent(led('a'));
|
||||||
|
s.addComponent(led('b'));
|
||||||
|
const w = wire('w', 'a', 'b');
|
||||||
|
s.addWire(w);
|
||||||
|
s.recordRemoveWire('w');
|
||||||
|
expect(useSimulatorStore.getState().wires).toHaveLength(0);
|
||||||
|
s.undo();
|
||||||
|
expect(useSimulatorStore.getState().wires[0]).toEqual(w);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bulk setters clear history', () => {
|
||||||
|
it('setComponents wipes the stack', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.recordAddComponent(led('x'));
|
||||||
|
s.recordAddComponent(led('y'));
|
||||||
|
expect(useSimulatorStore.getState().history).toHaveLength(2);
|
||||||
|
s.setComponents([]);
|
||||||
|
expect(useSimulatorStore.getState().history).toHaveLength(0);
|
||||||
|
expect(useSimulatorStore.getState().historyIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setWires wipes the stack', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.recordAddComponent(led('x'));
|
||||||
|
s.setWires([]);
|
||||||
|
expect(useSimulatorStore.getState().history).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearHistory', () => {
|
||||||
|
it('resets index and entries without touching components/wires', () => {
|
||||||
|
const s = useSimulatorStore.getState();
|
||||||
|
s.recordAddComponent(led('keep'));
|
||||||
|
s.clearHistory();
|
||||||
|
const after = useSimulatorStore.getState();
|
||||||
|
expect(after.components).toHaveLength(1); // component still there
|
||||||
|
expect(after.history).toHaveLength(0);
|
||||||
|
expect(after.canUndo()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -335,6 +335,32 @@ interface Component {
|
||||||
properties: Record<string, unknown>;
|
properties: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Undo/redo history ────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* One entry on the canvas undo/redo stack.
|
||||||
|
*
|
||||||
|
* description — human-readable label shown as the undo/redo button
|
||||||
|
* tooltip ("Undo: Move LED").
|
||||||
|
* execute() — applied on redo. Should be idempotent against the
|
||||||
|
* current state at redo time (the user may have undone
|
||||||
|
* several steps then started a new branch).
|
||||||
|
* undo() — reverts the change. Same idempotency contract.
|
||||||
|
*
|
||||||
|
* Commands that capture the inverse on construction (e.g. `recordMove`
|
||||||
|
* captures fromX/fromY) are pushed with `applyNow:false` because the
|
||||||
|
* mutation already happened — the command only needs to remember how to
|
||||||
|
* undo/redo it later. Commands that ARE the canonical mutation (e.g.
|
||||||
|
* `recordAddComponent`) are pushed with `applyNow:true` so a single call
|
||||||
|
* both performs the action and stores the undo path.
|
||||||
|
*/
|
||||||
|
export interface CanvasCommand {
|
||||||
|
description: string;
|
||||||
|
execute(): void;
|
||||||
|
undo(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_MAX = 50;
|
||||||
|
|
||||||
// ── Store interface ───────────────────────────────────────────────────────
|
// ── Store interface ───────────────────────────────────────────────────────
|
||||||
interface SimulatorState {
|
interface SimulatorState {
|
||||||
// ── Multi-board state ───────────────────────────────────────────────────
|
// ── Multi-board state ───────────────────────────────────────────────────
|
||||||
|
|
@ -436,6 +462,44 @@ interface SimulatorState {
|
||||||
updateWirePositions: (componentId: string) => void;
|
updateWirePositions: (componentId: string) => void;
|
||||||
recalculateAllWirePositions: () => void;
|
recalculateAllWirePositions: () => void;
|
||||||
|
|
||||||
|
// ── Undo/redo ────────────────────────────────────────────────────────────
|
||||||
|
/** Bounded ring buffer of canvas mutations (HISTORY_MAX = 50). */
|
||||||
|
history: CanvasCommand[];
|
||||||
|
/** Index of the last APPLIED command. -1 = empty / fully undone. */
|
||||||
|
historyIndex: number;
|
||||||
|
/** Push a command and (by default) execute it. Truncates the redo stack. */
|
||||||
|
pushCommand: (cmd: CanvasCommand, opts?: { applyNow?: boolean }) => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
canUndo: () => boolean;
|
||||||
|
canRedo: () => boolean;
|
||||||
|
/** Wipe the stack (called on project load / clear). */
|
||||||
|
clearHistory: () => void;
|
||||||
|
/**
|
||||||
|
* Recorded canvas actions — these are the public API the UI and agent
|
||||||
|
* tools should use to mutate the canvas. Each one wraps a raw mutator
|
||||||
|
* with a CanvasCommand so the change is undoable. Drag-preview frames
|
||||||
|
* still use the raw mutators (addComponent / updateComponent / addWire
|
||||||
|
* / removeWire / updateWire) which DO NOT touch history.
|
||||||
|
*/
|
||||||
|
recordAddComponent: (component: Component) => void;
|
||||||
|
recordRemoveComponent: (id: string) => void;
|
||||||
|
recordMove: (
|
||||||
|
id: string,
|
||||||
|
from: { x: number; y: number },
|
||||||
|
to: { x: number; y: number },
|
||||||
|
) => void;
|
||||||
|
recordRotate: (id: string, prevRotation: number, nextRotation: number) => void;
|
||||||
|
recordSetProperty: (id: string, key: string, prevValue: unknown, nextValue: unknown) => void;
|
||||||
|
recordAddWire: (wire: Wire) => void;
|
||||||
|
recordRemoveWire: (wireId: string) => void;
|
||||||
|
recordUpdateWire: (
|
||||||
|
wireId: string,
|
||||||
|
prev: Partial<Wire>,
|
||||||
|
next: Partial<Wire>,
|
||||||
|
description?: string,
|
||||||
|
) => void;
|
||||||
|
|
||||||
// ── Serial monitor ──────────────────────────────────────────────────────
|
// ── Serial monitor ──────────────────────────────────────────────────────
|
||||||
toggleSerialMonitor: () => void;
|
toggleSerialMonitor: () => void;
|
||||||
serialWrite: (text: string) => void;
|
serialWrite: (text: string) => void;
|
||||||
|
|
@ -1503,7 +1567,11 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
||||||
|
|
||||||
handleComponentEvent: (_componentId, _eventName, _data) => {},
|
handleComponentEvent: (_componentId, _eventName, _data) => {},
|
||||||
|
|
||||||
setComponents: (components) => set({ components }),
|
setComponents: (components) => {
|
||||||
|
// Bulk replacement (project load / clear) — any pending undo/redo
|
||||||
|
// would point at component IDs that no longer exist after this.
|
||||||
|
set({ components, history: [], historyIndex: -1 });
|
||||||
|
},
|
||||||
|
|
||||||
addWire: (wire) => set((state) => ({ wires: [...state.wires, wire] })),
|
addWire: (wire) => set((state) => ({ wires: [...state.wires, wire] })),
|
||||||
|
|
||||||
|
|
@ -1524,6 +1592,9 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
||||||
set({
|
set({
|
||||||
// Ensure every wire has waypoints (backwards-compatible with saved projects)
|
// Ensure every wire has waypoints (backwards-compatible with saved projects)
|
||||||
wires: wires.map((w) => ({ waypoints: [], ...w })),
|
wires: wires.map((w) => ({ waypoints: [], ...w })),
|
||||||
|
// Bulk replacement clears history for the same reason as setComponents.
|
||||||
|
history: [],
|
||||||
|
historyIndex: -1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
startWireCreation: (endpoint, color) =>
|
startWireCreation: (endpoint, color) =>
|
||||||
|
|
@ -1645,6 +1716,239 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
||||||
set({ wires: updatedWires });
|
set({ wires: updatedWires });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Undo/redo ──────────────────────────────────────────────────────────
|
||||||
|
history: [],
|
||||||
|
historyIndex: -1,
|
||||||
|
|
||||||
|
pushCommand: (cmd, opts) => {
|
||||||
|
const applyNow = opts?.applyNow ?? true;
|
||||||
|
if (applyNow) cmd.execute();
|
||||||
|
set((state) => {
|
||||||
|
// Truncate the redo branch — once you push a new command, the
|
||||||
|
// entries you'd previously redone are abandoned.
|
||||||
|
const truncated = state.history.slice(0, state.historyIndex + 1);
|
||||||
|
let next = [...truncated, cmd];
|
||||||
|
let nextIdx = next.length - 1;
|
||||||
|
// Cap at HISTORY_MAX. When over, drop the oldest entry and shift
|
||||||
|
// the index down so it still points at the just-pushed command.
|
||||||
|
if (next.length > HISTORY_MAX) {
|
||||||
|
const overflow = next.length - HISTORY_MAX;
|
||||||
|
next = next.slice(overflow);
|
||||||
|
nextIdx = next.length - 1;
|
||||||
|
}
|
||||||
|
return { history: next, historyIndex: nextIdx };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
undo: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.historyIndex < 0) return;
|
||||||
|
const cmd = state.history[state.historyIndex];
|
||||||
|
try {
|
||||||
|
cmd.undo();
|
||||||
|
} catch (err) {
|
||||||
|
// A failing undo would otherwise leave the index pointing at a
|
||||||
|
// half-applied command. Bail out cleanly.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[history] undo failed:', cmd.description, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ historyIndex: state.historyIndex - 1 });
|
||||||
|
},
|
||||||
|
|
||||||
|
redo: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.historyIndex >= state.history.length - 1) return;
|
||||||
|
const cmd = state.history[state.historyIndex + 1];
|
||||||
|
try {
|
||||||
|
cmd.execute();
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[history] redo failed:', cmd.description, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({ historyIndex: state.historyIndex + 1 });
|
||||||
|
},
|
||||||
|
|
||||||
|
canUndo: () => get().historyIndex >= 0,
|
||||||
|
canRedo: () => {
|
||||||
|
const s = get();
|
||||||
|
return s.historyIndex < s.history.length - 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHistory: () => set({ history: [], historyIndex: -1 }),
|
||||||
|
|
||||||
|
// ── Recorded canvas actions ────────────────────────────────────────────
|
||||||
|
// Each `record*` builds a CanvasCommand that captures both directions
|
||||||
|
// and pushes it. Naming intent: the user has *committed* a change
|
||||||
|
// (drag-end, click finalised, agent tool execute) — distinct from the
|
||||||
|
// raw mutators above which can be called per-frame during a drag.
|
||||||
|
|
||||||
|
recordAddComponent: (component) => {
|
||||||
|
get().pushCommand({
|
||||||
|
description: `Add ${component.metadataId}`,
|
||||||
|
execute: () =>
|
||||||
|
set((s) => ({ components: [...s.components, component] })),
|
||||||
|
undo: () =>
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.filter((c) => c.id !== component.id),
|
||||||
|
// Mirror the cascade in removeComponent so a redo→undo round
|
||||||
|
// trip of an add-then-wired pair stays consistent.
|
||||||
|
wires: s.wires.filter(
|
||||||
|
(w) =>
|
||||||
|
w.start.componentId !== component.id && w.end.componentId !== component.id,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRemoveComponent: (id) => {
|
||||||
|
const state = get();
|
||||||
|
const removed = state.components.find((c) => c.id === id);
|
||||||
|
if (!removed) return;
|
||||||
|
// Capture wires that will be cascaded too — undo must restore both
|
||||||
|
// the component AND its wires together.
|
||||||
|
const removedWires = state.wires.filter(
|
||||||
|
(w) => w.start.componentId === id || w.end.componentId === id,
|
||||||
|
);
|
||||||
|
get().pushCommand({
|
||||||
|
description: `Remove ${removed.metadataId}`,
|
||||||
|
execute: () =>
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.filter((c) => c.id !== id),
|
||||||
|
wires: s.wires.filter(
|
||||||
|
(w) => w.start.componentId !== id && w.end.componentId !== id,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
undo: () =>
|
||||||
|
set((s) => ({
|
||||||
|
components: [...s.components, removed],
|
||||||
|
wires: [...s.wires, ...removedWires],
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
recordMove: (id, from, to) => {
|
||||||
|
// The state is already at `to` (caller mutated during drag). We push
|
||||||
|
// applyNow:false so we don't redundantly re-apply on first push;
|
||||||
|
// execute()/undo() are only invoked on future redo/undo.
|
||||||
|
get().pushCommand(
|
||||||
|
{
|
||||||
|
description: 'Move component',
|
||||||
|
execute: () => {
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.map((c) =>
|
||||||
|
c.id === id ? { ...c, x: to.x, y: to.y } : c,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
get().updateWirePositions(id);
|
||||||
|
},
|
||||||
|
undo: () => {
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.map((c) =>
|
||||||
|
c.id === id ? { ...c, x: from.x, y: from.y } : c,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
get().updateWirePositions(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ applyNow: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRotate: (id, prevRotation, nextRotation) => {
|
||||||
|
get().pushCommand(
|
||||||
|
{
|
||||||
|
description: 'Rotate component',
|
||||||
|
execute: () =>
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.map((c) =>
|
||||||
|
c.id === id
|
||||||
|
? { ...c, properties: { ...c.properties, rotation: nextRotation } }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
undo: () =>
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.map((c) =>
|
||||||
|
c.id === id
|
||||||
|
? { ...c, properties: { ...c.properties, rotation: prevRotation } }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ applyNow: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
recordSetProperty: (id, key, prevValue, nextValue) => {
|
||||||
|
get().pushCommand(
|
||||||
|
{
|
||||||
|
description: `Change ${key}`,
|
||||||
|
execute: () =>
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.map((c) =>
|
||||||
|
c.id === id
|
||||||
|
? { ...c, properties: { ...c.properties, [key]: nextValue } }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
undo: () =>
|
||||||
|
set((s) => ({
|
||||||
|
components: s.components.map((c) =>
|
||||||
|
c.id === id
|
||||||
|
? { ...c, properties: { ...c.properties, [key]: prevValue } }
|
||||||
|
: c,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ applyNow: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
recordAddWire: (wire) => {
|
||||||
|
get().pushCommand({
|
||||||
|
description: 'Add wire',
|
||||||
|
execute: () => set((s) => ({ wires: [...s.wires, wire] })),
|
||||||
|
undo: () =>
|
||||||
|
set((s) => ({
|
||||||
|
wires: s.wires.filter((w) => w.id !== wire.id),
|
||||||
|
selectedWireId: s.selectedWireId === wire.id ? null : s.selectedWireId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRemoveWire: (wireId) => {
|
||||||
|
const removed = get().wires.find((w) => w.id === wireId);
|
||||||
|
if (!removed) return;
|
||||||
|
get().pushCommand({
|
||||||
|
description: 'Remove wire',
|
||||||
|
execute: () =>
|
||||||
|
set((s) => ({
|
||||||
|
wires: s.wires.filter((w) => w.id !== wireId),
|
||||||
|
selectedWireId: s.selectedWireId === wireId ? null : s.selectedWireId,
|
||||||
|
})),
|
||||||
|
undo: () => set((s) => ({ wires: [...s.wires, removed] })),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
recordUpdateWire: (wireId, prev, next, description = 'Update wire') => {
|
||||||
|
get().pushCommand(
|
||||||
|
{
|
||||||
|
description,
|
||||||
|
execute: () =>
|
||||||
|
set((s) => ({
|
||||||
|
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...next } : w)),
|
||||||
|
})),
|
||||||
|
undo: () =>
|
||||||
|
set((s) => ({
|
||||||
|
wires: s.wires.map((w) => (w.id === wireId ? { ...w, ...prev } : w)),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{ applyNow: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
toggleSerialMonitor: () => set((s) => ({ serialMonitorOpen: !s.serialMonitorOpen })),
|
toggleSerialMonitor: () => set((s) => ({ serialMonitorOpen: !s.serialMonitorOpen })),
|
||||||
|
|
||||||
serialWrite: (text: string) => {
|
serialWrite: (text: string) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue