Add global keyboard EventRouter and unify publish (M6, §01D)

One module owns the document keydown listener and dispatches the shortcut map
(Cmd/Ctrl+Shift+N / +K / +S / +, / Escape), platform-aware, via the single-source
focus-utils interactive-context gate. Escape and Cmd/Ctrl+S run before the gate so
save works while editing; the rest are suppressed mid-typing. Publish + its toast
move into services/snippet-actions so the button and Cmd/Ctrl+S behave identically.
Removes the ad-hoc keydown handler from App and Monaco's Cmd+S command.
This commit is contained in:
2026-06-07 17:18:55 +03:00
parent f365258fcc
commit 807d3c8e3c
12 changed files with 401 additions and 48 deletions
+22 -12
View File
@@ -320,7 +320,17 @@ function onKeyDown(e: KeyboardEvent): void {
const mod = isMac ? e.metaKey : e.ctrlKey;
// --- Shortcuts: never fire while typing in an input or Monaco ----------
// Cmd/Ctrl + S -> publish current draft. Checked BEFORE the interactive-context
// gate: it is the canonical "save" shortcut (spec §01D mandates it override the
// browser default), and publishing happens *while editing the draft in Monaco* —
// gating it behind "not typing" would defeat its purpose.
if (mod && !e.shiftKey && e.key.toLowerCase() === 's') {
e.preventDefault(); // override the browser "save page" dialog
useSnippetStore.getState().publishDraft();
return;
}
// --- Remaining shortcuts: never fire while typing in an input or Monaco ----
if (isInInteractiveContext()) return;
// Cmd/Ctrl + Shift + N -> new snippet
@@ -336,16 +346,12 @@ function onKeyDown(e: KeyboardEvent): void {
toggleDatasets();
return;
}
// Cmd/Ctrl + S -> publish current draft
if (mod && e.key.toLowerCase() === 's') {
e.preventDefault(); // override the browser "save page" dialog
useSnippetStore.getState().publishDraft();
return;
}
// Cmd/Ctrl + , -> settings (through the coordinator: snapshot + URL sync)
// Cmd/Ctrl + , -> open the editor settings popover. Settings are distributed to
// per-pane disclosure popovers, not a modal (spec §07), so this opens the editor
// cluster (openSettingsPopover) — there is no 'settings' modal to open.
if (mod && e.key === ',') {
e.preventDefault();
openModal('settings');
openSettingsPopover('editor-settings');
return;
}
}
@@ -392,9 +398,13 @@ by inserting a rung at the right priority — never by sprinkling
`e.preventDefault()` so Cmd/Ctrl+S does not trigger "save page", Cmd/Ctrl+K
does not focus the browser search bar, etc.
Note the asymmetry: **Escape is checked before the interactive-context gate**
(you want Escape to dismiss a modal even while focus is in the editor), whereas
all other shortcuts are checked **after** the gate (so they don't fire mid-typing).
Note the asymmetry: **Escape and Cmd/Ctrl+S are checked before the
interactive-context gate.** Escape, so it dismisses a modal even while focus is in
the editor; Cmd/Ctrl+S, because publishing the draft is something you do _while_
editing it — gating "save" behind "not typing" would defeat it (and it must
override the browser's "save page" regardless). The remaining shortcuts (new
snippet, toggle Datasets, settings) are checked **after** the gate, so they never
fire mid-typing or steal a key the editor wants (e.g. Monaco's own Cmd+K chord).
### 2.2 The single-source helper: `orchestration/focus-utils.ts`
@@ -65,6 +65,13 @@ nature of the message, not by convenience.
_inline on the control_ ("Copied") with a polite `aria-live` announcement for assistive
tech, never a toast-per-copy. This refines the spec's earlier blanket "every action
toasts" (spec §01F/§02/§05, reconciled).
- **An action's confirmation belongs with the action, not its call site.** When an action is
reachable from more than one trigger (e.g. publish is both a toolbar button _and_
Cmd/Ctrl+S), pair the store mutation and its toast in **one `services/` helper**
(`publishActiveSnippet`) that every trigger calls — otherwise the toast rides one path and
the other publishes silently (the exact inconsistency this rule prevents). The shortcut is
owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is
confirmed.
- **The same failure can light up two channels.** An unrenderable spec shows the _same_
message inline in both the editor (§03E) and the preview (§04) — one producer
(`PreviewStore`), two subscribers. That's intentional, not duplication.