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
+1 -20
View File
@@ -8,8 +8,7 @@ import { SnippetLibrary } from './components/SnippetLibrary';
import { SpecEditor } from './components/SpecEditor';
import { ThemeToggle } from './components/ThemeToggle';
import { Toaster } from './components/Toaster';
import { openModal, setConfirm, toggleDatasets } from './modals/ModalCoordinator';
import { openSettingsPopover } from './stores/SettingsPopoverStore';
import { openModal, setConfirm } from './modals/ModalCoordinator';
import { confirm } from './stores/ConfirmStore';
import { usePanesStore } from './stores/PanesStore';
import { exportWorkspace, importWorkspace } from './services/transfer';
@@ -47,24 +46,6 @@ export function App() {
setConfirm((message) => confirm({ title: 'Discard changes?', message, danger: true }));
}, []);
// Cmd/Ctrl+K toggles the Datasets manager (spec §05); Cmd/Ctrl+, opens the
// editor settings popover — the primary preference cluster, now that settings
// are distributed per pane (spec §07). Full key router lands in M6 (arch 04).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return;
if (e.key === 'k' || e.key === 'K') {
e.preventDefault();
toggleDatasets();
} else if (e.key === ',') {
e.preventDefault();
openSettingsPopover('editor-settings');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const handleImportFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
// Reset the input so picking the same file again still fires onChange.
+8 -16
View File
@@ -27,6 +27,7 @@ import { openModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { confirm } from '../stores/ConfirmStore';
import { hasInlineData } from '../stores/ExtractStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { notify } from '../stores/NotificationStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
@@ -152,18 +153,9 @@ function EditorToolbar() {
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
);
const handlePublish = () => {
if (!useSnippetStore.getState().activeSnippetId) return;
useSnippetStore.getState().publish();
// Success confirmation (spec §03D). Per the council's toast-copy rule
// (docs/architecture/10 → Toast copy), the title states the action and the
// message adds the consequence rather than paraphrasing it.
notify({
kind: 'success',
title: 'Snippet published',
message: 'Your draft is now the published version.',
});
};
// Publish + its success toast live in one place (services/snippet-actions) so
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
const handlePublish = publishActiveSnippet;
const handleRevert = async () => {
const ok = await confirm({
@@ -275,10 +267,10 @@ export function SpecEditor() {
}
});
// Cmd/Ctrl+S publishes the current draft (spec §03D → Publish).
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
if (useSnippetStore.getState().activeSnippetId) useSnippetStore.getState().publish();
});
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
// "bind listeners in exactly one place"), which publishes before the
// interactive-context gate so it works while the editor has focus. Monaco
// binds no default for Cmd+S, so the keystroke bubbles to that one handler.
return () => {
sub.dispose();
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { createSnippet } from '@core/snippet';
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { startEventRouter, stopEventRouter } from './EventRouter';
const T = new Date('2026-06-01T00:00:00Z');
const active = () => useSnippetStore.getState();
/** Dispatch a global keydown with both modifiers set so it fires on any platform. */
function press(key: string, opts: Partial<KeyboardEventInit> = {}): void {
window.dispatchEvent(
new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
ctrlKey: true,
metaKey: true,
...opts,
}),
);
}
beforeEach(() => {
useSnippetStore.getState().reset();
useAppStore.getState().setActiveModal(null);
document.body.innerHTML = '';
window.location.hash = '';
useSnippetStore.getState().hydrate([createSnippet({ id: 'a', now: T })], 'a');
startEventRouter();
});
afterEach(() => {
stopEventRouter();
});
describe('shortcut dispatch (spec §01D)', () => {
test('Cmd/Ctrl+Shift+N creates and activates a new snippet', () => {
expect(active().snippets).toHaveLength(1);
press('n', { shiftKey: true });
expect(active().snippets).toHaveLength(2);
expect(active().activeSnippetId).not.toBe('a');
});
test('Cmd/Ctrl+K toggles the Datasets manager open then closed', async () => {
press('k');
expect(useAppStore.getState().activeModal).toBe('datasets');
press('k');
await Promise.resolve(); // closeModal is async (no unsaved changes → no prompt)
expect(useAppStore.getState().activeModal).toBeNull();
});
test('Cmd/Ctrl+S publishes the active draft', () => {
active().updateDraft('{"published":1}');
press('s');
expect(active().snippets.find((s) => s.id === 'a')?.spec).toBe('{"published":1}');
});
test('Escape closes the active modal', async () => {
useAppStore.getState().setActiveModal('datasets');
press('Escape');
await Promise.resolve();
expect(useAppStore.getState().activeModal).toBeNull();
});
test('Escape with no modal open does nothing', () => {
expect(useAppStore.getState().activeModal).toBeNull();
press('Escape'); // must not throw or change state
expect(useAppStore.getState().activeModal).toBeNull();
});
});
describe('interactive-context gating', () => {
test('N is suppressed while an input is focused', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
press('n', { shiftKey: true });
expect(active().snippets).toHaveLength(1); // no new snippet
});
test('Cmd/Ctrl+S still publishes while the editor/input is focused (deliberate divergence)', () => {
const input = document.createElement('input');
document.body.appendChild(input);
input.focus();
active().updateDraft('{"saved-while-typing":1}');
press('s');
expect(active().snippets.find((s) => s.id === 'a')?.spec).toBe('{"saved-while-typing":1}');
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* Global keyboard routing (spec §01D, docs/architecture/04 → "Global Events").
*
* One module owns the document-level `keydown` listener and dispatches the app's
* shortcuts. Centralizing it keeps ordering explicit and gives one place to
* reason about priority — components never attach their own `window` keydown
* handlers (see the "Don't" list in docs/architecture/04 §2).
*
* The shortcut map (spec §01D), platform-aware (Cmd on Mac, Ctrl elsewhere):
* Cmd/Ctrl + Shift + N → new snippet
* Cmd/Ctrl + K → toggle the Datasets manager
* Cmd/Ctrl + S → publish the active snippet's draft
* Cmd/Ctrl + , → open the editor settings popover
* Escape → close the active modal
*
* Interactive-context gating (the `isInInteractiveContext` seam):
*
* - **Escape** is checked *before* the gate — it must dismiss a modal even while
* focus is in the editor or an input.
* - **Cmd/Ctrl+S** is also checked before the gate. It is the canonical "save"
* shortcut and the spec mandates it override the browser default; publishing is
* something the user does *while editing the draft in Monaco*, so gating it
* behind "not typing" would defeat its purpose. (Deliberate divergence from the
* arch sketch, which placed Cmd+S after the gate.)
* - **N / K / ,** are checked *after* the gate, so they never steal a keystroke
* from the editor or an input (e.g. Monaco's own Cmd+K chord).
*/
import { closeModal, toggleDatasets } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { openSettingsPopover } from '../stores/SettingsPopoverStore';
import { publishActiveSnippet } from '../services/snippet-actions';
import { isInInteractiveContext } from './focus-utils';
let started = false;
const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform);
function modifier(e: KeyboardEvent): boolean {
return isMac ? e.metaKey : e.ctrlKey;
}
/** Returns true if it consumed the Escape (caller should `preventDefault`). */
function handleEscape(): boolean {
// Route through the coordinator so the unsaved-change discard prompt runs and
// the URL is cleared. NEVER setActiveModal(null) here — that would silently
// drop in-progress dataset / chart-builder edits. Escape only acts when a
// modal is open; with no modal it does nothing (spec §01D).
if (useAppStore.getState().activeModal) {
void closeModal();
return true;
}
return false;
}
function onKeyDown(e: KeyboardEvent): void {
// Escape: highest priority, runs even while the editor / an input has focus.
if (e.key === 'Escape') {
if (handleEscape()) e.preventDefault();
return;
}
if (!modifier(e)) return;
const key = e.key.toLowerCase();
// Cmd/Ctrl+S: publish. Handled before the interactive-context gate so it works
// while editing the draft, and overrides the browser "save page" dialog.
if (key === 's' && !e.shiftKey) {
e.preventDefault();
publishActiveSnippet();
return;
}
// Everything below must not fire while the user is typing.
if (isInInteractiveContext()) return;
// Cmd/Ctrl+Shift+N: new snippet (selection drives the URL via the routing
// subscription, so no explicit navigate call is needed).
if (key === 'n' && e.shiftKey) {
e.preventDefault();
useSnippetStore.getState().createSnippet();
return;
}
// Cmd/Ctrl+K: toggle the Datasets manager (coordinator owns open/close + URL).
if (key === 'k' && !e.shiftKey) {
e.preventDefault();
toggleDatasets();
return;
}
// Cmd/Ctrl+,: open the editor settings popover. Settings are distributed to
// per-pane disclosure popovers, not a modal (spec §07); the editor cluster is
// the primary one (the arch sketch's openModal('settings') predates that).
if (e.key === ',') {
e.preventDefault();
openSettingsPopover('editor-settings');
return;
}
}
/** Bind the global keyboard listener. Idempotent; pair with `stopEventRouter`. */
export function startEventRouter(): void {
if (started) return;
started = true;
window.addEventListener('keydown', onKeyDown);
}
/** Unbind the global keyboard listener (teardown for tests). */
export function stopEventRouter(): void {
window.removeEventListener('keydown', onKeyDown);
started = false;
}
+48
View File
@@ -0,0 +1,48 @@
import { afterEach, describe, expect, test } from 'vitest';
import { isInInteractiveContext } from './focus-utils';
afterEach(() => {
document.body.innerHTML = '';
});
/** Append `el` to the document, focus it, and return it. */
function mountAndFocus<T extends HTMLElement>(el: T): T {
document.body.appendChild(el);
el.focus();
return el;
}
describe('isInInteractiveContext', () => {
test('false when nothing is focused', () => {
expect(isInInteractiveContext()).toBe(false);
});
test('true for a focused <input>', () => {
mountAndFocus(document.createElement('input'));
expect(isInInteractiveContext()).toBe(true);
});
test('true for a focused contenteditable element', () => {
const div = document.createElement('div');
div.contentEditable = 'true';
mountAndFocus(div);
expect(isInInteractiveContext()).toBe(true);
});
test('true when focus is inside the Monaco editor', () => {
const editor = document.createElement('div');
editor.className = 'monaco-editor';
const textarea = document.createElement('textarea');
textarea.className = 'inputarea';
editor.appendChild(textarea);
document.body.appendChild(editor);
// Focus an inner node: the .monaco-editor ancestor must still count.
textarea.focus();
expect(isInInteractiveContext()).toBe(true);
});
test('false for a focused <button>', () => {
mountAndFocus(document.createElement('button'));
expect(isInInteractiveContext()).toBe(false);
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* Single-source "is the user typing in an editable surface?" helper
* (docs/architecture/04 §2.2).
*
* App layer (browser APIs allowed): reads `document.activeElement`. Every global
* shortcut path and the paste-to-import handler call this so they never fire
* while the user is typing in an input or the Monaco editor. Do not inline these
* element-type checks at call sites — one place to get it right, one to fix.
*
* Monaco difference (important): Astrolabe's spec editor is Monaco, not
* CodeMirror. Monaco renders into a `.monaco-editor` container and keeps focus on
* a hidden `<textarea class="inputarea">` inside it. We detect via the
* `.monaco-editor` ancestor — never a `.cm-editor` / `.cm-content` CodeMirror
* selector, which would silently fail and let shortcuts fire mid-edit.
*/
/**
* True when focus is in an editable surface where global shortcuts and
* paste-to-import must be suppressed: `<input>`, `<textarea>`, `<select>`,
* contenteditable, or the Monaco editor.
*
* This is the SINGLE source of truth — do not inline these checks elsewhere.
*/
export function isInInteractiveContext(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (el.isContentEditable) return true;
// Monaco renders into a .monaco-editor container; its focused element is a
// hidden <textarea class="inputarea"> (already caught above) but guard the
// container explicitly so focus on any inner node still counts.
if (el.closest?.('.monaco-editor')) return true;
return false;
}
+4
View File
@@ -18,6 +18,7 @@ import { useDatasetStore } from '../stores/DatasetStore';
import { wirePersistence } from './persistence';
import { wireDatasetPersistence } from './dataset-persistence';
import { startRouting } from '../modals/UrlStateSync';
import { startEventRouter } from './EventRouter';
let started = false;
@@ -72,4 +73,7 @@ export async function initApp(): Promise<void> {
// Routing starts AFTER hydrate so the on-load hash restore can resolve snippet
// / dataset ids against the loaded stores (spec §01E, docs/architecture/04).
startRouting();
// Global keyboard shortcuts (spec §01D). Order after routing so a shortcut that
// navigates (new snippet, toggle Datasets) writes through a started router.
startEventRouter();
}
+35
View File
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createSnippet } from '@core/snippet';
import { useNotificationStore } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { publishActiveSnippet } from './snippet-actions';
const T = new Date('2026-06-01T00:00:00Z');
const snippets = () => useSnippetStore.getState();
const toasts = () => useNotificationStore.getState().notifications;
beforeEach(() => {
useSnippetStore.getState().reset();
useNotificationStore.getState().clear();
});
describe('publishActiveSnippet', () => {
test('publishes the active draft and raises one success toast', () => {
snippets().hydrate([createSnippet({ id: 'a', now: T })], 'a');
snippets().updateDraft('{"published":1}');
publishActiveSnippet();
expect(snippets().snippets.find((s) => s.id === 'a')?.spec).toBe('{"published":1}');
expect(toasts()).toHaveLength(1);
expect(toasts()[0]).toMatchObject({ kind: 'success', title: 'Snippet published' });
});
test('is a silent no-op when no snippet is active', () => {
snippets().hydrate([], null);
publishActiveSnippet();
expect(toasts()).toHaveLength(0);
});
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Snippet action helpers that pair a store mutation with its user-facing
* confirmation, so every trigger of an action behaves identically.
*
* `publishActiveSnippet` is the single publish-with-confirmation path: the
* Publish button (SpecEditor) and the Cmd/Ctrl+S shortcut (EventRouter) both go
* through it, so the success toast (spec §03D) fires no matter how the user
* published. Without this, Cmd+S published silently while the button toasted — a
* trigger-dependent inconsistency.
*/
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
/**
* Publish the active snippet's draft and confirm it. No-op (no toast) when no
* snippet is active. The toast copy follows the council's rule (architecture 10
* → Toast copy): the title states the action, the message adds the consequence.
*/
export function publishActiveSnippet(): void {
if (!useSnippetStore.getState().activeSnippetId) return;
useSnippetStore.getState().publish();
notify({
kind: 'success',
title: 'Snippet published',
message: 'Your draft is now the published version.',
});
}