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;
|
||||
}
|
||||
|
||||
/* ── 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 {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -143,6 +143,12 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
const recordRotate = useSimulatorStore((s) => s.recordRotate);
|
||||
const recordSetProperty = useSimulatorStore((s) => s.recordSetProperty);
|
||||
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
|
||||
const oscilloscopeOpen = useOscilloscopeStore((s) => s.open);
|
||||
|
|
@ -1901,6 +1907,61 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
</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 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,34 @@ export const EditorPage: React.FC = () => {
|
|||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
|
|
|
|||
Loading…
Reference in New Issue