feat(canvas): SimulatorCanvas routes mutations through record* actions

Wires every user-initiated canvas mutation in SimulatorCanvas to the
recorded variants added in 99ed22b, so Ctrl+Z (next commit) can roll
each one back as a single step.

Routed through record*:
- handleSelectComponent (picker → add) → recordAddComponent
- Delete keyboard handler (selectedComponentId branch) → recordRemoveComponent
- handleRotateComponent → recordRotate (still mutates live via
  updateComponent so the rotation visually applies; record stores the
  prev/next angles for undo)
- Drag → recordMove on mouseup. Captures component.x/y at mousedown in
  a new dragStartPosRef and only records on actual drag-end (skips the
  click branch that just opens the property dialog).
- Pin-click "finish wire" path → calls finishWireCreation (which
  atomically appends the wire) then pushes a CanvasCommand for that
  wire with applyNow:false (state is already at post-add).
- Selected-wire delete (keyboard + SelectionActionBar + PinPickerDialog)
  → recordRemoveWire
- Selection action bar component delete → recordRemoveComponent
- Pin picker dialog component delete → recordRemoveComponent
- ComponentPropertyDialog onPropertyChange → updateComponent applies
  live, then recordSetProperty captures prev/next so Ctrl+Z reverts the
  value without re-running the raw mutation.

Cleaned up unused destructures of addComponent / removeComponent /
removeWire from the original useSimulatorStore() call — every call site
now uses the record* equivalents.

No new keyboard shortcuts or toolbar buttons in this commit; that's
landing next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-08 12:09:20 -03:00
parent 99ed22ba5d
commit df8e666aa3
1 changed files with 82 additions and 16 deletions

View File

@ -102,13 +102,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
pinManager,
initSimulator,
updateComponentState,
addComponent,
removeComponent,
removeBoard,
updateComponent,
serialMonitorOpen,
toggleSerialMonitor,
} = useSimulatorStore();
// `addComponent` / `removeComponent` / `removeWire` are no longer used here —
// every user-initiated mutation routes through the record* actions below
// so it can be undone. Raw mutators are still available for transient
// operations (e.g. drag preview frames) but those don't live in this file.
// Active board (for WiFi/BLE status display)
const activeBoard = boards.find((b) => b.id === activeBoardId) ?? null;
@ -128,10 +130,20 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
const recalculateAllWirePositions = useSimulatorStore((s) => s.recalculateAllWirePositions);
const selectedWireId = useSimulatorStore((s) => s.selectedWireId);
const setSelectedWire = useSimulatorStore((s) => s.setSelectedWire);
const removeWire = useSimulatorStore((s) => s.removeWire);
const updateWire = useSimulatorStore((s) => s.updateWire);
const wires = useSimulatorStore((s) => s.wires);
// Recorded canvas actions — these wrap the raw mutators above with an
// undoable CanvasCommand. Use these at the *commit* point of a user
// interaction (drag-end, click finish, picker confirm); use the raw
// mutators for transient state during the interaction (drag preview).
const recordAddComponent = useSimulatorStore((s) => s.recordAddComponent);
const recordRemoveComponent = useSimulatorStore((s) => s.recordRemoveComponent);
const recordMove = useSimulatorStore((s) => s.recordMove);
const recordRotate = useSimulatorStore((s) => s.recordRotate);
const recordSetProperty = useSimulatorStore((s) => s.recordSetProperty);
const recordRemoveWire = useSimulatorStore((s) => s.recordRemoveWire);
// Oscilloscope
const oscilloscopeOpen = useOscilloscopeStore((s) => s.open);
const toggleOscilloscope = useOscilloscopeStore((s) => s.toggleOscilloscope);
@ -199,6 +211,10 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
// Component dragging state
const [draggedComponentId, setDraggedComponentId] = useState<string | null>(null);
// Captures (x, y) of the dragged component at mousedown so a drag-end
// can record the diff as a single undoable Move. Boards are intentionally
// skipped — board moves don't go through component history.
const dragStartPosRef = useRef<{ x: number; y: number } | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
// Canvas ref for coordinate calculations
@ -1067,7 +1083,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedComponentId) {
removeComponent(selectedComponentId);
// Recorded so the user can Ctrl+Z this back. Cascades wire removal too.
recordRemoveComponent(selectedComponentId);
setSelectedComponentId(null);
} else if (activeBoardId) {
setBoardToRemove(activeBoardId);
@ -1077,7 +1094,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedComponentId, removeComponent, activeBoardId]);
}, [selectedComponentId, recordRemoveComponent, activeBoardId]);
// Handle component selection from modal
const handleSelectComponent = (metadata: ComponentMetadata) => {
@ -1101,7 +1118,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
const component = createComponentFromMetadata(metadata, x, y);
trackAddComponent(metadata.id);
addComponent(component as any);
// Recorded — user can Ctrl+Z to remove the just-added component.
recordAddComponent(component as Parameters<typeof recordAddComponent>[0]);
setShowComponentPicker(false);
// Custom Chips need a compile step before they can do anything — open the
@ -1111,18 +1129,21 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
};
// Component rotation
// Component rotation — applies the new angle and records it as a single
// undoable command (round-trip flips the rotation property both ways).
const handleRotateComponent = (componentId: string) => {
const component = components.find((c) => c.id === componentId);
if (!component) return;
const currentRotation = (component.properties.rotation as number) || 0;
const nextRotation = (currentRotation + 90) % 360;
updateComponent(componentId, {
properties: {
...component.properties,
rotation: (currentRotation + 90) % 360,
rotation: nextRotation,
},
} as any);
recordRotate(componentId, currentRotation, nextRotation);
};
// Component dragging handlers
@ -1142,6 +1163,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
x: world.x - component.x,
y: world.y - component.y,
});
// Snapshot the starting position. mouseup uses this to push a single
// Move command if the component actually moved (vs being a click).
dragStartPosRef.current = { x: component.x, y: component.y };
setSelectedComponentId(componentId);
};
@ -1383,6 +1407,25 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
}
// If this was a real drag (not a click), commit one Move command
// so undo can roll the position back. Click-without-drag short-
// circuits via the (posDiff < 5 && timeDiff < 300) branch above
// and just opens the property dialog — no history entry needed.
const start = dragStartPosRef.current;
const isClick = posDiff < 5 && timeDiff < 300;
if (
!isClick &&
start &&
draggedComponentId &&
!draggedComponentId.startsWith('__board__')
) {
const moved = components.find((c) => c.id === draggedComponentId);
if (moved && (moved.x !== start.x || moved.y !== start.y)) {
recordMove(draggedComponentId, start, { x: moved.x, y: moved.y });
}
}
dragStartPosRef.current = null;
recalculateAllWirePositions();
setDraggedComponentId(null);
}
@ -1522,9 +1565,24 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
}
if (wireInProgress) {
// Finish wire: connect to this pin
// Finish wire: the store atomically appends the new wire and clears
// `wireInProgress`. Once that's done, we look up the wire it just
// created and push a CanvasCommand with applyNow:false (state is
// already at the post-add state). Undo removes the wire; redo re-adds.
finishWireCreation({ componentId, pinName, x, y });
trackCreateWire();
const wires = useSimulatorStore.getState().wires;
const created = wires[wires.length - 1];
if (created) {
useSimulatorStore.getState().pushCommand(
{
description: 'Add wire',
execute: () => useSimulatorStore.getState().addWire(created),
undo: () => useSimulatorStore.getState().removeWire(created.id),
},
{ applyNow: false },
);
}
} else {
// Start wire: auto-detect color from pin name
startWireCreation({ componentId, pinName, x, y }, autoWireColor(pinName));
@ -1539,9 +1597,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
cancelWireCreation();
return;
}
// Delete / Backspace → remove selected wire
// Delete / Backspace → remove selected wire (recorded for undo).
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedWireId) {
removeWire(selectedWireId);
recordRemoveWire(selectedWireId);
return;
}
// Color shortcuts (0-9, c, l, m, p, y) — Wokwi style
@ -1561,7 +1619,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
wireInProgress,
cancelWireCreation,
selectedWireId,
removeWire,
recordRemoveWire,
setWireInProgressColor,
updateWire,
]);
@ -2251,7 +2309,8 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
kind="wire"
label="Wire"
onDelete={() => {
removeWire(selectedWireId);
// Recorded so the touch / mobile delete is also undoable.
recordRemoveWire(selectedWireId);
setSelectedWire(null);
}}
onDeselect={() => setSelectedWire(null)}
@ -2269,7 +2328,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
canRotate
onRotate={() => handleRotateComponent(selectedComponentId)}
onDelete={() => {
removeComponent(selectedComponentId);
recordRemoveComponent(selectedComponentId);
setSelectedComponentId(null);
}}
onDeselect={() => setSelectedComponentId(null)}
@ -2317,7 +2376,7 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
setBoardToRemove(id);
} else {
setPinPicker(null);
removeComponent(id);
recordRemoveComponent(id);
setSelectedComponentId(null);
}
};
@ -2390,15 +2449,22 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
onClose={() => setShowPropertyDialog(false)}
onRotate={handleRotateComponent}
onDelete={(id) => {
removeComponent(id);
recordRemoveComponent(id);
setShowPropertyDialog(false);
}}
onPropertyChange={(id, propName, value) => {
const comp = components.find((c) => c.id === id);
if (comp) {
const prevValue = comp.properties[propName];
updateComponent(id, {
properties: { ...comp.properties, [propName]: value },
});
// Property panel is the canonical UI for property edits — record
// each change so Ctrl+Z reverts the value (without re-running the
// raw mutation, which already happened).
if (prevValue !== value) {
recordSetProperty(id, propName, prevValue, value);
}
}
}}
onPinSelect={(id, pinName) => {