Shortcuts & Command Palette
The contextual command registry, the searchable shortcuts overlay (?), and the fuzzy command palette (⌘⇧K).
commands.ts is a flat, contextual command registry: every app-level action reachable from a keyboard shortcut — tool switches, undo/redo, clipboard, delete, align, file operations, help — is one CommandDef entry. Two UI surfaces consume this list directly: the keyboard-driven fallback dispatcher in Editor.tsx, and the two dialogs this page covers, the searchable shortcuts overlay (?) and the fuzzy command palette (⌘/Ctrl+Shift+K). A command's chord, label, or availability can never drift out of sync between "what fires on keydown" and "what a shortcuts UI lists," because both read the exact same array.
Note
This replaces the old hand-maintained static shortcuts table (dialogs/) — that file is gone; its dialog id and file are now shortcut-panel.
The command registry
interface CommandDef {
id: string;
label: string;
category: string; // Tool | Edit | View | Align | File | Text | Help
chord?: Chord; // absent = not keyboard-reachable (palette-only)
displayOnly?: boolean; // real gesture owned elsewhere (Paste, Nudge)
preventDefault?: boolean;
run(ctx: CommandContext): void;
isEnabled(ctx: CommandContext): boolean;
shortcutDisplay?: string; // overrides the chord-derived display string
}Every command reads ctx fresh on every invocation — commands are never pre-bound closures baked in at registration time, a deliberate divergence from the reference port's CommandCallbacks indirection layer. Tool-switch commands aren't hand-listed either: toolCommands() derives one command per tool from the tool registry on every call, so a tool's own shortcut field stays the single source of truth for both the toolbar and this list.
Two flags change how a command participates in the two surfaces below, independently:
chordabsent ("chordless") — the command has no keyboard shortcut at all; it's reachable only from the command palette. Align, Distribute, New Panel, Import/Download JSON, Download Gerber (.zip), the three zoom actions, and Browse Google Fonts are all chordless by design.displayOnly: true— the command does have a chord (or an explicitshortcutDisplay) worth showing, but its real gesture is owned by other code andrun()is never invoked through the registry. Paste (the real gesture is the nativepasteevent — see Clipboard → Paste) and Nudge (Arrows, Shift = ×10 —Editor.tsx's own arrow-key switch) are the only two.
Chord matching
interface Chord {
key: string | readonly string[]; // one key, or several meaning the same shortcut
meta?: boolean; // undefined = don't care; true/false = must/must-not be held
shift?: boolean;
alt?: boolean;
}meta matches metaKey or ctrlKey, on any platform — chords were never platform-gated (⌘/Ctrl+Z undoes on both Mac and Windows/Linux). is-mac.ts (navigator.platform.includes('Mac'), with iPad/iPhone and touch-capable MacIntel excluded) only decides which glyph to show — ⌘/⌥/⇧ vs Ctrl+/Alt+/Shift+ — never which physical key is accepted.
Dispatch order
Editor.tsx's global keydown handler routes every key exactly the way the rest of the app does (see Tools → The pointer/keyboard event contract):
The active tool's
onKeyDowngets first refusal.If it doesn't claim the key,
dispatchCommand()finds the first enabled, non-displayOnlycommand whose chord matches and runs it (preventDefault()-ing first, if the command asks for it).If nothing in the registry matched, a small bespoke switch handles arrow-key nudge — four keys plus a Shift-scaled step size don't collapse into one
Chord, so nudging stays outside the registry (edit-nudgeexists only as adisplayOnlyentry so it can still be listed).
Keyboard input is ignored globally while an <input>/<textarea>/<select> is focused, so typing into the layer-rename field or a number field never fires a shortcut.
The keyboard shortcuts overlay
Opened by the bare ? key (no modifier — the help-shortcuts command's chord is just { key: '?' }) or the header's ? button. dialogs/ lists every command that has something to show — a real chord, or an explicit shortcutDisplay — which includes both runnable commands (tool switches, undo/redo, clipboard, delete, deselect, help/palette) and the two displayOnly entries (Paste, Nudge), grouped by category and filterable by a search box:
Search — a text input (placeholder "Search shortcuts…") filters by substring match against a command's
labelorcategory(e.g. typing "edit" surfaces the whole Edit group). An empty result shows "No shortcuts match "‹query›"".Grouping — matches are grouped under uppercase category headers, in registry order.
Focus — the search input autofocuses on open (a layout-effect trick deliberately wins the race against the dialog host's generic "focus the first focusable descendant" fallback, since the Close button sits earlier in DOM order — without it, a keystroke meant as a filter query could instead re-trigger an editor shortcut behind the modal).
Chordless commands (Align, New Panel, Import/Download JSON, Download Gerber (.zip), zoom, Browse Google Fonts) do not appear here — they have nothing to show. Reach for the command palette to run those.
The command palette
Opened by ⌘/Ctrl+Shift+K. dialogs/ is a fuzzy-searchable, keyboard-navigable list of every runnable command (displayOnly entries are excluded — running Paste or Nudge from a list would silently do nothing):
Fuzzy search — a hand-rolled subsequence matcher (
fuzzyScore): every character of the query must appear in the target, in order, not necessarily contiguously ("cpy"matches"Copy"). It scores against the command'slabelandcategorycombined, so a query like"align"surfaces every Align command even when none of their labels contain that substring literally. Lower score = tighter match (a contiguous run costs nothing; each gap between matched characters adds its length); results are sorted best-match-first.Recents, when the query is empty — up to 8 most-recently-run command ids persist to
localStorageunderzpd.palette-recents.v1, most-recent-first, deduped. They lead the list ahead of everything else (in natural registry order) until you start typing, at which point fuzzy ranking takes over completely.Keyboard nav —
↑/↓move the highlight (clamped to the list bounds),Enterruns the highlighted command,Escapeis deliberately not handled here — it bubbles up to the shared dialog-host Escape-to-close listener (see below).Disabled rows stay visible but inert — a command whose
isEnabled(ctx)is currently false (e.g. Browse Google Fonts, enabled only when atextlayer is selected) still appears, dimmed and unclickable, rather than disappearing from the list.Running a command records it as a recent and closes the palette — unless the command itself opened a different dialog (e.g. Keyboard Shortcuts opens
shortcut-panel, Browse Google Fonts opensfont-explorer). Since the dialog store only tracks one open dialog at a time, the palette checks it's still the one open before closing over whateverrun()just opened.
Shared dialog chrome
Both dialogs (like every registered dialog — see Add-Actions & Dialogs) render through components/, which is the single owner of: the role="dialog" aria-modal="true" backdrop, a focus trap confined to the dialog while it's open, initial focus on the first focusable descendant (deferred to a component that already moved focus itself, like the shortcuts overlay's search box), Escape-to-close, and restoring focus to whatever was focused before the dialog opened. Registered dialog components supply content only — none of this chrome is reimplemented per dialog.