From 45261f147e6ca6201cab38c8cc114c4dd21e4f33 Mon Sep 17 00:00:00 2001 From: Nayan Date: Sat, 15 Aug 2026 10:23:11 +0530 Subject: [PATCH] feat(overlay): declare every command once, and generate the reference from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chords were string literals at each `keys.bind` call site, so the only thing that knew a command existed was the line that bound it. Twenty-seven of the thirty-three shortcuts appeared nowhere in the product or the docs, five menu rows showed Mac glyphs to Windows users, and one advertised `⌘Z` for a feature `⌘Z` has never run. The only reference was six hand-written rows in `README.md`, and its copy under `apps/cli/` had already drifted. `keys/catalog.ts` is the declaration: chord, title, sentence, mode, surface, and where a scoped command applies. A binding supplies only `run`, `when` and `within`. `CommandId` is a union, so a mistyped id is a compile error rather than a shortcut that quietly never fires. Three surfaces read it. `keys/shortcuts-panel.ts` is the `?` sheet, grouped and marking what is live right now; `keys/palette.ts` is `⌘K`; and `MenuItem.command` renders a menu row's chord so nothing spells one by hand — `catalog.test.ts` fails any `hint:` literal that looks like a keystroke. `scripts/gen-controls.mjs` is the fourth reader. It imports the `.ts` catalog directly under `--experimental-strip-types`, which is why that module may contain no value imports, and writes `CONTROLS.md` plus the short table in `README.md`. `keys/controls-doc.test.ts` byte-compares the committed files against the same renderers, so drift fails the suite even on a Node that cannot strip types. The tooltip chip moves with it. It used to be found by matching the tip's own *text* against a binding's label — elegant until someone reworded a tip, at which point the chip vanished with nothing failing. A control now names its command with `data-key`. --- CONTROLS.md | 115 ++ packages/overlay/src/canvas/device-menu.ts | 2 +- packages/overlay/src/keys.test.ts | 245 ---- packages/overlay/src/keys.ts | 303 ----- packages/overlay/src/keys/catalog.test.ts | 252 ++++ packages/overlay/src/keys/catalog.ts | 1025 +++++++++++++++++ .../overlay/src/keys/controls-doc.test.ts | 84 ++ packages/overlay/src/keys/gestures.test.ts | 156 +++ packages/overlay/src/keys/keys.stories.ts | 210 ++++ packages/overlay/src/keys/palette.test.ts | 280 +++++ packages/overlay/src/keys/palette.ts | 268 +++++ packages/overlay/src/keys/registry.test.ts | 689 +++++++++++ packages/overlay/src/keys/registry.ts | 669 +++++++++++ .../overlay/src/keys/shortcuts-panel.test.ts | 174 +++ packages/overlay/src/keys/shortcuts-panel.ts | 197 ++++ packages/overlay/src/styles/help.css.ts | 134 +++ packages/overlay/src/toast.ts | 2 +- packages/overlay/src/tooltip.copy.test.ts | 314 +++-- packages/overlay/src/tooltip.stories.ts | 62 +- packages/overlay/src/tooltip.test.ts | 42 +- packages/overlay/src/tooltip.ts | 16 +- scripts/gen-controls.d.mts | 55 + scripts/gen-controls.mjs | 244 ++++ 23 files changed, 4871 insertions(+), 667 deletions(-) create mode 100644 CONTROLS.md delete mode 100644 packages/overlay/src/keys.test.ts delete mode 100644 packages/overlay/src/keys.ts create mode 100644 packages/overlay/src/keys/catalog.test.ts create mode 100644 packages/overlay/src/keys/catalog.ts create mode 100644 packages/overlay/src/keys/controls-doc.test.ts create mode 100644 packages/overlay/src/keys/gestures.test.ts create mode 100644 packages/overlay/src/keys/keys.stories.ts create mode 100644 packages/overlay/src/keys/palette.test.ts create mode 100644 packages/overlay/src/keys/palette.ts create mode 100644 packages/overlay/src/keys/registry.test.ts create mode 100644 packages/overlay/src/keys/registry.ts create mode 100644 packages/overlay/src/keys/shortcuts-panel.test.ts create mode 100644 packages/overlay/src/keys/shortcuts-panel.ts create mode 100644 packages/overlay/src/styles/help.css.ts create mode 100644 scripts/gen-controls.d.mts create mode 100644 scripts/gen-controls.mjs diff --git a/CONTROLS.md b/CONTROLS.md new file mode 100644 index 0000000..d734699 --- /dev/null +++ b/CONTROLS.md @@ -0,0 +1,115 @@ + + +# Controls + +Every keyboard shortcut and pointer gesture in the Airship editor. + +Press `?` in the editor for the same list with whatever is live right now +highlighted, or `⌘K` to search the commands and run one. + +A shortcut never fires while you are typing, with one exception: a field's +own `⌘↵`. On Safari the browser keeps `⌘+` and `⌘-` for its own zoom, so +the editor also answers to a plain `+` and `-`. + +## Edit + +| Command | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Undo | ⌘Z | Ctrl+Z | edit mode | Step back through your pending direct-manipulation edits. | +| Redo | ⌘⇧Z or ⌘Y | Ctrl+Shift+Z or Ctrl+Y | edit mode | Step forward again through edits you have undone. | +| Delete element | ⌫ or Del | Backspace or Del | edit mode | Remove the selected element. | +| Duplicate | ⌘D | Ctrl+D | edit mode | Copy the selected element in place. | +| Edit text | ↩ or T | Enter or T | edit mode | Edit the selected element's text in place. | +| Nudge | ← → ↑ ↓ | ← → ↑ ↓ | edit mode | Move the selected element one pixel. | +| Nudge by ten | ⇧ ← → ↑ ↓ | ⇧ ← → ↑ ↓ | edit mode | Move the selected element ten pixels. | + +## Selection + +| Command | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Deselect | Esc | Esc | edit mode | Clear the selection. | +| Move | V | V | edit mode | Hover highlights and clicks select. The default. | +| Inspect | I | I | edit mode | Hover reads out an element's specs instead of selecting it. | + +## View + +| Command | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Zoom in | ⌘= or = | Ctrl+= or = | canvas only | Zoom in a step, centred on the canvas. On Safari, use + rather than ⌘+. | +| Zoom out | ⌘- or - | Ctrl+- or - | canvas only | Zoom out a step. On Safari, use − rather than ⌘−. | +| Zoom to 100% | ⌘0 or ⇧0 | Ctrl+0 or Shift+0 | canvas only | Return the canvas to actual size. | +| Zoom to fit | ⇧1 | Shift+1 | canvas only | Fit every frame on screen. | +| Zoom to selection | ⇧2 | Shift+2 | canvas only | Fill the canvas with the current selection. | +| Hand tool | H | H | view mode, canvas only | Drag anywhere to move the canvas, leaving the page beneath untouched. | +| Put the Hand down | Esc | Esc | view mode, canvas only | Put the Hand down and go back to pointing. | + +## Frames + +| Command | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Add a frame | F | F | canvas only | Open the device picker and place a new frame on the canvas. | +| Delete frame | ⌫ or Del | Backspace or Del | view mode, canvas only | Remove the active frame from the canvas. | +| Bring frame forward | ↑ | ↑ | on a frame's handle | Move the frame up the stack, so it covers the ones it overlaps. | +| Send frame backward | ↓ | ↓ | on a frame's handle | Move the frame down the stack, behind the ones it overlaps. | + +## Agent + +| Command | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Send | ⌘↩ | Ctrl+Enter | anywhere | Send the description and the pending edits to the agent. | +| Add comment | ⌘↩ | Ctrl+Enter | in a comment | Attach the comment to the diff line it is anchored on. | +| Next change | → | → | on the change strip | Move to the next pending change on the composer's strip. | +| Previous change | ← | ← | on the change strip | Move to the previous pending change. | +| First change | Home | Home | on the change strip | Jump to the first pending change. | +| Last change | End | End | on the change strip | Jump to the last pending change. | +| Drop the change you are on | ⌫ or Del | Backspace or Del | on the change strip | Discard the pending change you are on. | + +## Help + +| Command | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Keyboard shortcuts | ? | ? | anywhere | Every shortcut and gesture, grouped, with what is live right now. | +| Command palette | ⌘K | Ctrl+K | anywhere | Search everything the editor can do right now, and run it. | + +## Menus + +| Command | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Close the menu | Esc | Esc | in an open menu | Close the open menu. | +| Next option | ↓ | ↓ | in an open menu | Move down the open menu. | +| Previous option | ↑ | ↑ | in an open menu | Move up the open menu. | +| First option | Home | Home | in an open menu | Jump to the first option. | +| Last option | End | End | in an open menu | Jump to the last option. | +| Choose option | ↩ | Enter | in an open menu | Take the option you are on. | +| Close the device menu | Esc | Esc | in the device menu | Close the frame's device menu. | +| Next result | ↓ | ↓ | in the palette | Move down the results. | +| Previous result | ↑ | ↑ | in the palette | Move up the results. | +| Run result | ↩ | Enter | in the palette | Run the result you are on. | +| Close the palette | Esc | Esc | in the palette | Clear the search, then close. | + +## Mouse and trackpad + +| Gesture | macOS | Windows / Linux | Where | | +| --- | --- | --- | --- | --- | +| Pan the canvas | Wheel / two-finger | Wheel / two-finger | canvas only | Two fingers or a wheel move the canvas under you. | +| Zoom at the cursor | ⌘-wheel / pinch | Ctrl-wheel / pinch | canvas only | Zooms toward the pointer, not the middle of the screen. | +| Pan without the Hand | Space-drag | Space-drag | canvas only | Hold space and drag, from anywhere, without changing tool. | +| Pan with the middle button | Middle-drag | Middle-drag | canvas only | The middle button pans, whatever tool is armed. | +| Scroll a frame | Wheel over the selected frame | Wheel over the selected frame | view mode, canvas only | A selected frame keeps the wheel to its own ends, so the canvas never lurches sideways. | +| Select an element | Click | Click | edit mode | Hover highlights, click selects. | +| Marquee-select | Drag from empty space | Drag from empty space | edit mode | Drag from empty space to band-select several elements. | +| Edit text in place | Double-click | Double-click | edit mode | Opens the caret in the element itself, not in a field beside it. | +| Open the element menu | Right-click | Right-click | edit mode | Verbs for the element you clicked, which it selects first. | +| Measure spacing | ⌥-hover | Alt-hover | edit mode | Hold Alt and hover to read the distance to the element under the pointer. | +| Move or resize a frame | Drag the title or a grip | Drag the title or a grip | view mode, canvas only | Drag a frame by its title; drag a grip to resize it. | +| Restack frames | Drag a row in the frame list | Drag a row in the frame list | view mode, canvas only | Drag a row in the frame list to change which frame is in front. | +| Jump the camera | Press or drag the minimap | Press or drag the minimap | view mode, canvas only | Press anywhere on the minimap to jump there, and keep dragging to keep moving. | +| Scrub a number | Drag a field's glyph | Drag a field's glyph | edit mode | Drag a field's glyph sideways. Shift for ten at a time, Alt for a tenth. | +| Re-dock a panel | Double-click a panel header | Double-click a panel header | anywhere | Double-click a floating panel's header to put it back against the edge. | +| Scroll the pending changes | Wheel over the strip | Wheel over the strip | anywhere | A vertical wheel scrolls the strip sideways, because a mouse has no sideways. | + +## In any field + +- Enter commits the field you are in; Esc reverts it. +- ↑ and ↓ step a number field. Shift for ten at a time, Alt for a tenth. +- A shortcut never fires while you are typing, except a field's own submit — ⌘↵ on a Mac, Ctrl+Enter elsewhere. diff --git a/packages/overlay/src/canvas/device-menu.ts b/packages/overlay/src/canvas/device-menu.ts index 63d61d7..3135b71 100644 --- a/packages/overlay/src/canvas/device-menu.ts +++ b/packages/overlay/src/canvas/device-menu.ts @@ -70,7 +70,7 @@ export function deviceGroups( * Both `Enter` and `Escape` are handled on the field, not left to the menu. * `Keys` skips every binding without `allowWhileTyping` while a field has focus, * so with this one focused the menu's own Escape never runs and the only way out - * would be the mouse. `keys.ts` prescribes exactly this — field-local commit and + * would be the mouse. `keys/registry.ts` prescribes exactly this — field-local commit and * cancel — and `renameFrame` already does it. */ export function customSizeRow( diff --git a/packages/overlay/src/keys.test.ts b/packages/overlay/src/keys.test.ts deleted file mode 100644 index ad4d4d6..0000000 --- a/packages/overlay/src/keys.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { PREFIX } from "./dom"; -import { isInsidePopover, keys } from "./keys"; - -/* - * The two questions the registry asks before it runs anything: "is the user - * typing?" and "is this keystroke inside a popover?". - * - * They used to be one flag, and that is the bug this file exists to hold shut. - * `isInsidePopover` was folded into `typing`, so a single `continue` skipped - * every binding without `allowWhileTyping` — including the ones `popover-host` - * registers for the popover *itself*, which carry no such flag. Every menu in - * the overlay lost Escape and its arrow keys, and nothing caught it because - * nothing here dispatched a key inside a popover. - * - * `within` is what separates them now, so the cases below are mostly about which - * of the two guards a binding is standing behind. - */ - -/** Undo every binding a test registered. The registry is a singleton. */ -const disposers: (() => void)[] = []; - -function bind(binding: Parameters[0]): () => void { - const off = keys.bind(binding); - disposers.push(off); - return off; -} - -/** - * Dispatch from a specific node. - * - * `bubbles` so the event reaches `document`, where the registry listens. The - * chord only needs `key` — `physicalKey` falls back to `e.key.toLowerCase()` - * for everything outside the digit and letter rows. - */ -function press(from: Node, key: string): KeyboardEvent { - const e = new KeyboardEvent("keydown", { - bubbles: true, - cancelable: true, - key, - }); - from.dispatchEvent(e); - return e; -} - -/** A popover shell, as `popover-host` builds it: the class is what is matched. */ -function popover(): { item: HTMLElement; shell: HTMLElement } { - const shell = document.createElement("div"); - shell.className = `${PREFIX}-pop`; - const item = document.createElement("button"); - shell.append(item); - document.body.append(shell); - return { item, shell }; -} - -function plain(tag = "button"): HTMLElement { - const node = document.createElement(tag); - document.body.append(node); - return node; -} - -afterEach(() => { - for (const off of disposers.splice(0)) { - off(); - } - document.body.replaceChildren(); -}); - -describe("isInsidePopover", () => { - it("is true for a node inside a popover shell and false outside one", () => { - const { item } = popover(); - expect(isInsidePopover(item)).toBe(true); - expect(isInsidePopover(plain())).toBe(false); - }); - - it("is false for a target that is not an element", () => { - // `document` and `window` reach the handler too, and neither has `closest`. - expect(isInsidePopover(document)).toBe(false); - expect(isInsidePopover(null)).toBe(false); - }); -}); - -describe("a binding scoped with `within`", () => { - it("fires for a keystroke inside its element", () => { - const { item, shell } = popover(); - const run = vi.fn(); - bind({ keys: "escape", label: "Close", run, within: shell }); - - press(item, "Escape"); - - expect(run).toHaveBeenCalledTimes(1); - }); - - it("declines a keystroke from outside its element", () => { - const { shell } = popover(); - const run = vi.fn(); - bind({ keys: "escape", label: "Close", run, within: shell }); - - press(plain(), "Escape"); - - expect(run).not.toHaveBeenCalled(); - }); - - it("does not reach a sibling popover's rows", () => { - // The nesting case: a menu opened from inside a popover is the host's - // sibling, not its descendant, so the outer scope must not contain it. - const outer = popover(); - const inner = popover(); - const outerRun = vi.fn(); - const innerRun = vi.fn(); - bind({ - keys: "arrowdown", - label: "Next option", - run: outerRun, - within: outer.shell, - }); - bind({ - keys: "arrowdown", - label: "Next option", - run: innerRun, - within: inner.shell, - }); - - press(inner.item, "ArrowDown"); - - expect(innerRun).toHaveBeenCalledTimes(1); - expect(outerRun).not.toHaveBeenCalled(); - }); - - it("declines a target in another document, where `contains` cannot reach", () => { - // The `observe` path: a frame's own document shares this binding table. - const { shell } = popover(); - const run = vi.fn(); - bind({ keys: "escape", label: "Close", run, within: shell }); - - const other = document.implementation.createHTMLDocument(); - const node = other.createElement("button"); - other.body.append(node); - keys.observe(other); - disposers.push(() => keys.observe(other)); - - press(node, "Escape"); - - expect(run).not.toHaveBeenCalled(); - }); -}); - -describe("an unscoped binding", () => { - it("fires normally outside a popover", () => { - const run = vi.fn(); - bind({ keys: "escape", label: "Deselect", run }); - - press(plain(), "Escape"); - - expect(run).toHaveBeenCalledTimes(1); - }); - - it("is suppressed while the keystroke comes from inside a popover", () => { - // The guard's whole purpose: the canvas nudge must not run under an open - // colour picker, whose sliders are focusable divs and so read as "not typing". - const { item } = popover(); - const run = vi.fn(); - bind({ keys: "arrowright", label: "Nudge", run }); - - press(item, "ArrowRight"); - - expect(run).not.toHaveBeenCalled(); - }); - - it("yields to the popover's own binding on the same chord", () => { - const { item, shell } = popover(); - const global = vi.fn(); - const scoped = vi.fn(); - bind({ keys: "escape", label: "Deselect", run: global }); - bind({ keys: "escape", label: "Close", run: scoped, within: shell }); - - press(item, "Escape"); - - expect(scoped).toHaveBeenCalledTimes(1); - expect(global).not.toHaveBeenCalled(); - }); -}); - -describe("typing still outranks scope", () => { - it("withholds a scoped binding from a field inside the popover", () => { - // A field owns its own Escape — the token picker clears its query on the - // first press and closes on the second, and `bindField` reverts a value. - // The registry matching first would `preventDefault` both out of existence. - const { shell } = popover(); - const field = document.createElement("input"); - shell.append(field); - const run = vi.fn(); - bind({ keys: "escape", label: "Close", run, within: shell }); - - press(field, "Escape"); - - expect(run).not.toHaveBeenCalled(); - }); - - it("still lets an `allowWhileTyping` binding through in a field", () => { - const field = document.createElement("input"); - document.body.append(field); - const run = vi.fn(); - bind({ - allowWhileTyping: true, - keys: "escape", - label: "Send", - run, - }); - - press(field, "Escape"); - - expect(run).toHaveBeenCalledTimes(1); - }); -}); - -describe("a binding that matches", () => { - it("consumes the event so nothing underneath sees it", () => { - const { item, shell } = popover(); - bind({ - keys: "escape", - label: "Close", - run: () => undefined, - within: shell, - }); - - const e = press(item, "Escape"); - - expect(e.defaultPrevented).toBe(true); - }); - - it("leaves the event alone when scope declines it", () => { - const { shell } = popover(); - bind({ - keys: "escape", - label: "Close", - run: () => undefined, - within: shell, - }); - - const e = press(plain(), "Escape"); - - expect(e.defaultPrevented).toBe(false); - }); -}); diff --git a/packages/overlay/src/keys.ts b/packages/overlay/src/keys.ts deleted file mode 100644 index d033f0c..0000000 --- a/packages/overlay/src/keys.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** - * One keyboard-shortcut registry for the whole overlay. - * - * Bindings used to live in three files (`picker.ts` owned Escape, - * `canvas/viewport.ts` owned the zoom set, `app.ts` owned ⌘Enter) with no shared - * notion of precedence or of "is the user typing right now". Every feature that - * came after — nudge, undo, delete, tool switching, tooltips that show their own - * shortcut — needs to answer those questions the same way, so they answer them - * here. - * - * Field-local Enter/Escape handlers are deliberately *not* migrated: they belong - * to the input they commit, and routing them through a global registry would - * mean every text field had to re-declare itself. - */ -import { PREFIX } from "./dom"; - -/** True on Apple platforms, where `mod` means ⌘ rather than Ctrl. */ -const IS_MAC = - typeof navigator !== "undefined" && - /Mac|iPhone|iPad/.test(navigator.platform); - -/** `KeyboardEvent.code` for the layout-independent digit and letter rows. */ -const DIGIT_CODE = /^Digit(\d)$/; -const LETTER_CODE = /^Key([A-Z])$/; - -export interface Binding { - /** Fire even while a text field has focus. Rare; ⌘Enter is the real case. */ - allowWhileTyping?: boolean; - /** - * A normalised chord, or several separated by commas: `"mod+z"`, - * `"shift+arrowleft"`, `"v"`, `"mod+=, mod+plus"`. Modifiers are `mod` - * (⌘ on macOS, Ctrl elsewhere), `alt` and `shift`, always in that order. - */ - keys: string; - /** Human-readable action name. Doubles as the tooltip lookup key. */ - label: string; - run: (e: KeyboardEvent) => void; - /** Skip this binding when the guard returns false — e.g. nothing selected. */ - when?: () => boolean; - /** - * Only fire for keystrokes originating inside this element. - * - * What lets a popover own its keys. An open popover suppresses every binding - * that is not scoped — a shortcut firing under it acts on something the user - * cannot see — while the bindings scoped to it still run. Scoping to the - * element rather than flagging "I am a popover" is what keeps nested - * popovers honest: children are siblings in the host, not descendants, so an - * outer menu's `within` cannot contain an inner one's rows and its roving - * keys stay out of the way. - */ - within?: HTMLElement; -} - -/** - * Is the target inside a popover the editor has open? - * - * The popover owns its own keys, and a global shortcut firing underneath it acts - * on something the user cannot see. So this suppresses every binding that has not - * scoped itself with `within` — deliberately *not* the same thing as typing, - * which suppresses scoped and unscoped alike so a field keeps its own Escape. - * - * Exported for `canvas/viewport.ts`, whose space-to-pan is a raw listener rather - * than a binding and so has to ask the same question for itself. - */ -export function isInsidePopover(target: EventTarget | null): boolean { - const node = target as Element | null; - return Boolean(node?.closest?.(`.${PREFIX}-pop`)); -} - -/** Is the user typing? Shortcuts must not fire inside the composer. */ -export function isTypingTarget(target: EventTarget | null): boolean { - const node = target as HTMLElement | null; - if (!node?.tagName) { - return false; - } - const tag = node.tagName.toLowerCase(); - return tag === "input" || tag === "textarea" || node.isContentEditable; -} - -/** - * The physical key, independent of layout and of what shift did to it. - * - * `e.key` alone is not enough: on a US layout ⇧1 arrives as `"!"`, so a binding - * written as `shift+1` would never match. Reading the digit and letter rows off - * `e.code` keeps chords stable across layouts and shift states, which is exactly - * what the old hand-rolled `e.key === "!" || e.code === "Digit1"` pairs in - * viewport.ts were working around. - */ -function physicalKey(e: KeyboardEvent): string { - const digit = DIGIT_CODE.exec(e.code); - if (digit) { - return digit[1]; - } - const letter = LETTER_CODE.exec(e.code); - if (letter) { - return letter[1].toLowerCase(); - } - switch (e.code) { - case "Equal": - return "="; - case "Minus": - return "-"; - case "Space": - return "space"; - default: - return e.key.toLowerCase(); - } -} - -/** `"mod+shift+z"` — the canonical form both sides of the match agree on. */ -function chordOf(e: KeyboardEvent): string { - const parts: string[] = []; - if (IS_MAC ? e.metaKey : e.ctrlKey) { - parts.push("mod"); - } - if (e.altKey) { - parts.push("alt"); - } - if (e.shiftKey) { - parts.push("shift"); - } - parts.push(physicalKey(e)); - return parts.join("+"); -} - -/** Split a binding's `keys` into the individual chords it answers to. */ -function chordsOf(spec: string): string[] { - return spec - .split(",") - .map((k) => k.trim().toLowerCase()) - .filter(Boolean); -} - -const DISPLAY: Record = { - alt: IS_MAC ? "⌥" : "Alt", - arrowdown: "↓", - arrowleft: "←", - arrowright: "→", - arrowup: "↑", - backspace: IS_MAC ? "⌫" : "Backspace", - delete: "Del", - enter: IS_MAC ? "↩" : "Enter", - escape: "Esc", - mod: IS_MAC ? "⌘" : "Ctrl", - shift: IS_MAC ? "⇧" : "Shift", - space: "Space", -}; - -/** `"mod+shift+z"` → `"⌘⇧Z"` (mac) or `"Ctrl+Shift+Z"`. */ -function displayChord(chord: string): string { - const parts = chord.split("+").map((p) => DISPLAY[p] ?? p.toUpperCase()); - return IS_MAC ? parts.join("") : parts.join("+"); -} - -export class Keys { - /** Newest first, so a later binding shadows an earlier one on the same chord. */ - private readonly bindings: Binding[] = []; - private listening = false; - /** Frame documents this registry also listens in. See `observe`. */ - private readonly observed = new Set(); - - /** Register a binding. Returns a disposer. */ - bind(binding: Binding): () => void { - this.bindings.unshift(binding); - this.listen(); - return () => { - const i = this.bindings.indexOf(binding); - if (i !== -1) { - this.bindings.splice(i, 1); - } - }; - } - - /** Register several at once; the disposer removes all of them. */ - bindAll(bindings: Binding[]): () => void { - const offs = bindings.map((b) => this.bind(b)); - return () => { - for (const off of offs) { - off(); - } - }; - } - - /** The display string for an action, for tooltips. `null` if unbound. */ - hintFor(label: string): string | null { - const found = this.bindings.find((b) => b.label === label); - if (!found) { - return null; - } - const [first] = chordsOf(found.keys); - return first ? displayChord(first) : null; - } - - private listen(): void { - if (this.listening) { - return; - } - this.listening = true; - // Capture phase, like the picker's own handler was: the host app may stop - // propagation on its own listeners, and the editor's shortcuts have to win - // over the page it is editing. - document.addEventListener("keydown", this.onKeyDown, true); - } - - /** - * Also listen in a frame's own document. - * - * A keydown inside a same-origin iframe does not reach the shell's `document`, so - * every shortcut was dead while focus was in the app — including Escape, which is - * what closes an open popover. `text-edit.ts` already worked around this by binding - * to `ownerWindow(node)`; this makes it the registry's job instead, so one binding - * table serves both realms. - * - * Idempotent per document, and the returned disposer is what a frame calls when it - * unloads. - */ - observe(doc: Document): () => void { - if (doc === document || this.observed.has(doc)) { - return () => undefined; - } - this.observed.add(doc); - doc.addEventListener("keydown", this.onKeyDown, true); - return () => { - this.observed.delete(doc); - doc.removeEventListener("keydown", this.onKeyDown, true); - }; - } - - /** - * Release every listener. - * - * There was no teardown at all and `listening` was never reset, so an overlay torn - * down and rebuilt in the same page — which `?__airship=inline` does on every HMR - * cycle — left the old registry's capture-phase handler on `document`, still matching - * chords and still calling `preventDefault` for bindings whose `when()` closed over a - * dead panel. - */ - destroy(): void { - if (this.listening) { - document.removeEventListener("keydown", this.onKeyDown, true); - this.listening = false; - } - for (const doc of this.observed) { - doc.removeEventListener("keydown", this.onKeyDown, true); - } - this.observed.clear(); - } - - private readonly onKeyDown = (e: KeyboardEvent): void => { - const chord = chordOf(e); - const target = e.target as Node | null; - const typing = isTypingTarget(e.target); - /* - * A keystroke inside an open popover belongs to the popover. - * - * `isTypingTarget` only recognises inputs, so focus resting on a colour picker's - * swatch trigger or one of its sliders read as "not typing" and the canvas nudge - * bindings fired: open the picker, press → four times, and the element slid 4px - * across the canvas while nothing in the picker changed. The popover's own handlers - * `stopPropagation`, but they run in the bubble phase and this listener is capture. - * - * Kept apart from `typing` rather than folded into it, which is how this - * started: one flag skipped every binding without `allowWhileTyping`, and - * the popover's *own* Escape and roving keys are registered here too and - * carry no such flag — so they suppressed themselves and every menu in the - * overlay lost its keyboard. `within` is what tells the two apart. - */ - const inPopover = isInsidePopover(e.target); - for (const b of this.bindings) { - if (typing && !b.allowWhileTyping) { - continue; - } - // A scoped binding never fires outside the subtree it belongs to... - if (b.within && !b.within.contains(target)) { - continue; - } - // ...and inside a popover, only scoped bindings fire at all: an unscoped - // one would act on something standing behind what the user is looking at. - if (inPopover && !b.within) { - continue; - } - if (!chordsOf(b.keys).includes(chord)) { - continue; - } - if (b.when && !b.when()) { - continue; - } - // A binding that matched owns the event. Anything that wants to fall - // through to a lower binding should say so with `when()`, not by - // declining inside `run` — otherwise precedence stops being inspectable. - e.preventDefault(); - e.stopPropagation(); - b.run(e); - return; - } - }; -} - -/** - * The overlay's registry. A singleton because the document has exactly one - * keyboard and both stages (inline and canvas) share it. - */ -export const keys = new Keys(); diff --git a/packages/overlay/src/keys/catalog.test.ts b/packages/overlay/src/keys/catalog.test.ts new file mode 100644 index 0000000..254b8c0 --- /dev/null +++ b/packages/overlay/src/keys/catalog.test.ts @@ -0,0 +1,252 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ALL_COMMANDS, + COMMAND_GROUPS, + type Command, + displayChord, +} from "./catalog"; + +/* + * The contract the catalog has to keep. + * + * Four properties, and the reason each is a test rather than a convention: + * + * - **Unique ids and titles.** An id is what code refers to and a duplicate + * would silently shadow; a duplicate title makes the palette unreadable, and + * the palette is a list of titles. + * - **No ambiguous chord.** Several pairs share one on purpose and are told + * apart by mutually exclusive guards — ⌫ is Delete element in edit mode and + * Delete frame in view mode. `canvas/frame-chrome.ts` argues that pairing in + * thirty lines of prose and says outright that registration order "is *not* + * the guarantee and must not become one". This is where that stops being + * prose. + * - **Every declared command is bound.** The compiler catches an id that does + * not exist; nothing catches a command that exists and is bound nowhere, + * which is a row in the palette that cannot be run and a line in CONTROLS.md + * that is a lie. + * - **No value imports.** `scripts/gen-controls.mjs` loads `catalog.ts` under + * plain Node, where an extensionless value import does not resolve. Type + * imports are erased before resolution and are free. + */ + +const SKIP = /\.(test|stories)\.ts$/; + +/** A chord typed into a menu row by hand, which `command:` should replace. */ +const CHORD_HINT = /hint:\s*["'][^"']*[⌘⇧⌥⌃↩⌫]|hint:\s*["']Ctrl\+/; + +/** A chord that is not in the registry's normal form. */ +const NOT_NORMALISED = /\s/; + +/** A value import — the one thing `catalog.ts` may not contain. */ +const VALUE_IMPORT = /^import\s+(?!type\b)/; + +function srcRoot(): string { + for (const base of ["src", join("packages", "overlay", "src")]) { + if (existsSync(base)) { + return base; + } + } + throw new Error("Cannot find the overlay source tree from this cwd."); +} + +const SRC = srcRoot(); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...sourceFiles(path)); + } else if (entry.name.endsWith(".ts") && !SKIP.test(entry.name)) { + out.push(path); + } + } + return out; +} + +/** Modes overlap unless they name two different ones. `any` overlaps all. */ +function modesOverlap(a: Command, b: Command): boolean { + return a.mode === "any" || b.mode === "any" || a.mode === b.mode; +} + +/** Same for surfaces, where `both` is the wildcard. */ +function surfacesOverlap(a: Command, b: Command): boolean { + return ( + a.surface === "both" || b.surface === "both" || a.surface === b.surface + ); +} + +/** + * Where a command sits when two answer to one chord. + * + * Mirrors `rankOf` in `registry.ts`. A scoped command only fires inside its own + * element, and a modal one is declared to be in front — so neither can be + * ambiguous against an ordinary binding, and only same-rank pairs are checked. + */ +function rank(spec: Command): string { + if (spec.scoped) { + return "scoped"; + } + return spec.priority ?? "normal"; +} + +describe("the command table", () => { + it("has a unique id for every command", () => { + const seen = new Set(); + const dupes = ALL_COMMANDS.filter((c) => { + if (seen.has(c.id)) { + return true; + } + seen.add(c.id); + return false; + }).map((c) => c.id); + expect(dupes).toEqual([]); + }); + + it("has a unique title for every command", () => { + const seen = new Set(); + const dupes = ALL_COMMANDS.filter((c) => { + if (seen.has(c.title)) { + return true; + } + seen.add(c.title); + return false; + }).map((c) => `${c.id} (${c.title})`); + expect(dupes).toEqual([]); + }); + + it("declares at least one chord for every command", () => { + const silent = ALL_COMMANDS.filter((c) => c.keys.length === 0).map( + (c) => c.id + ); + expect(silent).toEqual([]); + }); + + it("puts every command in a group the panel renders", () => { + const orphans = ALL_COMMANDS.filter( + (c) => !COMMAND_GROUPS.includes(c.group) + ).map((c) => `${c.id} → ${c.group}`); + expect(orphans).toEqual([]); + }); + + it("keeps direction-dependent commands out of the palette", () => { + // The palette invokes `run` with a synthetic event that carries no key, so + // a command that reads its argument off the keystroke has nothing to act + // on. Nudge is the case: one command, four arrows, and `run` derives the + // axis from `e.key` — listed in the palette it rendered a row that did + // nothing at all, which is the exact failure "only list what is runnable" + // exists to prevent. `hidden` keeps it out of the palette and in the sheet. + const directional = ALL_COMMANDS.filter((c) => c.display?.includes("←")); + expect(directional.length).toBeGreaterThan(0); + expect(directional.filter((c) => !c.hidden).map((c) => c.id)).toEqual([]); + }); + + it("gives no two commands an ambiguous chord", () => { + const clashes: string[] = []; + for (let i = 0; i < ALL_COMMANDS.length; i += 1) { + for (let j = i + 1; j < ALL_COMMANDS.length; j += 1) { + const a = ALL_COMMANDS[i]; + const b = ALL_COMMANDS[j]; + if (rank(a) !== rank(b)) { + continue; + } + if (rank(a) === "scoped") { + continue; + } + if (!(modesOverlap(a, b) && surfacesOverlap(a, b))) { + continue; + } + const shared = a.keys.filter((k) => b.keys.includes(k)); + if (shared.length) { + clashes.push( + `${a.id} and ${b.id} both answer to ${shared.join(", ")}` + ); + } + } + } + // Two commands may share a chord only if their modes or surfaces are + // disjoint, one of them is `scoped`, or one is `modal`. Anything else is a + // coin flip decided by whichever constructor ran last. + expect(clashes).toEqual([]); + }); + + it("writes every chord in the registry's own normal form", () => { + // `mod` then `alt` then `shift` then the key, lowercase — the order + // `chordOf` emits. A chord spelled any other way silently never matches. + const malformed = ALL_COMMANDS.flatMap((c) => + c.keys + .filter((k) => k !== k.toLowerCase() || NOT_NORMALISED.test(k)) + .map((k) => `${c.id}: ${k}`) + ); + expect(malformed).toEqual([]); + }); +}); + +describe("the table and the code agree", () => { + const sources = sourceFiles(SRC).filter( + (p) => !p.endsWith(join("keys", "catalog.ts")) + ); + const corpus = sources.map((p) => readFileSync(p, "utf8")).join("\n"); + + it("binds every command it declares", () => { + // Ids are dotted and quoted at the binding site, so a plain substring scan + // has no false positives. The reverse direction is free: `Binding.id` is + // `CommandId`, so an id that is not declared will not compile. + const unbound = ALL_COMMANDS.filter( + (c) => !corpus.includes(`"${c.id}"`) + ).map((c) => c.id); + expect(unbound).toEqual([]); + }); + + it("leaves no hand-written chord in a menu row", () => { + // `MenuItem.hint` is for a size or a unit. A chord typed in there is a copy + // of the registry that nothing keeps honest — five rows in + // `canvas/frame-chrome.ts` carried Mac glyphs that Windows users saw + // unchanged, and one in `chat/transcript.ts` advertised a key that ran a + // different feature entirely. Use `command:` instead. + const offenders = sources + .filter((p) => CHORD_HINT.test(readFileSync(p, "utf8"))) + .map((p) => p.slice(SRC.length + 1)); + expect(offenders).toEqual([]); + }); +}); + +describe("the catalog stays loadable by plain Node", () => { + const source = readFileSync(join(SRC, "keys", "catalog.ts"), "utf8"); + + it("has no value imports", () => { + // `scripts/gen-controls.mjs` imports this module directly. Node's type + // stripper erases `import type` before resolution, so those are free, but a + // real import would need a file extension this repo does not write. + const values = source.split("\n").filter((line) => VALUE_IMPORT.test(line)); + expect(values).toEqual([]); + }); + + it("still declares the table it is supposed to", () => { + // A guard on the guard: a scan of an empty file passes everything. + expect(ALL_COMMANDS.length).toBeGreaterThan(20); + }); +}); + +describe("rendering a chord", () => { + it("spells the same chord differently per platform", () => { + expect(displayChord("mod+shift+z", "mac")).toBe("⌘⇧Z"); + expect(displayChord("mod+shift+z", "pc")).toBe("Ctrl+Shift+Z"); + }); + + it("takes the platform as an argument rather than probing", () => { + // Node ≥21 defines `globalThis.navigator` with an undefined `platform`, so + // a probe inside this function would have the docs generator emit the + // Windows spelling for every reader of both columns. + expect(displayChord("mod+k", "mac")).not.toBe(displayChord("mod+k", "pc")); + }); + + it("renders the keys people actually reach for", () => { + expect(displayChord("shift+1", "mac")).toBe("⇧1"); + expect(displayChord("numpadadd", "mac")).toBe("+"); + expect(displayChord("arrowleft", "pc")).toBe("←"); + expect(displayChord("escape", "pc")).toBe("Esc"); + }); +}); diff --git a/packages/overlay/src/keys/catalog.ts b/packages/overlay/src/keys/catalog.ts new file mode 100644 index 0000000..b871088 --- /dev/null +++ b/packages/overlay/src/keys/catalog.ts @@ -0,0 +1,1025 @@ +/** + * Every command the editor answers to, declared once. + * + * Chords used to be string literals at the `keys.bind` call site, and the only + * thing that knew a command existed was the line that bound it. That made three + * things impossible and one thing silently wrong: + * + * - **Nothing could enumerate the bindings**, so there was no shortcuts panel + * and no command palette, and twenty-seven of the thirty-three shortcuts were + * undiscoverable unless you already knew them. + * - **Nothing could document them.** The only shortcut reference in the repo + * was a hand-written six-row table in `README.md`, and its copy in + * `apps/cli/README.md` had already drifted two rows out of date. + * - **A tooltip found its chord by matching its own text against a binding's + * `label`.** Reword the tooltip and the chip silently disappears; name two + * commands "Delete" and one of them shadows the other's chip. Both happened. + * `tooltip.copy.test.ts` existed to freeze thirteen spellings by hand, and it + * was missing four of them. + * - **Five menu rows spelled their own chords** — `"⌘+"` where the binding was + * `mod+=`, Mac glyphs rendered on Windows, and a `⌘Z` on a row wired to the + * server-side revert, a feature `app.ts` warns must never be reached by ⌘Z. + * + * So the declaration lives here and the implementation stays where it belongs. + * A binding supplies `run`, `when` and `within`; everything a *reader* needs — + * the chord, the name, the sentence, which mode it works in — comes from this + * table. `CommandId` is a union, so a mistyped id is a compile error rather + * than a shortcut that quietly never fires. + * + * ## This module must stay loadable by plain Node + * + * `scripts/gen-controls.mjs` imports it directly to generate `CONTROLS.md`, so + * it may contain **no value imports** — Node's type stripper erases `import + * type` before module resolution, but a real import would need an extension it + * does not have. `catalog.test.ts` enforces this. Keep this file data and pure + * functions; anything that needs the DOM belongs in `registry.ts`. + */ +import type { IconName } from "../icons"; + +/** Which editor mode a command belongs to. */ +export type CommandMode = "any" | "edit" | "view"; + +/** Which of the two surfaces a command exists on. */ +export type CommandSurface = "both" | "canvas" | "inline"; + +/** The sections of the shortcuts panel, in the order they are shown. */ +export type CommandGroup = + | "Agent" + | "Edit" + | "Frames" + | "Help" + | "Menus" + | "Selection" + | "View"; + +/** + * How a binding is ordered against another that answers the same chord. + * + * `modal` is for a surface that is standing in front of everything else — an + * open device menu owns Escape while it is up. It exists because the honest + * alternative, scoping that Escape to the menu, does not work: focus is on + * `document.body` after the click that opened it, so `within` would never + * match. Registration order used to decide this, and `canvas/frame-chrome.ts` + * says in as many words that it must not. + */ +export type CommandPriority = "modal" | "normal"; + +export interface CommandSpec { + /** + * Fire even while a text field has focus. + * + * Rare, and every use is a field's own submit: ⌘↵ in the composer, ⌘↵ in a + * comment, the palette's own navigation. Anything else stealing a keystroke + * from someone who is typing is a bug. + */ + readonly allowWhileTyping?: boolean; + /** + * Overrides the rendered chord where the literal spelling reads badly. + * + * The nudge commands are the case: one command answers to four arrows, and + * "← → ↑ ↓" is what a reader wants to see rather than four separate rows. + */ + readonly display?: string; + /** One sentence, for the palette's second line and the generated reference. */ + readonly doc: string; + /** Include in the short table in `README.md`. */ + readonly essential?: boolean; + readonly group: CommandGroup; + /** Kept out of the palette — popover-local keys and modal navigation. */ + readonly hidden?: boolean; + readonly icon?: IconName; + /** Stable, dotted, never shown to a user. The only thing code refers to. */ + readonly id: string; + /** + * Reaches the registry from inside a live frame's document. + * + * In view mode the page in a frame belongs to the user and takes focus, and + * a keydown there never reaches the shell — which is why Escape, the zoom + * keys and the two help surfaces were dead the moment you clicked into your + * own app. `Keys.observe` fixes that, but it must not hand the app's own + * typing to a single-letter command: `f` would add a frame while someone + * filled in a form. So only the commands marked here are routed. + */ + readonly inFrame?: boolean; + /** Every chord it answers to. The first is the one a tooltip shows. */ + readonly keys: readonly string[]; + readonly mode: CommandMode; + /** + * The subset of `keys` worth showing a reader. Defaults to all of them. + * + * Zoom is why this exists. `⌘=`, `⌘⇧=`, `⇧=`, `=` and the two numpad keys are + * six genuinely distinct keystrokes and all six should work — "⌘+" on a US + * layout is `mod+shift+=`, and a lot of people reach for the keypad. Printing + * all six in a table nobody can scan is not documentation, it is a shrug. The + * aliases stay bound; the reference names the two you would teach someone. + */ + readonly primary?: readonly string[]; + readonly priority?: CommandPriority; + /** + * Always bound with `within`, and so exempt from the duplicate-chord rule. + * + * A scoped binding cannot collide with a global one: it only fires for a + * keystroke that originated inside the element it belongs to. + */ + readonly scoped?: boolean; + readonly surface: CommandSurface; + /** Sentence case. The palette, the panel and the docs all show this. */ + readonly title: string; + /** + * Where a scoped or modal command applies, as a phrase. + * + * The reference shows every command including the ones that are not live, and + * says why — which is the whole argument for showing them. `mode` and + * `surface` answer that for a global command ("edit mode", "canvas only") and + * cannot answer it for a scoped one, which is live exactly while some + * particular thing is on screen. Nineteen rows read "not available here" + * before this existed, which tells a reader nothing they can act on. + */ + readonly where?: string; +} + +/** + * The table. + * + * `as const satisfies` rather than a plain annotation: the annotation would + * widen every `id` to `string` and there would be no union to key on, while + * `as const` alone would not type-check the fields. Same pattern as + * `stories/foundations.stories.ts`. + */ +export const COMMANDS = [ + // -- Edit ----------------------------------------------------------------- + { + doc: "Step back through your pending direct-manipulation edits.", + essential: true, + group: "Edit", + icon: "rotate-ccw", + id: "history.undo", + keys: ["mod+z"], + mode: "edit", + surface: "both", + title: "Undo", + }, + { + doc: "Step forward again through edits you have undone.", + group: "Edit", + // No glyph: the set has `rotate-ccw` and no mirrored twin, and Redo wearing + // Undo's mark is worse than Redo wearing none. + id: "history.redo", + keys: ["mod+shift+z", "mod+y"], + mode: "edit", + surface: "both", + title: "Redo", + }, + { + doc: "Remove the selected element.", + essential: true, + group: "Edit", + icon: "minus", + id: "element.delete", + keys: ["backspace", "delete"], + mode: "edit", + surface: "both", + title: "Delete element", + }, + { + doc: "Copy the selected element in place.", + essential: true, + group: "Edit", + icon: "plus", + id: "element.duplicate", + keys: ["mod+d"], + mode: "edit", + surface: "both", + title: "Duplicate", + }, + { + doc: "Edit the selected element's text in place.", + essential: true, + group: "Edit", + icon: "layer-text", + id: "element.editText", + keys: ["enter", "t"], + mode: "edit", + surface: "both", + title: "Edit text", + }, + { + display: "← → ↑ ↓", + doc: "Move the selected element one pixel.", + group: "Edit", + // Out of the palette, in the sheet. `run` reads its direction off the + // keystroke, so a palette row saying "Nudge" has no direction to nudge in + // and would do nothing at all — which is the exact failure the palette's + // "only list what is runnable" rule exists to prevent. The sheet renders + // the whole catalog, `hidden` included, so it is still documented. + hidden: true, + id: "element.nudge", + keys: ["arrowleft", "arrowright", "arrowup", "arrowdown"], + mode: "edit", + surface: "both", + title: "Nudge", + }, + { + display: "⇧ ← → ↑ ↓", + doc: "Move the selected element ten pixels.", + group: "Edit", + hidden: true, + id: "element.nudgeBig", + keys: [ + "shift+arrowleft", + "shift+arrowright", + "shift+arrowup", + "shift+arrowdown", + ], + mode: "edit", + surface: "both", + title: "Nudge by ten", + }, + + // -- Selection ------------------------------------------------------------ + { + doc: "Clear the selection.", + group: "Selection", + id: "selection.deselect", + inFrame: true, + keys: ["escape"], + mode: "edit", + surface: "both", + title: "Deselect", + }, + { + doc: "Hover highlights and clicks select. The default.", + essential: true, + group: "Selection", + icon: "tool-move", + id: "tool.move", + keys: ["v"], + mode: "edit", + surface: "both", + title: "Move", + }, + { + doc: "Hover reads out an element's specs instead of selecting it.", + essential: true, + group: "Selection", + icon: "tool-inspect", + id: "tool.inspect", + keys: ["i"], + mode: "edit", + surface: "both", + title: "Inspect", + }, + + // -- View ----------------------------------------------------------------- + { + doc: "Zoom in a step, centred on the canvas. On Safari, use + rather than ⌘+.", + essential: true, + group: "View", + id: "view.zoomIn", + inFrame: true, + keys: [ + "mod+=", + "mod+shift+=", + "shift+=", + "=", + "numpadadd", + "mod+numpadadd", + ], + mode: "any", + primary: ["mod+=", "="], + surface: "canvas", + title: "Zoom in", + }, + { + doc: "Zoom out a step. On Safari, use − rather than ⌘−.", + essential: true, + group: "View", + id: "view.zoomOut", + inFrame: true, + keys: [ + "mod+-", + "mod+shift+-", + "shift+-", + "-", + "numpadsubtract", + "mod+numpadsubtract", + ], + mode: "any", + primary: ["mod+-", "-"], + surface: "canvas", + title: "Zoom out", + }, + { + doc: "Return the canvas to actual size.", + essential: true, + group: "View", + id: "view.zoom100", + inFrame: true, + keys: ["mod+0", "shift+0"], + mode: "any", + surface: "canvas", + title: "Zoom to 100%", + }, + { + doc: "Fit every frame on screen.", + essential: true, + group: "View", + id: "view.zoomToFit", + inFrame: true, + keys: ["shift+1"], + mode: "any", + surface: "canvas", + title: "Zoom to fit", + }, + { + doc: "Fill the canvas with the current selection.", + group: "View", + id: "view.zoomToSelection", + inFrame: true, + keys: ["shift+2"], + mode: "any", + surface: "canvas", + title: "Zoom to selection", + }, + { + doc: "Drag anywhere to move the canvas, leaving the page beneath untouched.", + essential: true, + group: "View", + icon: "tool-hand", + id: "tool.hand", + keys: ["h"], + mode: "view", + surface: "canvas", + title: "Hand tool", + }, + { + doc: "Put the Hand down and go back to pointing.", + group: "View", + id: "tool.handDrop", + keys: ["escape"], + mode: "view", + surface: "canvas", + title: "Put the Hand down", + }, + + // -- Frames --------------------------------------------------------------- + { + doc: "Open the device picker and place a new frame on the canvas.", + essential: true, + group: "Frames", + icon: "plus", + id: "frame.add", + keys: ["f"], + mode: "any", + surface: "canvas", + title: "Add a frame", + }, + { + doc: "Remove the active frame from the canvas.", + group: "Frames", + icon: "minus", + id: "frame.delete", + keys: ["backspace", "delete"], + mode: "view", + surface: "canvas", + title: "Delete frame", + }, + { + doc: "Move the frame up the stack, so it covers the ones it overlaps.", + group: "Frames", + id: "frame.bringForward", + keys: ["arrowup"], + mode: "view", + scoped: true, + surface: "canvas", + title: "Bring frame forward", + where: "on a frame's handle", + }, + { + doc: "Move the frame down the stack, behind the ones it overlaps.", + group: "Frames", + id: "frame.sendBackward", + keys: ["arrowdown"], + mode: "view", + scoped: true, + surface: "canvas", + title: "Send frame backward", + where: "on a frame's handle", + }, + + // -- Agent ---------------------------------------------------------------- + { + allowWhileTyping: true, + doc: "Send the description and the pending edits to the agent.", + essential: true, + group: "Agent", + icon: "chev-up", + id: "chat.send", + keys: ["mod+enter"], + mode: "any", + surface: "both", + title: "Send", + }, + { + allowWhileTyping: true, + doc: "Attach the comment to the diff line it is anchored on.", + group: "Agent", + icon: "tool-comment", + id: "comment.add", + keys: ["mod+enter"], + mode: "any", + scoped: true, + surface: "both", + title: "Add comment", + where: "in a comment", + }, + { + doc: "Move to the next pending change on the composer's strip.", + group: "Agent", + hidden: true, + id: "chips.next", + keys: ["arrowright"], + mode: "any", + scoped: true, + surface: "both", + title: "Next change", + where: "on the change strip", + }, + { + doc: "Move to the previous pending change.", + group: "Agent", + hidden: true, + id: "chips.prev", + keys: ["arrowleft"], + mode: "any", + scoped: true, + surface: "both", + title: "Previous change", + where: "on the change strip", + }, + { + doc: "Jump to the first pending change.", + group: "Agent", + hidden: true, + id: "chips.first", + keys: ["home"], + mode: "any", + scoped: true, + surface: "both", + title: "First change", + where: "on the change strip", + }, + { + doc: "Jump to the last pending change.", + group: "Agent", + hidden: true, + id: "chips.last", + keys: ["end"], + mode: "any", + scoped: true, + surface: "both", + title: "Last change", + where: "on the change strip", + }, + { + doc: "Discard the pending change you are on.", + group: "Agent", + id: "chips.drop", + keys: ["backspace", "delete"], + mode: "any", + scoped: true, + surface: "both", + title: "Drop the change you are on", + where: "on the change strip", + }, + + // -- Help ----------------------------------------------------------------- + { + // `shift+/` renders as "⇧/", which is the chord and not what anyone calls + // this key. Four places already told the user to press `?` — the panel's own + // header, the generated reference, `README.md` and the doc below — while + // every chip, row and table spelled the other thing. `?` is the same + // keystroke on both platforms, so one override settles all of them. + display: "?", + doc: "Every shortcut and gesture, grouped, with what is live right now.", + essential: true, + group: "Help", + icon: "keyboard", + id: "help.shortcuts", + inFrame: true, + keys: ["shift+/", "mod+/"], + mode: "any", + surface: "both", + title: "Keyboard shortcuts", + }, + { + doc: "Search everything the editor can do right now, and run it.", + essential: true, + group: "Help", + icon: "command", + id: "help.palette", + inFrame: true, + keys: ["mod+k"], + mode: "any", + surface: "both", + title: "Command palette", + }, + + // -- Menus ---------------------------------------------------------------- + // Scoped to whichever popover is open, and hidden: a palette row reading + // "Next option" with no menu on screen is an action that cannot be taken. + { + doc: "Close the open menu.", + group: "Menus", + hidden: true, + id: "popover.close", + keys: ["escape"], + mode: "any", + scoped: true, + surface: "both", + title: "Close the menu", + where: "in an open menu", + }, + { + doc: "Move down the open menu.", + group: "Menus", + hidden: true, + id: "popover.next", + keys: ["arrowdown"], + mode: "any", + scoped: true, + surface: "both", + title: "Next option", + where: "in an open menu", + }, + { + doc: "Move up the open menu.", + group: "Menus", + hidden: true, + id: "popover.prev", + keys: ["arrowup"], + mode: "any", + scoped: true, + surface: "both", + title: "Previous option", + where: "in an open menu", + }, + { + doc: "Jump to the first option.", + group: "Menus", + hidden: true, + id: "popover.first", + keys: ["home"], + mode: "any", + scoped: true, + surface: "both", + title: "First option", + where: "in an open menu", + }, + { + doc: "Jump to the last option.", + group: "Menus", + hidden: true, + id: "popover.last", + keys: ["end"], + mode: "any", + scoped: true, + surface: "both", + title: "Last option", + where: "in an open menu", + }, + { + doc: "Take the option you are on.", + group: "Menus", + hidden: true, + id: "popover.choose", + keys: ["enter"], + mode: "any", + scoped: true, + surface: "both", + title: "Choose option", + where: "in an open menu", + }, + // The device menu is opened by a click, so focus is still on `document.body` + // and `within` would never match. `modal` is the honest way to say "this is + // in front of everything" — see the note on `CommandPriority`. + { + doc: "Close the frame's device menu.", + group: "Menus", + hidden: true, + id: "frameMenu.close", + keys: ["escape"], + mode: "any", + priority: "modal", + surface: "canvas", + title: "Close the device menu", + where: "in the device menu", + }, + // The palette's own navigation. `allowWhileTyping`, because the search field + // has focus the whole time it is open, and scoped so none of it leaks. + { + allowWhileTyping: true, + doc: "Move down the results.", + group: "Menus", + hidden: true, + id: "palette.next", + keys: ["arrowdown"], + mode: "any", + scoped: true, + surface: "both", + title: "Next result", + where: "in the palette", + }, + { + allowWhileTyping: true, + doc: "Move up the results.", + group: "Menus", + hidden: true, + id: "palette.prev", + keys: ["arrowup"], + mode: "any", + scoped: true, + surface: "both", + title: "Previous result", + where: "in the palette", + }, + { + allowWhileTyping: true, + doc: "Run the result you are on.", + group: "Menus", + hidden: true, + id: "palette.run", + keys: ["enter"], + mode: "any", + scoped: true, + surface: "both", + title: "Run result", + where: "in the palette", + }, + { + allowWhileTyping: true, + doc: "Clear the search, then close.", + group: "Menus", + hidden: true, + id: "palette.close", + keys: ["escape"], + mode: "any", + scoped: true, + surface: "both", + title: "Close the palette", + where: "in the palette", + }, +] as const satisfies readonly CommandSpec[]; + +export type CommandId = (typeof COMMANDS)[number]["id"]; + +/** + * A declaration whose `id` has been narrowed back to the union. + * + * `CommandSpec.id` has to be `string` — it is the interface the literals are + * checked *against*, so it cannot name a type derived from them. Every real + * entry does carry a `CommandId`, and this is where that fact is stated so + * callers iterating the table can pass an id straight to `keys.chords`. + */ +export type Command = CommandSpec & { readonly id: CommandId }; + +/** + * The table, widened to the interface. + * + * Iterate this rather than `COMMANDS`. `as const` gives each entry a literal + * type in which an omitted optional field is *absent* rather than optional, so + * `spec.hidden` on the union does not type-check — which is the price of having + * `CommandId` be a union at all. `COMMANDS` is for the type; this is for the + * loops. + */ +export const ALL_COMMANDS: readonly Command[] = COMMANDS; + +const BY_ID = new Map( + COMMANDS.map((c): [string, Command] => [c.id, c]) +); + +/** + * The declaration for a command. + * + * Total by construction — `CommandId` is derived from `COMMANDS`, so the only + * way to reach the throw is to hand it a string that was cast past the type. + */ +export function commandSpec(id: CommandId): Command { + const found = BY_ID.get(id); + if (!found) { + throw new Error(`No command is declared for "${id}"`); + } + return found; +} + +/** The panel's sections, in order. */ +export const COMMAND_GROUPS: readonly CommandGroup[] = [ + "Edit", + "Selection", + "View", + "Frames", + "Agent", + "Help", + "Menus", +]; + +// --------------------------------------------------------------------------- +// Pointer gestures +// --------------------------------------------------------------------------- + +/** Which device a gesture is really for. */ +export type GestureDevice = "any" | "mouse" | "trackpad"; + +/** + * A pointer gesture — documentation only. + * + * Gestures cannot be commands and should not pretend to be: space-to-pan is a + * state held between a keydown and a keyup, a wheel is a stream, and neither has a + * `run()` a palette could call. What they do have is the same need to be + * discoverable and documented, which is the half of the input surface that was + * missing entirely — the README's six-row table was the only place any of this + * was written down, and it covered the canvas alone. + * + * `impl` is what keeps the table honest, and it works in both directions in + * `gestures.test.ts`: forward, every symbol named here must exist, so a renamed + * handler fails the build rather than leaving a row describing nothing; + * backward, every file that registers a pointer or wheel listener must be named + * by some row, so the right-drag somebody adds next year cannot go undocumented. + */ +export interface GestureSpec { + readonly device: GestureDevice; + readonly doc: string; + readonly essential?: boolean; + readonly id: string; + /** `"canvas/viewport.ts#onSpaceDown"` — the file and the symbol. */ + readonly impl: string; + /** How it is performed, on a Mac: "Space-drag", "⌘-wheel", "Middle-drag". */ + readonly input: string; + /** + * The same, spelled for Windows and Linux. Omit when it is identical. + * + * Only two gestures carry a modifier glyph, and both read as Mac-only to + * everyone else. Commands have had two spellings since the catalog existed; + * this is the same fact for the pointer half, and without it the generated + * tables were quietly Mac-only wherever a gesture used a glyph. + */ + readonly inputPc?: string; + readonly mode: CommandMode; + readonly surface: CommandSurface; + readonly title: string; +} + +export const GESTURES = [ + { + device: "any", + doc: "Two fingers or a wheel move the canvas under you.", + essential: true, + id: "gesture.pan", + impl: "canvas/viewport.ts#applyWheel", + input: "Wheel / two-finger", + mode: "any", + surface: "canvas", + title: "Pan the canvas", + }, + { + device: "any", + doc: "Zooms toward the pointer, not the middle of the screen.", + essential: true, + id: "gesture.zoom", + impl: "canvas/viewport.ts#applyWheel", + input: "⌘-wheel / pinch", + inputPc: "Ctrl-wheel / pinch", + mode: "any", + surface: "canvas", + title: "Zoom at the cursor", + }, + { + device: "any", + doc: "Hold space and drag, from anywhere, without changing tool.", + essential: true, + id: "gesture.spacePan", + impl: "canvas/viewport.ts#onSpaceDown", + input: "Space-drag", + mode: "any", + surface: "canvas", + title: "Pan without the Hand", + }, + { + device: "mouse", + doc: "The middle button pans, whatever tool is armed.", + id: "gesture.middlePan", + impl: "canvas/viewport.ts#onPointerDown", + input: "Middle-drag", + mode: "any", + surface: "canvas", + title: "Pan with the middle button", + }, + { + device: "any", + doc: "A selected frame keeps the wheel to its own ends, so the canvas never lurches sideways.", + id: "gesture.frameScroll", + impl: "canvas/wheel.ts#routeWheel", + input: "Wheel over the selected frame", + mode: "view", + surface: "canvas", + title: "Scroll a frame", + }, + { + device: "any", + doc: "Hover highlights, click selects.", + essential: true, + id: "gesture.select", + impl: "picker.ts#onClick", + input: "Click", + mode: "edit", + surface: "both", + title: "Select an element", + }, + { + device: "any", + doc: "Drag from empty space to band-select several elements.", + id: "gesture.marquee", + impl: "picker.ts#onMarqueeDown", + input: "Drag from empty space", + mode: "edit", + surface: "both", + title: "Marquee-select", + }, + { + device: "any", + doc: "Opens the caret in the element itself, not in a field beside it.", + essential: true, + id: "gesture.editText", + impl: "picker.ts#onDblClick", + input: "Double-click", + mode: "edit", + surface: "both", + title: "Edit text in place", + }, + { + device: "any", + doc: "Verbs for the element you clicked, which it selects first.", + essential: true, + id: "gesture.contextMenu", + impl: "picker.ts#onContextMenu", + input: "Right-click", + mode: "edit", + surface: "both", + title: "Open the element menu", + }, + { + device: "any", + doc: "Hold Alt and hover to read the distance to the element under the pointer.", + id: "gesture.measure", + impl: "picker.ts#onModifier", + input: "⌥-hover", + inputPc: "Alt-hover", + mode: "edit", + surface: "both", + title: "Measure spacing", + }, + { + device: "any", + doc: "Drag a frame by its title; drag a grip to resize it.", + id: "gesture.frameMove", + impl: "canvas/frame-chrome.ts#onDragMove", + input: "Drag the title or a grip", + mode: "view", + surface: "canvas", + title: "Move or resize a frame", + }, + { + device: "any", + doc: "Drag a row in the frame list to change which frame is in front.", + id: "gesture.restack", + impl: "canvas/frames-panel.ts#watchDrag", + input: "Drag a row in the frame list", + mode: "view", + surface: "canvas", + title: "Restack frames", + }, + { + device: "any", + doc: "Press anywhere on the minimap to jump there, and keep dragging to keep moving.", + id: "gesture.minimap", + impl: "canvas/minimap.ts#onPress", + input: "Press or drag the minimap", + mode: "view", + surface: "canvas", + title: "Jump the camera", + }, + { + device: "any", + doc: "Drag a field's glyph sideways. Shift for ten at a time, Alt for a tenth.", + essential: true, + id: "gesture.scrub", + impl: "inspector/controls/num-field.ts#createNumField", + input: "Drag a field's glyph", + mode: "edit", + surface: "both", + title: "Scrub a number", + }, + { + device: "any", + doc: "Double-click a floating panel's header to put it back against the edge.", + id: "gesture.redock", + impl: "app.ts#redock", + input: "Double-click a panel header", + mode: "any", + surface: "both", + title: "Re-dock a panel", + }, + { + device: "mouse", + doc: "A vertical wheel scrolls the strip sideways, because a mouse has no sideways.", + id: "gesture.chipRail", + impl: "chat/change-chips.ts#attachRailWheel", + input: "Wheel over the strip", + mode: "any", + surface: "both", + title: "Scroll the pending changes", + }, +] as const satisfies readonly GestureSpec[]; + +/** The table, widened. See the note on `ALL_COMMANDS`. */ +export const ALL_GESTURES: readonly GestureSpec[] = GESTURES; + +/** + * Conventions that hold everywhere and belong to no command. + * + * Every editor field owns its own Enter and Escape — they commit and revert the + * input they are typed into, and are deliberately *not* registry bindings, + * because routing them through a global table would mean twenty fields each + * re-declaring "…but only while I have focus", which is a thing the DOM already + * does. They are still real, and a reference that omitted them would be lying + * by omission, so they are declared here as prose bound to nothing. + * + * Prose, which means these are the one place in the catalog no `displayChord` + * can reach — so a modifier written as a Mac glyph here is a Mac glyph in + * `CONTROLS.md` and in the shortcuts panel on every platform. They are spelled + * out for that reason, and the one chord that has to be named is given both ways. + */ +export const NOTES: readonly string[] = [ + "Enter commits the field you are in; Esc reverts it.", + "↑ and ↓ step a number field. Shift for ten at a time, Alt for a tenth.", + "A shortcut never fires while you are typing, except a field's own submit — ⌘↵ on a Mac, Ctrl+Enter elsewhere.", +]; + +// --------------------------------------------------------------------------- +// Rendering a chord +// --------------------------------------------------------------------------- + +/** Which glyph set to spell a chord in. */ +export type ChordPlatform = "mac" | "pc"; + +const DISPLAY_MAC: Readonly> = { + alt: "⌥", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + arrowup: "↑", + backspace: "⌫", + delete: "Del", + end: "End", + enter: "↩", + escape: "Esc", + home: "Home", + mod: "⌘", + numpadadd: "+", + numpadsubtract: "−", + shift: "⇧", + space: "Space", +}; + +const DISPLAY_PC: Readonly> = { + alt: "Alt", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + arrowup: "↑", + backspace: "Backspace", + delete: "Del", + end: "End", + enter: "Enter", + escape: "Esc", + home: "Home", + mod: "Ctrl", + numpadadd: "+", + numpadsubtract: "−", + shift: "Shift", + space: "Space", +}; + +/** + * `"mod+shift+z"` → `"⌘⇧Z"` on a Mac, `"Ctrl+Shift+Z"` everywhere else. + * + * The platform is a parameter rather than read from `navigator`, because the + * docs generator renders *both* columns and runs under Node — where + * `globalThis.navigator` exists with an undefined `platform`, so a probe would + * silently emit the Windows spelling for every reader. + */ +export function displayChord(chord: string, platform: ChordPlatform): string { + const map = platform === "mac" ? DISPLAY_MAC : DISPLAY_PC; + const parts = chord.split("+").map((p) => map[p] ?? p.toUpperCase()); + return platform === "mac" ? parts.join("") : parts.join("+"); +} diff --git a/packages/overlay/src/keys/controls-doc.test.ts b/packages/overlay/src/keys/controls-doc.test.ts new file mode 100644 index 0000000..c24287d --- /dev/null +++ b/packages/overlay/src/keys/controls-doc.test.ts @@ -0,0 +1,84 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +// The generator's own renderers, imported rather than reimplemented. One +// renderer means the gate and the writer cannot disagree — and it means this +// test still fails on drift on a Node that cannot strip types to *run* the +// script, because vitest resolves the `.mjs` itself. +import { + renderControls, + renderEssentials, +} from "../../../../scripts/gen-controls.mjs"; +import { + ALL_COMMANDS, + ALL_GESTURES, + COMMAND_GROUPS, + displayChord, + NOTES, +} from "./catalog"; + +/* + * CONTROLS.md and the README block are generated, and committed. + * + * Committed because that is this repo's pattern for generated files — the CLI + * README, the token modules, the route tree — and because a reference nobody + * can read without a build step is not a reference. Which means it can drift, + * which is what this catches. + * + * It is a byte comparison on purpose. A looser check ("does it mention every + * command?") would pass a file whose *chords* were stale, and stale chords are + * the exact failure this whole change exists to end. + */ + +function repoRoot(): string { + for (const base of [join("..", ".."), "."]) { + if (existsSync(join(base, "CONTROLS.md"))) { + return base; + } + } + throw new Error("Cannot find CONTROLS.md from this cwd."); +} + +const ROOT = repoRoot(); + +const shared = { + commands: ALL_COMMANDS, + displayChord, + gestures: ALL_GESTURES, + groups: COMMAND_GROUPS, + notes: NOTES, +}; + +/** The repo is checked out with native line endings on Windows. */ +const normalise = (text: string): string => text.replace(/\r\n/g, "\n"); + +describe("the generated controls reference", () => { + it("matches the catalog", () => { + const committed = normalise( + readFileSync(join(ROOT, "CONTROLS.md"), "utf8") + ); + + expect(committed).toBe(normalise(renderControls(shared))); + }); + + it("matches the short table in README.md", () => { + const readme = normalise(readFileSync(join(ROOT, "README.md"), "utf8")); + const from = readme.indexOf(""); + const to = readme.indexOf(""); + expect(from).toBeGreaterThan(-1); + expect(to).toBeGreaterThan(from); + + const block = readme + .slice(from + "".length, to) + .trim(); + + expect(block).toBe(normalise(renderEssentials(shared)).trim()); + }); + + it("says it is generated, so nobody edits it by hand", () => { + const committed = readFileSync(join(ROOT, "CONTROLS.md"), "utf8"); + + expect(committed).toContain("Do not edit"); + expect(committed).toContain("scripts/gen-controls.mjs"); + }); +}); diff --git a/packages/overlay/src/keys/gestures.test.ts b/packages/overlay/src/keys/gestures.test.ts new file mode 100644 index 0000000..2a90df2 --- /dev/null +++ b/packages/overlay/src/keys/gestures.test.ts @@ -0,0 +1,156 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ALL_GESTURES } from "./catalog"; + +/* + * Keeping the gesture table honest, in both directions. + * + * Gestures cannot be `run()` callbacks — space-to-pan is a state between a + * keydown and a keyup, a wheel is a stream — so unlike a command, nothing about + * a gesture is checked by the compiler. The table is prose, and prose about + * code rots. + * + * So it is checked by scanning, the way `tooltip.copy.test.ts` checks tooltip + * copy and `scripts/check-css.mjs` checks the no-transition rule: + * + * - **Forward.** Every `impl` resolves — the file exists and the symbol is in + * it. A renamed handler fails here rather than leaving a row that describes + * nothing. + * - **Backward.** Every file that registers a pointer or wheel listener is + * named by some row. This is the half that matters in a year: it catches the + * right-drag somebody adds and never documents, which is precisely how the + * input surface got into the state this table was written to fix. + */ + +/** Product source only — no tests, no stories. */ +const SKIP = /\.(test|stories)\.ts$/; + +function srcRoot(): string { + // Turbo runs vitest from either the package or the repo root. + for (const base of ["src", join("packages", "overlay", "src")]) { + if (existsSync(base)) { + return base; + } + } + throw new Error("Cannot find the overlay source tree from this cwd."); +} + +const SRC = srcRoot(); + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...sourceFiles(path)); + } else if (entry.name.endsWith(".ts") && !SKIP.test(entry.name)) { + out.push(path); + } + } + return out; +} + +/** + * Files that legitimately listen for a pointer event and are not gestures. + * + * Each is a *mechanism* rather than something a user performs, which is the + * line the table draws. Keep this list short and give every entry a reason — + * an unexplained addition here is how a real gesture goes undocumented. + */ +const NOT_GESTURES: Readonly> = { + "canvas/device-menu.ts": + "stops a press in the size field from reading as picking the row it sits in", + "chat/model-menu.ts": + "same as device-menu: stops a press in the model field from reading as picking the row it sits in", + "chat/transcript.ts": + "latches the text selection before the button collapses it", + "chrome-layer.ts": "hosts chrome; the listeners are its children's", + "dnd/manager.ts": "the drag sensor itself, under every drag gesture", + "edit-guard.ts": + "swallows presses so the host app stays inert; the opposite of a gesture", + "frame-agent.ts": + "forwards a frame's events up to the shell, which is where they are handled", + "inspector/controls/color-picker.ts": + "dragging a slider inside an open control, not a canvas gesture", + "inspector/controls/gradient-editor.ts": + "same: a slider inside an open control", + "inspector/reorder.ts": "the DOM-tree drag, driven by the dnd manager above", + "inspector/text-edit.ts": + "click-away from a live caret, which the browser owns", + "keys/palette.ts": + "a result row activating, which is a click and not a gesture", + "popover-host.ts": "outside-press to close, which every popover has", + "shell-app.ts": + "routes a frame's forwarded wheel to the canvas; the gesture is the canvas's", + "tooltip.ts": "hover to open a label", +}; + +/** A listener registration for something a user does with a pointer. */ +const POINTER_LISTENER = + /addEventListener\(\s*["'](wheel|pointerdown|mousedown|contextmenu|dblclick|auxclick|gesturestart|touchstart)["']/; + +/** `path/to/file.ts#symbol`. */ +const IMPL_SHAPE = /^[\w/-]+\.ts#\w+$/; + +const files = sourceFiles(SRC); + +describe("every declared gesture is real", () => { + it("finds the source tree", () => { + // A scan that matched nothing would make both directions pass vacuously. + expect(files.length).toBeGreaterThan(50); + }); + + it("resolves every `impl` to a file and a symbol", () => { + const broken: string[] = []; + for (const gesture of ALL_GESTURES) { + const [rel, symbol] = gesture.impl.split("#"); + const path = join(SRC, ...rel.split("/")); + if (!existsSync(path)) { + broken.push(`${gesture.id}: no such file ${rel}`); + continue; + } + const source = readFileSync(path, "utf8"); + if (!new RegExp(`\\b${symbol}\\b`).test(source)) { + broken.push(`${gesture.id}: ${rel} has no \`${symbol}\``); + } + } + expect(broken).toEqual([]); + }); + + it("spells every `impl` as `path/to/file.ts#symbol`", () => { + const malformed = ALL_GESTURES.filter((g) => !IMPL_SHAPE.test(g.impl)).map( + (g) => `${g.id}: ${g.impl}` + ); + expect(malformed).toEqual([]); + }); +}); + +describe("every real gesture is declared", () => { + it("names every file that listens for a pointer gesture", () => { + const declared = new Set(ALL_GESTURES.map((g) => g.impl.split("#")[0])); + const undocumented: string[] = []; + for (const path of files) { + const rel = path + .slice(SRC.length + 1) + .split(sep) + .join("/"); + if (declared.has(rel) || rel in NOT_GESTURES) { + continue; + } + if (POINTER_LISTENER.test(readFileSync(path, "utf8"))) { + undocumented.push(rel); + } + } + // A file here is either a gesture nobody wrote down — add it to `GESTURES` + // — or a mechanism, in which case add it to `NOT_GESTURES` with a reason. + expect(undocumented).toEqual([]); + }); + + it("keeps the exemption list explained", () => { + const unexplained = Object.entries(NOT_GESTURES) + .filter(([, why]) => why.length < 20) + .map(([file]) => file); + expect(unexplained).toEqual([]); + }); +}); diff --git a/packages/overlay/src/keys/keys.stories.ts b/packages/overlay/src/keys/keys.stories.ts new file mode 100644 index 0000000..4068dd6 --- /dev/null +++ b/packages/overlay/src/keys/keys.stories.ts @@ -0,0 +1,210 @@ +import type { Meta, StoryObj } from "@storybook/html-vite"; +import { cls, el } from "../dom"; +import { icon } from "../icons"; +import { plainStage } from "../stories/chrome"; +import { noop } from "../stories/fixtures"; +import { onStoryTeardown } from "../stories/lifecycle"; +import { ALL_COMMANDS, ALL_GESTURES, displayChord } from "./catalog"; +import { closePalette, openPalette } from "./palette"; +import { keys } from "./registry"; +import { closeShortcuts, openShortcuts } from "./shortcuts-panel"; + +/* + * The input catalogue, and the two surfaces that render it. + * + * Everything here reads one table — `keys/catalog.ts`. Chords used to be string + * literals at each `keys.bind` call site, which made three things impossible at + * once: nothing could enumerate them (so there was no panel and no palette), + * nothing could document them (so the README's six hand-written rows were the + * whole reference, and its copy under `apps/cli/` had already drifted), and a + * tooltip found its chord by matching its own *text* against a binding's label, + * so rewording a tooltip silently dropped the chip. + * + * The first story is the visual canary for `displayChord`. Everything else in + * the editor renders one chord at a time, in a tooltip, which is exactly the + * condition under which a wrong glyph goes unnoticed for a year — the zoom + * menu shipped `"⌘+"` for a binding that is `mod+=`, and shipped it to Windows. + */ + +const meta: Meta = { + title: "Foundations/Shortcuts", +}; + +export default meta; + +/** + * Every chord in the catalog, in both platforms' spelling. + * + * Side by side on purpose. `displayChord` takes the platform as an argument + * rather than probing `navigator`, and this is where you can see that the two + * columns really differ — a regression that collapsed them would be invisible + * in the product to anyone on the platform they develop on. + */ +export const Catalogue: StoryObj = { + render: () => + plainStage( + [ + el( + "div", + { + class: cls("sc-body"), + style: "columns: 1; max-width: 720px; padding: 20px;", + }, + ALL_COMMANDS.map((spec) => + el("div", { class: cls("sc-row") }, [ + el("span", { class: cls("sc-name") }, [ + el("span", { text: spec.title }), + el("span", { class: cls("sc-why"), text: spec.group }), + ]), + el( + "span", + { class: cls("sc-keys") }, + (spec.primary ?? spec.keys).flatMap((chord) => [ + el("kbd", { + class: cls("sc-key"), + text: displayChord(chord, "mac"), + }), + el("kbd", { + class: cls("sc-key"), + text: displayChord(chord, "pc"), + }), + ]) + ), + ]) + ) + ), + ], + { + try: "compare the two chips on each row — macOS first, then Windows and Linux", + what: `All ${ALL_COMMANDS.length} commands, rendered from the catalog in both platforms' spelling.`, + } + ), +}; + +/** The pointer half, which has no chord and used to have no documentation. */ +export const Gestures: StoryObj = { + render: () => + plainStage( + [ + el( + "div", + { + class: cls("sc-body"), + style: "columns: 1; max-width: 720px; padding: 20px;", + }, + ALL_GESTURES.map((spec) => + el("div", { class: cls("sc-row") }, [ + el("span", { class: cls("sc-name") }, [ + el("span", { text: spec.title }), + el("span", { class: cls("sc-why"), text: spec.device }), + ]), + el("span", { class: cls("sc-keys") }, [ + el("kbd", { class: cls("sc-key"), text: spec.input }), + ]), + ]) + ) + ), + ], + { + what: `All ${ALL_GESTURES.length} pointer gestures. Each names the symbol that implements it, and \`gestures.test.ts\` checks both directions.`, + } + ), +}; + +/** Bind enough of the catalog that the two surfaces have something to show. */ +function bindSome(): void { + for (const id of [ + "history.undo", + "history.redo", + "element.delete", + "element.duplicate", + "element.editText", + "element.nudge", + "selection.deselect", + "tool.move", + "tool.inspect", + "chat.send", + "help.shortcuts", + "help.palette", + ] as const) { + onStoryTeardown(keys.bind({ id, run: noop })); + } +} + +/** + * The `?` sheet. + * + * Rendered from the whole catalog, with the rows that are not live dimmed and + * labelled — "canvas only", "edit mode". That is the opposite of the palette + * below and it is the point: a reference that hides what you cannot currently + * do is useless for the reason people open one. Here the zoom set is dimmed, + * because this story binds no viewport. + */ +export const ShortcutsSheet: StoryObj = { + play: () => { + openShortcuts(); + // `closeShortcuts`, not a second `openShortcuts`: the opener toggles, so a + // teardown that calls it again *opens* the sheet whenever the story left it + // closed — the one case teardown exists for. + onStoryTeardown(closeShortcuts); + }, + render: () => { + // The bindings in `render`, the open in `play`. `preview.ts` mounts the + // popover host only *after* `story()` returns, so there is nowhere to open + // into until then — the same ordering `tooltip.stories.ts` documents. + bindSome(); + return plainStage([el("div", { style: "height: 520px;" })], { + try: "look at the dimmed rows — each says why it is not live, rather than being hidden", + what: "The shortcuts sheet, rendered from the catalog.", + }); + }, +}; + +/** + * The ⌘K palette. + * + * Rendered from `keys.available()` — bound *and* allowed by its guard right + * now. An action surface has to be able to run every row it shows, so the zoom + * commands that the sheet above dims are simply absent here. + */ +export const Palette: StoryObj = { + play: () => { + openPalette(); + // Closed explicitly, for the reason the sheet above gives. + onStoryTeardown(closePalette); + }, + render: () => { + bindSome(); + return plainStage([el("div", { style: "height: 520px;" })], { + try: "type a few letters — the ranking is subsequence-first, so “dpl” finds Duplicate", + what: "The command palette. Only what is runnable this second.", + }); + }, +}; + +/** The bar button that opens the sheet, with its chord resolved from the catalog. */ +export const HelpButton: StoryObj = { + render: () => { + bindSome(); + return plainStage( + [ + el("div", { class: cls("bar") }, [ + el( + "button", + { + "aria-label": "Keyboard shortcuts", + class: cls("iconbtn"), + "data-key": "help.shortcuts", + "data-tip": "Keyboard shortcuts", + type: "button", + }, + [icon("keyboard", "sm")] + ), + ]), + ], + { + what: "The bar's help button. Outside both mode lists, so it survives the mode you are stuck in.", + } + ); + }, +}; diff --git a/packages/overlay/src/keys/palette.test.ts b/packages/overlay/src/keys/palette.test.ts new file mode 100644 index 0000000..01b6d0f --- /dev/null +++ b/packages/overlay/src/keys/palette.test.ts @@ -0,0 +1,280 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cls } from "../dom"; +import { mountPopoverHost } from "../popover-host"; +import { closePalette, openPalette, paletteIsOpen } from "./palette"; +import { keys } from "./registry"; + +/* + * The command palette. + * + * Two properties are the whole design and both are easy to lose: + * + * - **It lists only what is runnable.** An action surface offering "Zoom to + * fit" on the inline overlay, where there is no canvas, has lied before you + * press Enter. The shortcuts panel is the one that shows everything. + * - **Focus never leaves the search field.** The active row moves by + * `aria-activedescendant`. Reusing the popover host's roving helpers would + * take the caret out of the field on the first ↓, which is the failure mode + * this file exists to catch. + */ + +const disposers: (() => void)[] = []; + +function bind( + id: Parameters[0]["id"], + run = () => undefined +) { + disposers.push(keys.bind({ id, run })); +} + +function card(): HTMLElement { + const found = document.querySelector(`.${cls("palette")}`); + if (!found) { + throw new Error("The palette is not open."); + } + return found; +} + +const field = (): HTMLInputElement => + card().querySelector(`.${cls("palette-field")}`) as HTMLInputElement; + +const rows = (): HTMLElement[] => [ + ...card().querySelectorAll(`.${cls("palette-row")}`), +]; + +const titles = (): string[] => + rows().map( + (r) => r.querySelector(`.${cls("palette-title")}`)?.textContent ?? "" + ); + +function type(text: string): void { + field().value = text; + field().dispatchEvent(new Event("input", { bubbles: true })); +} + +/** + * From the search field, which is where focus actually is. + * + * Not a detail: the popover host binds its own `popover.close` on Escape, + * scoped to the same card. Focus being in a *field* is what tells the two + * apart — the host's binding carries no `allowWhileTyping`, so the registry + * skips it, and only the palette's own two-step Escape survives. Pressing from + * the card instead makes the host's binding eligible and closes the palette on + * the first press. + */ +function press(key: string): void { + field().dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key }) + ); +} + +beforeEach(() => { + document.body.replaceChildren(); + mountPopoverHost(document.body); +}); + +afterEach(() => { + closePalette(); + for (const off of disposers.splice(0)) { + off(); + } + keys.destroy(); + document.body.replaceChildren(); +}); + +describe("what the palette lists", () => { + it("shows a bound command and not an unbound one", () => { + bind("history.undo"); + + openPalette(); + + expect(titles()).toContain("Undo"); + // Bound nowhere in this test, which is what the inline surface looks like + // for the zoom set. + expect(titles()).not.toContain("Zoom to fit"); + }); + + it("drops a command whose guard says no", () => { + let allowed = true; + disposers.push( + keys.bind({ + id: "history.undo", + run: () => undefined, + when: () => allowed, + }) + ); + openPalette(); + expect(titles()).toContain("Undo"); + + closePalette(); + allowed = false; + openPalette(); + + expect(titles()).not.toContain("Undo"); + }); + + it("reaches the real binding for every row it lists", () => { + // The plumbing behind the rule above: a listed row must invoke the binding + // it names, not merely appear. + const ran: string[] = []; + for (const id of ["history.undo", "element.duplicate"] as const) { + disposers.push(keys.bind({ id, run: () => ran.push(id) })); + } + openPalette(); + + for (const row of rows()) { + row.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + openPalette(); + } + + expect(ran.sort((a, b) => a.localeCompare(b))).toEqual([ + "element.duplicate", + "history.undo", + ]); + }); + + it("keeps a popover's own keys out of it", () => { + const shell = document.createElement("div"); + document.body.append(shell); + disposers.push( + keys.bind({ id: "popover.next", run: () => undefined, within: shell }) + ); + + openPalette(); + + expect(titles()).not.toContain("Next option"); + }); +}); + +describe("searching", () => { + beforeEach(() => { + bind("history.undo"); + bind("history.redo"); + bind("element.duplicate"); + openPalette(); + }); + + it("puts a title that starts with the query first", () => { + type("du"); + + expect(titles()[0]).toBe("Duplicate"); + }); + + it("matches a subsequence, which is how people actually type", () => { + type("dpl"); + + expect(titles()).toContain("Duplicate"); + }); + + it("says so when nothing matches", () => { + type("zzzz"); + + expect(rows()).toHaveLength(0); + expect(card().querySelector(`.${cls("palette-empty")}`)).not.toBeNull(); + }); + + it("groups only while the query is empty", () => { + expect(card().querySelector(`.${cls("pop-head")}`)).not.toBeNull(); + + type("u"); + + expect(card().querySelector(`.${cls("pop-head")}`)).toBeNull(); + }); +}); + +describe("navigating", () => { + it("moves the active row without moving focus", () => { + bind("history.undo"); + bind("history.redo"); + openPalette(); + const before = document.activeElement; + + press("ArrowDown"); + + expect(document.activeElement).toBe(before); + expect(rows()[1].classList.contains(cls("palette-row-on"))).toBe(true); + expect(field().getAttribute("aria-activedescendant")).toBe(rows()[1].id); + }); + + it("stops at the ends rather than wrapping", () => { + bind("history.undo"); + bind("history.redo"); + openPalette(); + + press("ArrowUp"); + + expect(rows()[0].classList.contains(cls("palette-row-on"))).toBe(true); + }); + + it("runs the active row on Enter and closes", () => { + const run = vi.fn(); + disposers.push(keys.bind({ id: "history.undo", run })); + openPalette(); + + press("Enter"); + + expect(run).toHaveBeenCalledTimes(1); + expect(paletteIsOpen()).toBe(false); + }); + + it("clears the query on the first Escape and closes on the second", () => { + bind("history.undo"); + openPalette(); + type("undo"); + + press("Escape"); + expect(paletteIsOpen()).toBe(true); + expect(field().value).toBe(""); + + press("Escape"); + expect(paletteIsOpen()).toBe(false); + }); +}); + +describe("the palette itself", () => { + it("toggles on a second open", () => { + bind("history.undo"); + + openPalette(); + expect(paletteIsOpen()).toBe(true); + + openPalette(); + expect(paletteIsOpen()).toBe(false); + }); + + it("describes itself as a modal combobox over a listbox", () => { + bind("history.undo"); + openPalette(); + + expect(card().getAttribute("role")).toBe("dialog"); + expect(card().getAttribute("aria-modal")).toBe("true"); + expect(field().getAttribute("role")).toBe("combobox"); + expect( + card() + .querySelector(`.${cls("palette-list")}`) + ?.getAttribute("role") + ).toBe("listbox"); + expect(rows()[0].getAttribute("role")).toBe("option"); + }); + + it("puts a scrim behind itself", () => { + bind("history.undo"); + openPalette(); + + expect(document.querySelector(`.${cls("pop-scrim")}`)).not.toBeNull(); + + closePalette(); + + expect(document.querySelector(`.${cls("pop-scrim")}`)).toBeNull(); + }); + + it("releases its own navigation when it closes", () => { + bind("history.undo"); + openPalette(); + closePalette(); + + // `palette.next` is scoped to a card that no longer exists; nothing should + // be left answering ↓. + expect(keys.isBound("palette.next")).toBe(false); + }); +}); diff --git a/packages/overlay/src/keys/palette.ts b/packages/overlay/src/keys/palette.ts new file mode 100644 index 0000000..25a3d1c --- /dev/null +++ b/packages/overlay/src/keys/palette.ts @@ -0,0 +1,268 @@ +/** + * ⌘K — search everything the editor can do right now, and run it. + * + * The counterpart to the shortcuts panel, and deliberately not the same list. + * This one renders `keys.available()`: commands that are bound *and* whose + * guards say yes this second. It is an action surface, so every row has to do + * something — a palette that offers "Zoom to fit" on the inline overlay, where + * there is no canvas, has lied to you before you press Enter. The panel renders + * the whole catalog with the unavailable rows dimmed, because a reference that + * hides what you cannot currently do is useless for learning. + * + * That distinction is the whole reason no mode enum was needed for either. The + * `when` closures are pure predicates each subsystem already maintains, so + * asking all of them at once *is* the live answer — `tools.ts` made that + * argument first ("consulted per keypress rather than latched — it asks"). + * + * ## Two things that look like details and are not + * + * **Focus stays in the search field.** The active row moves by + * `aria-activedescendant`, never by `focus()`. `popover-host`'s own roving + * helpers move real focus, which is right for a menu and would take the caret + * out of the field here on the first ↓. + * + * **The navigation is four scoped commands, not a keydown listener.** The + * field has focus the whole time, so the registry skips every binding without + * `allowWhileTyping`; these four carry it, and `within` keeps them from leaking + * to a page where ↓ means something else. + */ +import { cls, el } from "../dom"; +import { icon } from "../icons"; +import { openPopover, type PopoverHandle } from "../popover-host"; +import type { Command, CommandGroup } from "./catalog"; +import { keys, type LiveCommand } from "./registry"; + +/** Only one can be open, and ⌘K while it is up should close it. */ +let open: PopoverHandle | null = null; + +/** + * Rank a command against a query, or `null` if it does not match. + * + * Subsequence rather than substring, so "zf" finds "Zoom to fit" — the thing + * people actually type into a palette. Lower is better. No fuzzy-search + * dependency: the corpus is forty rows, and a scoring function nobody can + * explain is worse than one that occasionally ranks a row second. + */ +function score(spec: Command, query: string): number | null { + const haystack = `${spec.title} ${spec.group} ${spec.doc}`.toLowerCase(); + const title = spec.title.toLowerCase(); + if (!query) { + return 0; + } + // A title that starts with the query is what the user meant, every time. + if (title.startsWith(query)) { + return 0; + } + if (title.includes(query)) { + return 1; + } + let at = 0; + for (const ch of query) { + at = title.indexOf(ch, at); + if (at === -1) { + // Fall back to the whole haystack, so "canvas" finds the View group's + // rows through their group name and their sentence. + return haystack.includes(query) ? 3 : null; + } + at += 1; + } + return 2; +} + +function matches(query: string): LiveCommand[] { + const q = query.trim().toLowerCase(); + return ( + keys + .available() + .map((c) => ({ c, rank: score(c.spec, q) })) + .filter((r): r is { c: LiveCommand; rank: number } => r.rank !== null) + // Stable within a rank, so an empty query keeps catalog order and the + // groups below stay contiguous. + .sort((a, b) => a.rank - b.rank) + .map((r) => r.c) + ); +} + +export function closePalette(): void { + open?.close("programmatic"); + open = null; +} + +export function paletteIsOpen(): boolean { + return open !== null; +} + +export function openPalette(): void { + if (open) { + closePalette(); + return; + } + + const field = el("input", { + "aria-autocomplete": "list", + "aria-controls": `${cls("palette-list")}`, + "aria-expanded": "true", + class: cls("palette-field"), + placeholder: "Search commands…", + role: "combobox", + type: "text", + }) as HTMLInputElement; + + const list = el("div", { + class: `${cls("palette-list")} ${cls("scroll-y")}`, + id: cls("palette-list"), + role: "listbox", + }); + + const empty = el("div", { + class: cls("palette-empty"), + text: "Nothing matches.", + }); + + const card = el( + "div", + { + "aria-label": "Command palette", + "aria-modal": "true", + class: cls("palette"), + role: "dialog", + }, + [ + el("div", { class: cls("palette-head") }, [icon("search", "sm"), field]), + list, + ] + ); + + /** The rows currently rendered, in display order. */ + let rows: { el: HTMLElement; run: () => void }[] = []; + let active = 0; + + const setActive = (i: number): void => { + if (!rows.length) { + field.removeAttribute("aria-activedescendant"); + return; + } + active = Math.max(0, Math.min(rows.length - 1, i)); + rows.forEach((row, at) => { + row.el.classList.toggle(cls("palette-row-on"), at === active); + row.el.setAttribute("aria-selected", String(at === active)); + }); + const chosen = rows[active].el; + field.setAttribute("aria-activedescendant", chosen.id); + chosen.scrollIntoView({ block: "nearest" }); + }; + + const render = (): void => { + const found = matches(field.value); + list.replaceChildren(); + rows = []; + if (!found.length) { + list.append(empty); + field.removeAttribute("aria-activedescendant"); + return; + } + // Headers only on an empty query. Once you are searching, the ranking has + // already reordered everything and a group heading would sit above rows + // that are no longer grouped. + const grouped = !field.value.trim(); + let seen: CommandGroup | null = null; + found.forEach((cmd, i) => { + const { spec } = cmd; + if (grouped && spec.group !== seen) { + seen = spec.group; + list.append(el("div", { class: cls("pop-head"), text: spec.group })); + } + const [chord] = keys.chords(spec.id); + const row = el( + "div", + { + class: `${cls("pop-item")} ${cls("palette-row")}`, + id: `${cls("palette-row")}-${i}`, + role: "option", + }, + [ + spec.icon ? icon(spec.icon, "sm") : el("span"), + el("span", { class: cls("pop-item-main") }, [ + el("span", { class: cls("palette-title"), text: spec.title }), + el("span", { class: cls("palette-doc"), text: spec.doc }), + ]), + chord + ? el("span", { class: cls("pop-item-hint"), text: chord }) + : el("span"), + ] + ); + const invoke = (): void => { + closePalette(); + cmd.run(); + }; + // `pointerdown`, not `click`: the host's outside-press listener is also + // on `pointerdown`, and a row that waited for `click` would be gone by + // the time it arrived. + row.addEventListener("pointerdown", (e) => { + e.preventDefault(); + invoke(); + }); + row.addEventListener("pointerover", () => setActive(i)); + rows.push({ el: row, run: invoke }); + list.append(row); + }); + setActive(0); + }; + + field.addEventListener("input", render); + // The one trap in a dialog whose focus never moves: anything that steals + // focus leaves the four navigation commands scoped to a card nobody is in. + card.addEventListener("focusout", (e) => { + const to = (e as FocusEvent).relatedTarget as Node | null; + if (!(to && card.contains(to))) { + field.focus(); + } + }); + + render(); + + const offKeys = keys.bindAll([ + { id: "palette.next", run: () => setActive(active + 1), within: card }, + { id: "palette.prev", run: () => setActive(active - 1), within: card }, + { + id: "palette.run", + run: () => rows[active]?.run(), + within: card, + }, + { + id: "palette.close", + // Clears the query first and closes on the second press — the same + // two-step `token-field.ts` uses, so a mistyped search does not cost you + // the palette. + // + // The popover host binds its own Escape on this same card, and what tells + // the two apart is that focus is in a *field*: the host's carries no + // `allowWhileTyping`, so the registry skips it and this one is the only + // Escape left standing. If focus ever escapes the field the host's wins + // and the palette simply closes, which is the right fallback anyway. + run: () => { + if (field.value) { + field.value = ""; + render(); + return; + } + closePalette(); + }, + within: card, + }, + ]); + + open = openPopover({ + className: "pop-palette", + content: card, + modal: true, + onClose: () => { + offKeys(); + open = null; + }, + // The card's own commands do the navigating; the host's roving would move + // real focus out of the search field. + roving: false, + }); + field.focus(); +} diff --git a/packages/overlay/src/keys/registry.test.ts b/packages/overlay/src/keys/registry.test.ts new file mode 100644 index 0000000..17fa3fb --- /dev/null +++ b/packages/overlay/src/keys/registry.test.ts @@ -0,0 +1,689 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PREFIX } from "../dom"; +import { ALL_COMMANDS } from "./catalog"; +import { + isInsidePopover, + isNativeKeyTarget, + isTypingTarget, + keys, + PLATFORM, +} from "./registry"; + +/* + * What the registry asks before it runs anything. + * + * Three questions, and they used to be two. "Is the user typing?" and "is this + * keystroke inside a popover?" were once a single flag, and that is the bug the + * first half of this file holds shut: `isInsidePopover` folded into `typing` + * meant one `continue` skipped every binding without `allowWhileTyping` — + * including the ones `popover-host` registers for the popover *itself*, which + * carry no such flag. Every menu in the overlay lost Escape and its arrow keys, + * and nothing caught it because nothing here dispatched a key inside a popover. + * + * The third is "does this control own the key already?", split out of `typing` + * because widening `typing` to exclude a checkbox is right and widening it to + * exclude a `` is a widget. */ +const TEXT_INPUT_TYPES = new Set([ + "date", + "datetime-local", + "email", + "month", + "number", + "password", + "search", + "tel", + "text", + "time", + "url", + "week", +]); + +/** Roles that mean "a text field built out of a div". */ +const TEXT_ROLES = new Set(["combobox", "searchbox", "textbox"]); + +/** + * Bare keys that a native control legitimately owns. + * + * A `` inside a host app's web component read + * as its host and `isTypingTarget` said no — meaning Backspace deleted the + * selected element while the user was typing into their own form. + */ +function originOf(e: Event): EventTarget | null { + return e.composedPath?.()[0] ?? e.target; +} + +/** + * Is the target inside a popover the editor has open? + * + * The popover owns its own keys, and a global shortcut firing underneath it acts + * on something the user cannot see. So this suppresses every binding that has not + * scoped itself with `within` — deliberately *not* the same thing as typing, + * which suppresses scoped and unscoped alike so a field keeps its own Escape. + * + * Exported for `canvas/viewport.ts`, whose space-to-pan is a raw listener rather + * than a binding and so has to ask the same question for itself. + */ +export function isInsidePopover(target: EventTarget | null): boolean { + const node = target as Element | null; + return Boolean(node?.closest?.(`.${PREFIX}-pop`)); +} + +/** + * Is the user entering text? Shortcuts must not fire inside the composer. + * + * Duck-typed on `tagName` and `type` rather than `instanceof HTMLElement`, + * because with `observe()` live the node can come from a frame's realm, where + * `instanceof` is false for a perfectly ordinary input. See `realm.ts`. + */ +export function isTypingTarget( + target: EventTarget | null, + e?: KeyboardEvent +): boolean { + // An IME candidate window is mid-composition; those keystrokes are the user + // spelling a character, never a chord. `keyCode === 229` is the same signal + // on Safari and older WebKit, where `isComposing` is unreliable. + if (e?.isComposing || e?.keyCode === 229) { + return true; + } + const node = target as HTMLElement | null; + if (!node?.tagName) { + return false; + } + const tag = node.tagName.toLowerCase(); + if (tag === "textarea") { + return true; + } + if (tag === "input") { + // A missing `type` is `text`. Everything not in the set — checkbox, radio, + // range, button, submit, colour, file — is a widget, and treating those as + // "typing" suppressed *every* shortcut including Escape while one had focus. + return TEXT_INPUT_TYPES.has( + (node as HTMLInputElement).type?.toLowerCase() || "text" + ); + } + if (node.isContentEditable) { + return true; + } + const role = node.getAttribute?.("role"); + return Boolean(role && TEXT_ROLES.has(role)); +} + +/** + * Does this control own the plain navigation and activation keys itself? + * + * Asked only of bare arrows, Home, End, Enter and Space — a modified chord is + * never a native control's business. Splitting this out of `isTypingTarget` is + * what lets a focused checkbox pass Escape and ⌘Z through while a focused + * `