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:
parent
df8e666aa3
commit
bd5fd18756
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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={() => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue