feat(canvas): keyboard shortcuts + toolbar undo/redo buttons

UI-facing half of the undo/redo feature. Combined with the previous two
commits, Ctrl+Z (or the toolbar button) now reverses every canvas
mutation: add/remove component, move, rotate, set property, add/remove
wire.

EditorPage.tsx:
- New window-keydown effect for Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z (and the
  Cmd equivalents). Uses the same input/textarea/contenteditable guard
  as the existing Ctrl+S handler — Monaco's per-file undo and the AI
  chat composer keep their own behaviour.

SimulatorCanvas.tsx:
- Two icon buttons (undo + redo) added to the canvas header, between
  the board selector and the Serial Monitor toggle. Tooltip surfaces
  the next command's description ("Undo: Add LED (Ctrl+Z)") so the
  user knows exactly what's about to revert. Buttons disable when the
  stack is empty in that direction.
- New `canvas-icon-btn` CSS class for square 32×32 icon-only buttons
  (matches the visual weight of the existing Serial button without
  the label).
- Subscribes to history / historyIndex via store selectors so the
  buttons re-render reactively as commands are pushed/undone.

No new tests — the store-level coverage from 99ed22b already exercises
undo/redo round trips. UI affordances are wired pass-through to those
store APIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-05-08 12:11:53 -03:00
parent df8e666aa3
commit bd5fd18756
3 changed files with 119 additions and 0 deletions

View File

@ -137,6 +137,36 @@
color: #4fc3f7; color: #4fc3f7;
} }
/* ── Canvas icon-only buttons (undo/redo, …) ─────── */
.canvas-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid #3a4a5a;
border-radius: 5px;
color: #9d9d9d;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
color 0.15s,
opacity 0.15s;
flex-shrink: 0;
}
.canvas-icon-btn:hover:not(:disabled) {
border-color: #007acc;
color: #ccc;
}
.canvas-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Component count ─────────────────────────────── */ /* ── Component count ─────────────────────────────── */
.component-count { .component-count {
display: flex; display: flex;

View File

@ -143,6 +143,12 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
const recordRotate = useSimulatorStore((s) => s.recordRotate); const recordRotate = useSimulatorStore((s) => s.recordRotate);
const recordSetProperty = useSimulatorStore((s) => s.recordSetProperty); const recordSetProperty = useSimulatorStore((s) => s.recordSetProperty);
const recordRemoveWire = useSimulatorStore((s) => s.recordRemoveWire); const recordRemoveWire = useSimulatorStore((s) => s.recordRemoveWire);
// Subscribe to history shape so the undo/redo buttons reactively
// enable/disable and their tooltips reflect the next command.
const history = useSimulatorStore((s) => s.history);
const historyIndex = useSimulatorStore((s) => s.historyIndex);
const undo = useSimulatorStore((s) => s.undo);
const redo = useSimulatorStore((s) => s.redo);
// Oscilloscope // Oscilloscope
const oscilloscopeOpen = useOscilloscopeStore((s) => s.open); const oscilloscopeOpen = useOscilloscopeStore((s) => s.open);
@ -1901,6 +1907,61 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
</span> </span>
)} )}
{/* Undo / Redo canvas-scoped, mirrors the Ctrl+Z / Ctrl+Y
handler in EditorPage. Tooltip surfaces the description of
the command that would be applied. Disabled when the stack
is exhausted in that direction. */}
<button
onClick={() => undo()}
disabled={historyIndex < 0}
className="canvas-icon-btn"
title={
historyIndex >= 0
? `Undo: ${history[historyIndex].description} (Ctrl+Z)`
: 'Nothing to undo'
}
aria-label="Undo"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 7v6h6" />
<path d="M3 13a9 9 0 1 0 3-7.7L3 8" />
</svg>
</button>
<button
onClick={() => redo()}
disabled={historyIndex >= history.length - 1}
className="canvas-icon-btn"
title={
historyIndex < history.length - 1
? `Redo: ${history[historyIndex + 1].description} (Ctrl+Y)`
: 'Nothing to redo'
}
aria-label="Redo"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 7v6h-6" />
<path d="M21 13a9 9 0 1 1-3-7.7L21 8" />
</svg>
</button>
{/* Serial Monitor toggle */} {/* Serial Monitor toggle */}
<button <button
onClick={() => { onClick={() => {

View File

@ -197,6 +197,34 @@ export const EditorPage: React.FC = () => {
return () => window.removeEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler);
}, [handleSaveClick]); }, [handleSaveClick]);
// Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z — canvas undo/redo. Skipped when the
// user is typing in any input/textarea/contenteditable so the Monaco
// editor's per-file history (and the AI chat composer, etc.) keep
// working untouched.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null;
if (t) {
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) {
return;
}
}
if (!(e.ctrlKey || e.metaKey)) return;
const k = e.key.toLowerCase();
const sim = useSimulatorStore.getState();
if (k === 'z' && !e.shiftKey) {
e.preventDefault();
sim.undo();
} else if (k === 'y' || (k === 'z' && e.shiftKey)) {
e.preventDefault();
sim.redo();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// Prevent body scroll on the editor page // Prevent body scroll on the editor page
useEffect(() => { useEffect(() => {
const html = document.documentElement; const html = document.documentElement;