feat(overlay): declare every command once, and generate the reference from it
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`.
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
||||
<!-- Generated from packages/overlay/src/keys/catalog.ts by scripts/gen-controls.mjs. Do not edit. -->
|
||||
|
||||
# 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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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<typeof keys.bind>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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<Document>();
|
||||
|
||||
/** 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();
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
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");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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("<!-- controls:start -->");
|
||||
const to = readme.indexOf("<!-- controls:end -->");
|
||||
expect(from).toBeGreaterThan(-1);
|
||||
expect(to).toBeGreaterThan(from);
|
||||
|
||||
const block = readme
|
||||
.slice(from + "<!-- controls:start -->".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");
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = {
|
||||
"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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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<typeof keys.bind>[0]["id"],
|
||||
run = () => undefined
|
||||
) {
|
||||
disposers.push(keys.bind({ id, run }));
|
||||
}
|
||||
|
||||
function card(): HTMLElement {
|
||||
const found = document.querySelector<HTMLElement>(`.${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<HTMLElement>(`.${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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 `<select>`'s arrows is not. See `isNativeKeyTarget`.
|
||||
*
|
||||
* Fixtures use real command ids. A `test.*` id would have to be declared in the
|
||||
* catalog to compile, and would then show up in the palette and in CONTROLS.md.
|
||||
*/
|
||||
|
||||
/** Undo every binding a test registered. The registry is a singleton. */
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
function bind(binding: Parameters<typeof keys.bind>[0]): () => void {
|
||||
const off = keys.bind(binding);
|
||||
disposers.push(off);
|
||||
return off;
|
||||
}
|
||||
|
||||
interface PressOpts {
|
||||
code?: string;
|
||||
composed?: boolean;
|
||||
/**
|
||||
* Control, whatever the platform. On a PC this is `mod`; on a Mac it is the
|
||||
* modifier no chord in the catalog uses, which is the point of setting it.
|
||||
*/
|
||||
ctrl?: boolean;
|
||||
isComposing?: boolean;
|
||||
/** Command, whatever the platform. The mirror of `ctrl` above. */
|
||||
meta?: boolean;
|
||||
/**
|
||||
* ⌘ on a Mac, Ctrl elsewhere — the modifier chords actually spell.
|
||||
*
|
||||
* This used to set `ctrlKey` *and* `metaKey` together, so "either branch
|
||||
* matches" whichever platform the suite ran on. That is also why nothing
|
||||
* caught `chordOf` ignoring the off-platform modifier instead of refusing it:
|
||||
* no test could produce a keystroke carrying only one of them.
|
||||
*/
|
||||
mod?: boolean;
|
||||
shift?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 and the handful in
|
||||
* `CODE_KEYS`.
|
||||
*/
|
||||
function press(from: Node, key: string, opts: PressOpts = {}): KeyboardEvent {
|
||||
const onMac = PLATFORM === "mac";
|
||||
const mod = opts.mod ?? false;
|
||||
const e = new KeyboardEvent("keydown", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
code: opts.code ?? "",
|
||||
composed: opts.composed ?? false,
|
||||
ctrlKey: (opts.ctrl ?? false) || (mod && !onMac),
|
||||
isComposing: opts.isComposing ?? false,
|
||||
key,
|
||||
metaKey: (opts.meta ?? false) || (mod && onMac),
|
||||
shiftKey: opts.shift ?? false,
|
||||
});
|
||||
from.dispatchEvent(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** The modifier that is *not* `mod` here — the one no chord may claim. */
|
||||
const OFF_PLATFORM: "ctrl" | "meta" = PLATFORM === "mac" ? "ctrl" : "meta";
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
function input(type: string): HTMLInputElement {
|
||||
const node = document.createElement("input");
|
||||
node.type = type;
|
||||
document.body.append(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const off of disposers.splice(0)) {
|
||||
off();
|
||||
}
|
||||
keys.destroy();
|
||||
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("isTypingTarget", () => {
|
||||
it("is true for the things a person types into", () => {
|
||||
expect(isTypingTarget(document.createElement("textarea"))).toBe(true);
|
||||
expect(isTypingTarget(input("text"))).toBe(true);
|
||||
expect(isTypingTarget(input("search"))).toBe(true);
|
||||
expect(isTypingTarget(input("number"))).toBe(true);
|
||||
// A missing `type` is a text input.
|
||||
expect(isTypingTarget(document.createElement("input"))).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for the inputs that are widgets, not fields", () => {
|
||||
// These all report `tagName === "INPUT"`, which is why the old three-line
|
||||
// check treated them as typing and suppressed *every* shortcut — Escape
|
||||
// included — while a checkbox had focus.
|
||||
expect(isTypingTarget(input("checkbox"))).toBe(false);
|
||||
expect(isTypingTarget(input("radio"))).toBe(false);
|
||||
expect(isTypingTarget(input("range"))).toBe(false);
|
||||
expect(isTypingTarget(input("button"))).toBe(false);
|
||||
expect(isTypingTarget(input("color"))).toBe(false);
|
||||
expect(isTypingTarget(input("file"))).toBe(false);
|
||||
});
|
||||
|
||||
it("is true for a div wearing a textbox role", () => {
|
||||
const node = plain("div");
|
||||
node.setAttribute("role", "textbox");
|
||||
expect(isTypingTarget(node)).toBe(true);
|
||||
});
|
||||
|
||||
it("is true mid-IME-composition, whatever the target", () => {
|
||||
const node = plain();
|
||||
expect(
|
||||
isTypingTarget(node, new KeyboardEvent("keydown", { isComposing: true }))
|
||||
).toBe(true);
|
||||
// The Safari and older-WebKit spelling of the same fact.
|
||||
expect(
|
||||
isTypingTarget(node, new KeyboardEvent("keydown", { keyCode: 229 }))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNativeKeyTarget", () => {
|
||||
it("covers the controls that implement the bare keys themselves", () => {
|
||||
expect(isNativeKeyTarget(document.createElement("select"))).toBe(true);
|
||||
expect(isNativeKeyTarget(input("range"))).toBe(true);
|
||||
expect(isNativeKeyTarget(input("checkbox"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not cover an ordinary button or div", () => {
|
||||
expect(isNativeKeyTarget(plain())).toBe(false);
|
||||
expect(isNativeKeyTarget(plain("div"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a native control's own keys", () => {
|
||||
it("keeps its arrows away from the nudge bindings", () => {
|
||||
const run = vi.fn();
|
||||
bind({ id: "element.nudge", run });
|
||||
|
||||
press(document.createElement("select"), "ArrowDown");
|
||||
press(input("range"), "ArrowRight");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets Escape and modified chords through from a checkbox", () => {
|
||||
// The regression the naive "stop treating inputs as typing" fix causes,
|
||||
// read the other way: a checkbox must not swallow Escape.
|
||||
const onEscape = vi.fn();
|
||||
const undo = vi.fn();
|
||||
bind({ id: "selection.deselect", run: onEscape });
|
||||
bind({ id: "history.undo", run: undo });
|
||||
const box = input("checkbox");
|
||||
|
||||
press(box, "Escape");
|
||||
press(box, "z", { code: "KeyZ", mod: true });
|
||||
|
||||
expect(onEscape).toHaveBeenCalledTimes(1);
|
||||
expect(undo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a binding scoped with `within`", () => {
|
||||
it("fires for a keystroke inside its element", () => {
|
||||
const { item, shell } = popover();
|
||||
const run = vi.fn();
|
||||
bind({ id: "popover.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({ id: "popover.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({ id: "popover.next", run: outerRun, within: outer.shell });
|
||||
bind({ id: "popover.next", 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({ id: "popover.close", run, within: shell });
|
||||
|
||||
const other = document.implementation.createHTMLDocument();
|
||||
const node = other.createElement("button");
|
||||
other.body.append(node);
|
||||
// The disposer, not another call to `observe` — which is what this line
|
||||
// used to push, so it re-registered on teardown and never removed anything.
|
||||
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({ id: "selection.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({ id: "element.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({ id: "selection.deselect", run: global });
|
||||
bind({ id: "popover.close", run: scoped, within: shell });
|
||||
|
||||
press(item, "Escape");
|
||||
|
||||
expect(scoped).toHaveBeenCalledTimes(1);
|
||||
expect(global).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("precedence is declared, not incidental", () => {
|
||||
it("gives a modal binding the chord over an ordinary one", () => {
|
||||
const menu = vi.fn();
|
||||
const deselect = vi.fn();
|
||||
// Registered *first*, so recency would hand this the key.
|
||||
bind({ id: "frameMenu.close", run: menu });
|
||||
bind({ id: "selection.deselect", run: deselect });
|
||||
|
||||
press(plain(), "Escape");
|
||||
|
||||
expect(menu).toHaveBeenCalledTimes(1);
|
||||
expect(deselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not depend on which was registered first", () => {
|
||||
const menu = vi.fn();
|
||||
const deselect = vi.fn();
|
||||
bind({ id: "selection.deselect", run: deselect });
|
||||
bind({ id: "frameMenu.close", run: menu });
|
||||
|
||||
press(plain(), "Escape");
|
||||
|
||||
expect(menu).toHaveBeenCalledTimes(1);
|
||||
expect(deselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still puts a scoped binding above a modal one", () => {
|
||||
const { item, shell } = popover();
|
||||
const menu = vi.fn();
|
||||
const scoped = vi.fn();
|
||||
bind({ id: "frameMenu.close", run: menu });
|
||||
bind({ id: "popover.close", run: scoped, within: shell });
|
||||
|
||||
press(item, "Escape");
|
||||
|
||||
expect(scoped).toHaveBeenCalledTimes(1);
|
||||
expect(menu).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({ id: "popover.close", run, within: shell });
|
||||
|
||||
press(field, "Escape");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets an `allowWhileTyping` binding through in a field", () => {
|
||||
const field = input("text");
|
||||
const run = vi.fn();
|
||||
bind({ id: "chat.send", run });
|
||||
|
||||
press(field, "Enter", { mod: true });
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("withholds everything mid-composition", () => {
|
||||
const field = input("text");
|
||||
const run = vi.fn();
|
||||
bind({ id: "chat.send", run });
|
||||
|
||||
press(field, "Enter", { isComposing: true, mod: true });
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a keystroke from inside a live frame", () => {
|
||||
function frameDoc(): { doc: Document; node: HTMLElement } {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
const node = doc.createElement("button");
|
||||
doc.body.append(node);
|
||||
disposers.push(keys.observe(doc));
|
||||
return { doc, node };
|
||||
}
|
||||
|
||||
it("reaches the commands marked `inFrame`", () => {
|
||||
const run = vi.fn();
|
||||
bind({ id: "selection.deselect", run });
|
||||
const { node } = frameDoc();
|
||||
|
||||
press(node, "Escape");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still protects a field in the user's own page", () => {
|
||||
// The typing guard reaches one realm down as well: Escape while filling in
|
||||
// a form in view mode is the field's, not the editor's.
|
||||
const run = vi.fn();
|
||||
bind({ id: "selection.deselect", run });
|
||||
const { doc } = frameDoc();
|
||||
const field = doc.createElement("input");
|
||||
doc.body.append(field);
|
||||
|
||||
press(field, "Escape");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reach the ones that are not", () => {
|
||||
// `f`, `h`, `v` and the nudge set would be theft: in view mode the page in
|
||||
// a frame is the user's and they may well be typing into it.
|
||||
const run = vi.fn();
|
||||
bind({ id: "frame.add", run });
|
||||
const { doc } = frameDoc();
|
||||
const button = doc.createElement("button");
|
||||
doc.body.append(button);
|
||||
|
||||
press(button, "f", { code: "KeyF" });
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves those commands working in the shell", () => {
|
||||
const run = vi.fn();
|
||||
bind({ id: "frame.add", run });
|
||||
frameDoc();
|
||||
|
||||
press(plain(), "f", { code: "KeyF" });
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is idempotent per document", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
const node = doc.createElement("button");
|
||||
doc.body.append(node);
|
||||
const run = vi.fn();
|
||||
bind({ id: "selection.deselect", run });
|
||||
disposers.push(keys.observe(doc));
|
||||
disposers.push(keys.observe(doc));
|
||||
|
||||
press(node, "Escape");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops when the frame's disposer runs", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
const node = doc.createElement("button");
|
||||
doc.body.append(node);
|
||||
const run = vi.fn();
|
||||
bind({ id: "selection.deselect", run });
|
||||
const off = keys.observe(doc);
|
||||
|
||||
off();
|
||||
press(node, "Escape");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a binding that matches", () => {
|
||||
it("consumes the event so nothing underneath sees it", () => {
|
||||
const { item, shell } = popover();
|
||||
bind({ id: "popover.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({ id: "popover.close", run: () => undefined, within: shell });
|
||||
|
||||
const e = press(plain(), "Escape");
|
||||
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroy", () => {
|
||||
it("forgets every binding and stops answering", () => {
|
||||
const run = vi.fn();
|
||||
keys.bind({ id: "selection.deselect", run });
|
||||
|
||||
keys.destroy();
|
||||
press(plain(), "Escape");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the registry usable again afterwards", () => {
|
||||
keys.bind({ id: "selection.deselect", run: () => undefined });
|
||||
keys.destroy();
|
||||
|
||||
const run = vi.fn();
|
||||
bind({ id: "selection.deselect", run });
|
||||
press(plain(), "Escape");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("silences a frame document whose disposer never ran", () => {
|
||||
// A frame that navigated away takes its document with it, and any chance of
|
||||
// removing a listener from it. The dead flag is the backstop.
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
const node = doc.createElement("button");
|
||||
doc.body.append(node);
|
||||
const run = vi.fn();
|
||||
keys.bind({ id: "selection.deselect", run });
|
||||
keys.observe(doc);
|
||||
|
||||
keys.destroy();
|
||||
press(node, "Escape");
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the surfaces read", () => {
|
||||
it("renders a command's primary chord and all of its chords", () => {
|
||||
// Redo answers to two, and only ever advertised the first.
|
||||
expect(keys.chords("history.redo")).toHaveLength(2);
|
||||
expect(keys.hint("history.redo")).toBe(keys.chords("history.redo")[0]);
|
||||
});
|
||||
|
||||
it("uses a command's `display` override where one is set", () => {
|
||||
// Nudge is one command answering to four arrows; four rows saying the same
|
||||
// thing is not what a reference wants.
|
||||
expect(keys.chords("element.nudge")).toEqual(["← → ↑ ↓"]);
|
||||
});
|
||||
|
||||
it("lists only what is bound and currently allowed", () => {
|
||||
expect(keys.available().map((c) => c.spec.id)).not.toContain(
|
||||
"history.undo"
|
||||
);
|
||||
|
||||
let allowed = false;
|
||||
bind({ id: "history.undo", run: () => undefined, when: () => allowed });
|
||||
expect(keys.available().map((c) => c.spec.id)).not.toContain(
|
||||
"history.undo"
|
||||
);
|
||||
|
||||
allowed = true;
|
||||
expect(keys.available().map((c) => c.spec.id)).toContain("history.undo");
|
||||
});
|
||||
|
||||
it("keeps the popover's own keys out of the palette", () => {
|
||||
const { shell } = popover();
|
||||
bind({ id: "popover.next", run: () => undefined, within: shell });
|
||||
|
||||
expect(keys.available().map((c) => c.spec.id)).not.toContain(
|
||||
"popover.next"
|
||||
);
|
||||
});
|
||||
|
||||
it("runs a command by id, and says so when nothing is bound", () => {
|
||||
const run = vi.fn();
|
||||
expect(keys.run("history.undo")).toBe(false);
|
||||
|
||||
bind({ id: "history.undo", run });
|
||||
|
||||
expect(keys.run("history.undo")).toBe(true);
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports two bound commands that answer to the same chord", () => {
|
||||
bind({ id: "selection.deselect", run: () => undefined });
|
||||
bind({ id: "tool.handDrop", run: () => undefined });
|
||||
|
||||
const clash = keys.conflicts().find((c) => c.chord === "escape");
|
||||
|
||||
expect(clash?.ids).toEqual(
|
||||
expect.arrayContaining(["selection.deselect", "tool.handDrop"])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not count a scoped binding as a conflict", () => {
|
||||
const { shell } = popover();
|
||||
bind({ id: "selection.deselect", run: () => undefined });
|
||||
bind({ id: "popover.close", run: () => undefined, within: shell });
|
||||
|
||||
expect(keys.conflicts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* The modifier the platform does not use.
|
||||
*
|
||||
* `mod` is Command on a Mac and Control everywhere else, and `chordOf` used to
|
||||
* simply not read the other one. Nothing rejected it, so the *unmodified* chord
|
||||
* matched: on a Mac `⌃←` produced `arrowleft` and nudged the selection, `⌃⌫`
|
||||
* deleted it, `⌃F` opened the device picker — each of them also swallowing the
|
||||
* OS gesture, because a match always calls `preventDefault`.
|
||||
*
|
||||
* These press the off-platform modifier alone, which the old fixture could not
|
||||
* express: `mod` set `ctrlKey` and `metaKey` together on every platform.
|
||||
*/
|
||||
describe("the off-platform modifier", () => {
|
||||
it("does not fire a bare-key command", () => {
|
||||
const run = vi.fn();
|
||||
bind({ id: "frame.add", run });
|
||||
|
||||
press(document.body, "f", { [OFF_PLATFORM]: true });
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire a destructive bare-key command", () => {
|
||||
const run = vi.fn();
|
||||
bind({ id: "element.delete", run });
|
||||
|
||||
press(document.body, "Backspace", { [OFF_PLATFORM]: true });
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the keystroke to the OS rather than swallowing it", () => {
|
||||
// The half that is not about the wrong command running. `⌃←` is Mission
|
||||
// Control on a Mac; consuming it is a bug even when nothing else fires.
|
||||
bind({ id: "element.nudge", run: () => undefined });
|
||||
|
||||
const e = press(document.body, "ArrowLeft", { [OFF_PLATFORM]: true });
|
||||
|
||||
expect(e.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fire a mod-chord command when both modifiers are down", () => {
|
||||
const run = vi.fn();
|
||||
bind({ id: "history.undo", run });
|
||||
|
||||
press(document.body, "z", { mod: true, [OFF_PLATFORM]: true });
|
||||
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still fires the command when only the platform modifier is down", () => {
|
||||
// The guard rejects one specific extra modifier, and nothing else.
|
||||
const run = vi.fn();
|
||||
bind({ id: "history.undo", run });
|
||||
|
||||
press(document.body, "z", { mod: true });
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* One command, one spelling, whichever surface is asking.
|
||||
*
|
||||
* `chords` (the sheet, the palette) and `gen-controls.mjs` (CONTROLS.md, the
|
||||
* README) both prefer `display` over the literal chord, and both prefer
|
||||
* `primary` over the full key list. `hint` — the tooltip chip — read `keys[0]`
|
||||
* and did neither, so the bar's `?` button carried a `⇧/` chip while the sheet
|
||||
* it opens said `?`. Exactly the drift the catalog exists to end, one layer in.
|
||||
*/
|
||||
describe("keys.hint", () => {
|
||||
it("prefers a display override over the literal chord", () => {
|
||||
// `help.shortcuts` is bound to shift+/ and mod+/ and displays as "?".
|
||||
expect(keys.hint("help.shortcuts")).toBe("?");
|
||||
expect(keys.chords("help.shortcuts")).toEqual(["?"]);
|
||||
});
|
||||
|
||||
it("agrees with what the sheet shows, for every command", () => {
|
||||
// The invariant, rather than a list: a chip is the first of the chords the
|
||||
// panel would show, always. Nothing may spell one its own way.
|
||||
for (const spec of ALL_COMMANDS) {
|
||||
expect(keys.hint(spec.id)).toBe(keys.chords(spec.id)[0] ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
it("still renders a plain command from its chord", () => {
|
||||
expect(keys.hint("history.undo")).toBe(
|
||||
PLATFORM === "mac" ? "⌘Z" : "Ctrl+Z"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* What a binding carries is `run`, `when` and `within`. The chord, the name and
|
||||
* everything a reader needs comes from `./catalog`, keyed by a typed `id`. That
|
||||
* split is what lets the shortcuts panel, the command palette and
|
||||
* `CONTROLS.md` all be generated from the same table the runtime uses, and what
|
||||
* makes a mistyped shortcut a compile error instead of a dead key.
|
||||
*
|
||||
* 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. See the note in `catalog.ts`.
|
||||
*/
|
||||
import { PREFIX } from "../dom";
|
||||
import {
|
||||
ALL_COMMANDS,
|
||||
type ChordPlatform,
|
||||
type Command,
|
||||
type CommandId,
|
||||
commandSpec,
|
||||
displayChord,
|
||||
} from "./catalog";
|
||||
|
||||
/**
|
||||
* True on Apple platforms, where `mod` means ⌘ rather than Ctrl.
|
||||
*
|
||||
* `userAgentData.platform` first: `navigator.platform` is deprecated and frozen
|
||||
* on some engines. Both are read defensively because this module is imported by
|
||||
* tests running under happy-dom, where either may be absent.
|
||||
*/
|
||||
const APPLE_PLATFORM = /mac|iphone|ipad/i;
|
||||
|
||||
function detectMac(): boolean {
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const nav = navigator as Navigator & {
|
||||
userAgentData?: { platform?: string };
|
||||
};
|
||||
const source =
|
||||
nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent ?? "";
|
||||
return APPLE_PLATFORM.test(source);
|
||||
}
|
||||
|
||||
const IS_MAC = detectMac();
|
||||
|
||||
/** The glyph set this browser should be shown chords in. */
|
||||
export const PLATFORM: ChordPlatform = IS_MAC ? "mac" : "pc";
|
||||
|
||||
/** `KeyboardEvent.code` for the layout-independent digit and letter rows. */
|
||||
const DIGIT_CODE = /^Digit(\d)$/;
|
||||
const LETTER_CODE = /^Key([A-Z])$/;
|
||||
|
||||
/**
|
||||
* Codes whose physical identity matters more than the character they produce.
|
||||
*
|
||||
* `Slash` is here for `?` (the shortcuts panel), and the two numpad keys because
|
||||
* a numeric keypad's `+` and `−` are the ones a lot of people reach for to zoom
|
||||
* and they arrive under names nothing else would match.
|
||||
*/
|
||||
const CODE_KEYS: Readonly<Record<string, string>> = {
|
||||
Equal: "=",
|
||||
Minus: "-",
|
||||
NumpadAdd: "numpadadd",
|
||||
NumpadSubtract: "numpadsubtract",
|
||||
Slash: "/",
|
||||
Space: "space",
|
||||
};
|
||||
|
||||
/** Text-entry input types. Everything else in an `<input>` 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 `<select>`'s arrows change the option, a range's arrows move the thumb, a
|
||||
* checkbox's space toggles it. Suppressing a shortcut for these is right; doing
|
||||
* it for Escape or a ⌘-chord is not, which is why this is a separate question
|
||||
* from `isTypingTarget` rather than a wider version of it.
|
||||
*/
|
||||
const NATIVE_KEYS = new Set([
|
||||
"arrowdown",
|
||||
"arrowleft",
|
||||
"arrowright",
|
||||
"arrowup",
|
||||
"end",
|
||||
"enter",
|
||||
"home",
|
||||
"space",
|
||||
]);
|
||||
|
||||
export interface Binding {
|
||||
/** Which command this implements. The chord comes from the catalog. */
|
||||
readonly id: CommandId;
|
||||
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;
|
||||
}
|
||||
|
||||
/** A command that would fire right now, as the palette lists it. */
|
||||
export interface LiveCommand {
|
||||
readonly run: () => void;
|
||||
readonly spec: Command;
|
||||
}
|
||||
|
||||
/**
|
||||
* The deepest node the event actually came from.
|
||||
*
|
||||
* `e.target` is retargeted to the shadow *host* for an event that crossed a
|
||||
* shadow boundary, so a real `<input>` 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
|
||||
* `<select>` still gets its own arrow keys.
|
||||
*/
|
||||
export function isNativeKeyTarget(target: EventTarget | null): boolean {
|
||||
const node = target as HTMLElement | null;
|
||||
if (!node?.tagName) {
|
||||
return false;
|
||||
}
|
||||
const tag = node.tagName.toLowerCase();
|
||||
if (tag === "select" || tag === "option") {
|
||||
return true;
|
||||
}
|
||||
return tag === "input";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
return CODE_KEYS[e.code] ?? e.key.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* `"mod+shift+z"` — the canonical form both sides of the match agree on, or `""`
|
||||
* for a keystroke no binding may claim.
|
||||
*
|
||||
* `mod` is Command on a Mac and Control everywhere else, which leaves the *other*
|
||||
* one with no place in the vocabulary. Ignoring it rather than rejecting it is
|
||||
* what made `⌃←` on a Mac produce the bare chord `arrowleft`: the registry ran
|
||||
* `element.nudge` and, because a match always `preventDefault`s, swallowed the
|
||||
* Mission Control gesture on the way. `⌃⌫` deleted the selection and `⌃F` opened
|
||||
* the device picker for the same reason.
|
||||
*
|
||||
* There is no chord in the catalog that wants the off-platform modifier, so an
|
||||
* empty string is the whole answer: it matches nothing and leaves the keystroke
|
||||
* to the OS and the page.
|
||||
*/
|
||||
function chordOf(e: KeyboardEvent): string {
|
||||
if (IS_MAC ? e.ctrlKey : e.metaKey) {
|
||||
return "";
|
||||
}
|
||||
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("+");
|
||||
}
|
||||
|
||||
/** A chord with no modifiers, whose key a native control might want. */
|
||||
function isBareNativeChord(chord: string): boolean {
|
||||
return NATIVE_KEYS.has(chord);
|
||||
}
|
||||
|
||||
/** Scoped beats modal beats normal. Lower wins. */
|
||||
function rankOf(binding: Binding): number {
|
||||
if (binding.within) {
|
||||
return 0;
|
||||
}
|
||||
return commandSpec(binding.id).priority === "modal" ? 1 : 2;
|
||||
}
|
||||
|
||||
/** What one keystroke needs to know about where it came from. */
|
||||
interface Origin {
|
||||
chord: string;
|
||||
/** From a frame's own document rather than the shell's. */
|
||||
foreign: boolean;
|
||||
inPopover: boolean;
|
||||
native: boolean;
|
||||
target: Node | null;
|
||||
typing: boolean;
|
||||
}
|
||||
|
||||
export class Keys {
|
||||
/** Newest first, so a later binding shadows an earlier one at the same rank. */
|
||||
private readonly bindings: Binding[] = [];
|
||||
private listening = false;
|
||||
/**
|
||||
* Frame documents this registry also listens in.
|
||||
*
|
||||
* Weak: `shell-app.ts` prunes a dead frame's subscriptions precisely so its
|
||||
* realm can be collected, and a strong `Set` here would have pinned every
|
||||
* document a session ever mounted. Membership is only used for idempotence;
|
||||
* the listeners themselves are released through the disposer `observe`
|
||||
* returns.
|
||||
*/
|
||||
private readonly observed = new WeakSet<Document>();
|
||||
/**
|
||||
* One release per live `observe()`, so `destroy()` can let go of frame
|
||||
* documents it has no other way to enumerate — a `WeakSet` cannot be walked,
|
||||
* and holding the documents in a real `Set` to make it walkable would pin
|
||||
* every frame the overlay ever showed.
|
||||
*/
|
||||
private readonly unobserve: (() => void)[] = [];
|
||||
/**
|
||||
* Torn down. Checked on every keystroke rather than trusted to listener
|
||||
* removal, because a frame that navigated away takes its document — and any
|
||||
* chance of removing a listener from it — with it.
|
||||
*/
|
||||
private dead = false;
|
||||
|
||||
/** Register a binding. Returns a disposer. */
|
||||
bind(binding: Binding): () => void {
|
||||
this.bindings.unshift(binding);
|
||||
this.dead = false;
|
||||
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 primary chord for a command, as a tooltip chip shows it.
|
||||
*
|
||||
* Same three-step preference as `chords` and as `gen-controls.mjs`, and for
|
||||
* the same reason: a `display` override exists precisely because the literal
|
||||
* chord reads badly, so a surface that ignores it puts the spelling the
|
||||
* override was written to replace back on screen. This read `keys[0]`
|
||||
* directly, so the bar's ? button showed a ⇧/ chip while the sheet it opens,
|
||||
* the palette, `CONTROLS.md` and the README all said `?` — the one command
|
||||
* with both a `display` and a control naming it.
|
||||
*
|
||||
* `primary` before `keys` for the same reason `chords` prefers it: zoom's
|
||||
* first bound chord is not the one you would teach someone.
|
||||
*/
|
||||
hint(id: CommandId): string | null {
|
||||
const spec = commandSpec(id);
|
||||
if (spec.display) {
|
||||
return spec.display;
|
||||
}
|
||||
const [first] = spec.primary ?? spec.keys;
|
||||
return first ? displayChord(first, PLATFORM) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every chord a command answers to.
|
||||
*
|
||||
* The panel shows all of them. `hint` shows one, which is why Redo used to
|
||||
* advertise only ⌘⇧Z and Zoom-to-100% only ⌘0 — the very spelling the README
|
||||
* documented, ⇧0, was invisible in the product.
|
||||
*/
|
||||
chords(id: CommandId): string[] {
|
||||
const spec = commandSpec(id);
|
||||
if (spec.display) {
|
||||
return [spec.display];
|
||||
}
|
||||
// `primary` where one is declared: zoom answers to six real keystrokes and
|
||||
// a panel row showing all six is noise, not thoroughness.
|
||||
return (spec.primary ?? spec.keys).map((k) => displayChord(k, PLATFORM));
|
||||
}
|
||||
|
||||
/** Is this command bound at all right now? */
|
||||
isBound(id: CommandId): boolean {
|
||||
return this.bindings.some((b) => b.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every command that would fire if its chord were pressed now.
|
||||
*
|
||||
* The palette's input, and the reason no mode enum was needed: the `when`
|
||||
* closures are pure predicates that each subsystem already maintains, so
|
||||
* asking all of them at once *is* the live answer. A command nothing bound —
|
||||
* the zoom set on the inline surface, where there is no viewport — is simply
|
||||
* absent, which is correct rather than something to filter.
|
||||
*
|
||||
* Catalog order, so the palette's grouping matches the panel's.
|
||||
*/
|
||||
available(): LiveCommand[] {
|
||||
const out: LiveCommand[] = [];
|
||||
for (const spec of ALL_COMMANDS) {
|
||||
if (spec.hidden) {
|
||||
continue;
|
||||
}
|
||||
const hit = this.bindings.find(
|
||||
(b) => b.id === spec.id && !b.within && b.when?.() !== false
|
||||
);
|
||||
if (hit) {
|
||||
out.push({ run: () => hit.run(syntheticEvent()), spec });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Run a command by id. False if nothing is bound, or its guard declines. */
|
||||
run(id: CommandId): boolean {
|
||||
const hit = this.bindings.find(
|
||||
(b) => b.id === id && b.when?.() !== false && !b.within
|
||||
);
|
||||
if (!hit) {
|
||||
return false;
|
||||
}
|
||||
hit.run(syntheticEvent());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chords that more than one *currently bound* command would answer to.
|
||||
*
|
||||
* A diagnostic, surfaced in the shortcuts panel rather than logged: this
|
||||
* module ships inside somebody else's page and has no business writing to
|
||||
* their console. `catalog.test.ts` asks the same question statically, where
|
||||
* it can fail a build; this one catches a pair that only overlaps once both
|
||||
* are actually bound.
|
||||
*/
|
||||
conflicts(): { chord: string; ids: CommandId[] }[] {
|
||||
const byChord = new Map<string, Set<CommandId>>();
|
||||
for (const b of this.bindings) {
|
||||
const spec = commandSpec(b.id);
|
||||
// Guards included, which is the difference between a diagnostic and
|
||||
// noise. Several pairs share a chord on purpose and are told apart by
|
||||
// mutually exclusive `when`s — Escape is Deselect in edit mode and Put
|
||||
// the Hand down in view mode, and `element.delete`/`frame.delete` are the
|
||||
// same arrangement on ⌫. Those are the design, not a clash.
|
||||
if (b.within || spec.scoped || b.when?.() === false) {
|
||||
continue;
|
||||
}
|
||||
for (const chord of spec.keys) {
|
||||
const seen = byChord.get(chord) ?? new Set<CommandId>();
|
||||
seen.add(b.id);
|
||||
byChord.set(chord, seen);
|
||||
}
|
||||
}
|
||||
const out: { chord: string; ids: CommandId[] }[] = [];
|
||||
for (const [chord, ids] of byChord) {
|
||||
if (ids.size > 1) {
|
||||
out.push({ chord, ids: [...ids] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Only commands marked `inFrame` are routed from here. Focus reaches a frame
|
||||
* in view mode, where the page is the user's and they may be typing into it —
|
||||
* so Escape and the zoom keys are welcome and `f`, `h`, `v` and `i` would be
|
||||
* theft. The filter is in `originFor`, not here, because a shell keystroke
|
||||
* must still reach everything.
|
||||
*
|
||||
* 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);
|
||||
this.dead = false;
|
||||
doc.addEventListener("keydown", this.onKeyDown, true);
|
||||
const release = (): void => {
|
||||
this.observed.delete(doc);
|
||||
doc.removeEventListener("keydown", this.onKeyDown, true);
|
||||
const at = this.unobserve.indexOf(release);
|
||||
if (at !== -1) {
|
||||
this.unobserve.splice(at, 1);
|
||||
}
|
||||
};
|
||||
this.unobserve.push(release);
|
||||
return release;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every listener and forget every binding.
|
||||
*
|
||||
* 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 {
|
||||
this.dead = true;
|
||||
this.bindings.length = 0;
|
||||
// Every observed frame document, not just `document`. These came off in
|
||||
// practice only because `AirshipApp.destroy` happens to run `stage.destroy`
|
||||
// — which disposes the frame subscriptions — before it gets here, an
|
||||
// ordering dependency nothing stated and nothing enforced. Each release also
|
||||
// drops the document from `observed`, which matters as much as removing the
|
||||
// listener: `observe()` hands back a no-op for a `Document` it already
|
||||
// holds, so a stale entry would make re-observing that frame never
|
||||
// re-attach. `splice(0)` because each release splices itself out.
|
||||
for (const release of this.unobserve.splice(0)) {
|
||||
release();
|
||||
}
|
||||
if (this.listening) {
|
||||
document.removeEventListener("keydown", this.onKeyDown, true);
|
||||
this.listening = false;
|
||||
}
|
||||
}
|
||||
|
||||
private eligible(b: Binding, o: Origin): boolean {
|
||||
const spec = commandSpec(b.id);
|
||||
if (o.typing && !spec.allowWhileTyping) {
|
||||
return false;
|
||||
}
|
||||
// A scoped binding never fires outside the subtree it belongs to...
|
||||
if (b.within && !b.within.contains(o.target)) {
|
||||
return false;
|
||||
}
|
||||
// ...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 (o.inPopover && !b.within) {
|
||||
return false;
|
||||
}
|
||||
// A keystroke from inside a live frame belongs to the user's own page
|
||||
// unless the command explicitly says otherwise.
|
||||
if (o.foreign && !spec.inFrame) {
|
||||
return false;
|
||||
}
|
||||
// A native control keeps the bare keys it implements itself.
|
||||
if (o.native && isBareNativeChord(o.chord) && !b.within) {
|
||||
return false;
|
||||
}
|
||||
if (!spec.keys.includes(o.chord)) {
|
||||
return false;
|
||||
}
|
||||
return b.when?.() !== false;
|
||||
}
|
||||
|
||||
private readonly onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (this.dead) {
|
||||
return;
|
||||
}
|
||||
// Mid-composition, every keystroke belongs to the IME — including ⌘↵, which
|
||||
// is the Enter that commits a candidate rather than the one that sends a
|
||||
// message. This is the single case `allowWhileTyping` must not punch
|
||||
// through, which is why it is here and not folded into `eligible`.
|
||||
if (e.isComposing || e.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
// No binding declares the empty chord, so this only saves a pass over the
|
||||
// table — but it is also where the off-platform modifier stops, and that is
|
||||
// worth being able to point at.
|
||||
const chord = chordOf(e);
|
||||
if (!chord) {
|
||||
return;
|
||||
}
|
||||
const origin = originOf(e);
|
||||
const node = origin as Node | null;
|
||||
const o: Origin = {
|
||||
chord,
|
||||
foreign: Boolean(node?.ownerDocument && node.ownerDocument !== document),
|
||||
inPopover: isInsidePopover(origin),
|
||||
native: isNativeKeyTarget(origin),
|
||||
target: node,
|
||||
typing: isTypingTarget(origin, e),
|
||||
};
|
||||
|
||||
// One pass, keeping the lowest rank seen. `bindings` is newest-first and
|
||||
// the comparison is strict, so within a rank the newest still wins — but
|
||||
// across ranks a scoped or modal binding now beats an older global one on
|
||||
// merit rather than on the order two constructors happened to run in.
|
||||
let best: Binding | null = null;
|
||||
let bestRank = Number.POSITIVE_INFINITY;
|
||||
for (const b of this.bindings) {
|
||||
if (!this.eligible(b, o)) {
|
||||
continue;
|
||||
}
|
||||
const rank = rankOf(b);
|
||||
if (rank < bestRank) {
|
||||
best = b;
|
||||
bestRank = rank;
|
||||
}
|
||||
}
|
||||
if (!best) {
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
best.run(e);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in for the keystroke a palette invocation never had.
|
||||
*
|
||||
* `Binding.run` takes the event because two commands read it — the nudge pair
|
||||
* derive their axis from `e.key`. Those are `hidden` in the catalog and so are
|
||||
* never reachable from the palette, but the signature has to be honest for the
|
||||
* ones that are.
|
||||
*/
|
||||
function syntheticEvent(): KeyboardEvent {
|
||||
return new KeyboardEvent("keydown");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
/**
|
||||
* Tooltip attributes for a control, with its shortcut chip.
|
||||
*
|
||||
* `data-tip` is the copy and `data-key` is the command, which is the whole
|
||||
* point: the chip used to be found by matching the tooltip's *text* against a
|
||||
* binding's label, so rewording a tooltip silently dropped its shortcut and
|
||||
* two commands could not share a name. Spread into `el()`.
|
||||
*/
|
||||
export function tip(text: string, id?: CommandId): Record<string, string> {
|
||||
return id ? { "data-key": id, "data-tip": text } : { "data-tip": text };
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { cls } from "../dom";
|
||||
import { mountPopoverHost } from "../popover-host";
|
||||
import { ALL_COMMANDS, ALL_GESTURES, COMMAND_GROUPS } from "./catalog";
|
||||
import { keys } from "./registry";
|
||||
import { closeShortcuts, openShortcuts } from "./shortcuts-panel";
|
||||
|
||||
/*
|
||||
* The shortcuts sheet.
|
||||
*
|
||||
* The property worth defending is the one it does *not* share with the
|
||||
* palette: this is a reference, so a command you cannot use right now is still
|
||||
* on it — dimmed, and labelled with why. Filtering it down to what is bound
|
||||
* would answer "what can I do this second", which the palette already answers,
|
||||
* and would make the sheet useless for the reason people open one.
|
||||
*/
|
||||
|
||||
const disposers: (() => void)[] = [];
|
||||
|
||||
function sheet(): HTMLElement {
|
||||
const found = document.querySelector<HTMLElement>(`.${cls("sc")}`);
|
||||
if (!found) {
|
||||
throw new Error("The shortcuts sheet is not open.");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const headings = (): string[] =>
|
||||
[...sheet().querySelectorAll(`.${cls("sc-head")}`)].map(
|
||||
(h) => h.textContent ?? ""
|
||||
);
|
||||
|
||||
function rowFor(title: string): HTMLElement {
|
||||
const found = [
|
||||
...sheet().querySelectorAll<HTMLElement>(`.${cls("sc-row")}`),
|
||||
].find((r) =>
|
||||
r.querySelector(`.${cls("sc-name")}`)?.textContent?.startsWith(title)
|
||||
);
|
||||
if (!found) {
|
||||
throw new Error(`No row titled ${title}`);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.replaceChildren();
|
||||
mountPopoverHost(document.body);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeShortcuts();
|
||||
for (const off of disposers.splice(0)) {
|
||||
off();
|
||||
}
|
||||
keys.destroy();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("what the sheet shows", () => {
|
||||
it("has a section for every group in the catalog", () => {
|
||||
openShortcuts();
|
||||
|
||||
const shown = headings();
|
||||
for (const group of COMMAND_GROUPS) {
|
||||
if (ALL_COMMANDS.some((c) => c.group === group)) {
|
||||
expect(shown).toContain(group);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("lists every command, bound or not", () => {
|
||||
openShortcuts();
|
||||
|
||||
const rows = sheet().querySelectorAll(`.${cls("sc-row")}`);
|
||||
// Every command, every gesture, and the field-local notes.
|
||||
expect(rows.length).toBeGreaterThanOrEqual(
|
||||
ALL_COMMANDS.length + ALL_GESTURES.length
|
||||
);
|
||||
});
|
||||
|
||||
it("dims an unbound row and says why", () => {
|
||||
openShortcuts();
|
||||
|
||||
const row = rowFor("Zoom to fit");
|
||||
|
||||
expect(row.classList.contains(cls("sc-row-off"))).toBe(true);
|
||||
expect(row.querySelector(`.${cls("sc-why")}`)?.textContent).toBe(
|
||||
"canvas only"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a bound row alone", () => {
|
||||
disposers.push(keys.bind({ id: "history.undo", run: () => undefined }));
|
||||
|
||||
openShortcuts();
|
||||
|
||||
const row = rowFor("Undo");
|
||||
expect(row.classList.contains(cls("sc-row-off"))).toBe(false);
|
||||
expect(row.querySelector(`.${cls("sc-why")}`)?.textContent).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows every chord a command answers to", () => {
|
||||
disposers.push(keys.bind({ id: "history.redo", run: () => undefined }));
|
||||
|
||||
openShortcuts();
|
||||
|
||||
// Redo answers to two, and a tooltip only ever showed the first. This is
|
||||
// the surface where ⌘Y and ⇧0 finally appear.
|
||||
expect(rowFor("Redo").querySelectorAll(`.${cls("sc-key")}`)).toHaveLength(
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the pointer gestures too", () => {
|
||||
openShortcuts();
|
||||
|
||||
expect(headings()).toContain("Mouse and trackpad");
|
||||
expect(rowFor("Pan the canvas")).toBeDefined();
|
||||
expect(rowFor("Scroll the pending changes")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders the field conventions that are not commands", () => {
|
||||
openShortcuts();
|
||||
|
||||
expect(headings()).toContain("In any field");
|
||||
});
|
||||
|
||||
it("stays quiet when nothing conflicts", () => {
|
||||
disposers.push(keys.bind({ id: "history.undo", run: () => undefined }));
|
||||
|
||||
openShortcuts();
|
||||
|
||||
expect(headings()).not.toContain("Conflicts");
|
||||
});
|
||||
|
||||
it("reports a real conflict rather than logging it", () => {
|
||||
// Two live, unscoped bindings on Escape. The overlay ships inside somebody
|
||||
// else's page, so this is shown, never written to their console.
|
||||
disposers.push(
|
||||
keys.bind({ id: "selection.deselect", run: () => undefined }),
|
||||
keys.bind({ id: "tool.handDrop", run: () => undefined })
|
||||
);
|
||||
|
||||
openShortcuts();
|
||||
|
||||
expect(headings()).toContain("Conflicts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet itself", () => {
|
||||
it("toggles on a second open", () => {
|
||||
openShortcuts();
|
||||
expect(document.querySelector(`.${cls("sc")}`)).not.toBeNull();
|
||||
|
||||
openShortcuts();
|
||||
expect(document.querySelector(`.${cls("sc")}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("describes itself as a modal dialog", () => {
|
||||
openShortcuts();
|
||||
|
||||
expect(sheet().getAttribute("role")).toBe("dialog");
|
||||
expect(sheet().getAttribute("aria-modal")).toBe("true");
|
||||
expect(sheet().getAttribute("aria-label")).toBe("Keyboard shortcuts");
|
||||
});
|
||||
|
||||
it("puts focus on the scroller, so it can be read without a mouse", () => {
|
||||
openShortcuts();
|
||||
|
||||
expect(document.activeElement).toBe(
|
||||
sheet().querySelector(`.${cls("sc-body")}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The keyboard-and-mouse reference, opened with `?`.
|
||||
*
|
||||
* Before this there was no discovery surface at all. Twenty-seven of the
|
||||
* thirty-three shortcuts appeared nowhere in the product and nowhere in the
|
||||
* READMEs, and the only way to find one was to hover a button whose tooltip
|
||||
* happened to name a binding — which left Nudge, Deselect, Duplicate, Edit
|
||||
* text, Zoom to 100%, Zoom to selection and every popover key invisible.
|
||||
*
|
||||
* Rendered from the **catalog**, not from `keys.available()`. That is the one
|
||||
* real design decision here and it is the opposite of the palette's: this is a
|
||||
* reference, so a row you cannot currently use still has to be on it, dimmed
|
||||
* and labelled with *why* — "edit mode", "canvas only". Hiding it would answer
|
||||
* "what can I do right now", which is a question the palette already answers
|
||||
* better, and would make the panel useless for the thing people open a
|
||||
* shortcuts sheet to do, which is learn what exists.
|
||||
*
|
||||
* Gestures share the sheet. They are the half of the input surface that has no
|
||||
* chord and no palette row, and the half the user was complaining about.
|
||||
*/
|
||||
import { cls, el } from "../dom";
|
||||
import { icon } from "../icons";
|
||||
import { openPopover, type PopoverHandle } from "../popover-host";
|
||||
import {
|
||||
ALL_COMMANDS,
|
||||
ALL_GESTURES,
|
||||
COMMAND_GROUPS,
|
||||
type Command,
|
||||
type GestureSpec,
|
||||
NOTES,
|
||||
} from "./catalog";
|
||||
import { keys } from "./registry";
|
||||
|
||||
let open: PopoverHandle | null = null;
|
||||
|
||||
export function closeShortcuts(): void {
|
||||
open?.close("programmatic");
|
||||
open = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a row is not live, or null if it is.
|
||||
*
|
||||
* Read off the catalog rather than from the binding table, because the answer
|
||||
* has to be a *sentence* — "canvas only" tells you to switch surfaces, while
|
||||
* "not bound" tells you nothing you can act on.
|
||||
*/
|
||||
function unavailable(spec: Command): string | null {
|
||||
if (keys.isBound(spec.id)) {
|
||||
return null;
|
||||
}
|
||||
// `where` first: a scoped command is live exactly while some particular
|
||||
// thing is on screen, which neither `mode` nor `surface` can express.
|
||||
if (spec.where) {
|
||||
return spec.where;
|
||||
}
|
||||
if (spec.surface === "canvas") {
|
||||
return "canvas only";
|
||||
}
|
||||
if (spec.surface === "inline") {
|
||||
return "inline only";
|
||||
}
|
||||
if (spec.mode === "edit") {
|
||||
return "edit mode";
|
||||
}
|
||||
if (spec.mode === "view") {
|
||||
return "view mode";
|
||||
}
|
||||
return "not available here";
|
||||
}
|
||||
|
||||
function chordChips(chords: string[]): HTMLElement {
|
||||
return el(
|
||||
"span",
|
||||
{ class: cls("sc-keys") },
|
||||
chords.map((c) => el("kbd", { class: cls("sc-key"), text: c }))
|
||||
);
|
||||
}
|
||||
|
||||
function commandRow(spec: Command): HTMLElement {
|
||||
const why = unavailable(spec);
|
||||
const row = el("div", { class: cls("sc-row") }, [
|
||||
el("span", { class: cls("sc-name") }, [
|
||||
el("span", { text: spec.title }),
|
||||
why ? el("span", { class: cls("sc-why"), text: why }) : el("span"),
|
||||
]),
|
||||
chordChips(keys.chords(spec.id)),
|
||||
]);
|
||||
row.classList.toggle(cls("sc-row-off"), Boolean(why));
|
||||
return row;
|
||||
}
|
||||
|
||||
function gestureRow(spec: GestureSpec): HTMLElement {
|
||||
// The device only when it is *a* device. Every gesture that works on both
|
||||
// rendered the literal word "any" next to its name, which is fourteen rows of
|
||||
// noise saying nothing; the two that are mouse-only are the ones a trackpad
|
||||
// user wants flagged, and they still are.
|
||||
const note = spec.device === "any" ? null : `${spec.device} only`;
|
||||
return el("div", { class: cls("sc-row") }, [
|
||||
el("span", { class: cls("sc-name") }, [
|
||||
el("span", { text: spec.title }),
|
||||
note ? el("span", { class: cls("sc-why"), text: note }) : el("span"),
|
||||
]),
|
||||
chordChips([spec.input]),
|
||||
]);
|
||||
}
|
||||
|
||||
function section(title: string, rows: HTMLElement[]): HTMLElement {
|
||||
return el("section", { class: cls("sc-sect") }, [
|
||||
el("h3", { class: cls("sc-head"), text: title }),
|
||||
...rows,
|
||||
]);
|
||||
}
|
||||
|
||||
export function openShortcuts(): void {
|
||||
if (open) {
|
||||
closeShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
const sections: HTMLElement[] = [];
|
||||
for (const group of COMMAND_GROUPS) {
|
||||
const rows = ALL_COMMANDS.filter((c) => c.group === group).map(commandRow);
|
||||
if (rows.length) {
|
||||
sections.push(section(group, rows));
|
||||
}
|
||||
}
|
||||
sections.push(section("Mouse and trackpad", ALL_GESTURES.map(gestureRow)));
|
||||
|
||||
// The field-local conventions, which are real and are not commands: they
|
||||
// belong to the input they commit, so there is nothing to bind and nothing a
|
||||
// palette could run. See the note in `catalog.ts`.
|
||||
sections.push(
|
||||
section(
|
||||
"In any field",
|
||||
NOTES.map((note) =>
|
||||
el("div", { class: cls("sc-row") }, [
|
||||
el("span", { class: cls("sc-name") }, [el("span", { text: note })]),
|
||||
])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// A diagnostic, shown rather than logged: this module ships inside somebody
|
||||
// else's page and has no business writing to their console. Empty in a
|
||||
// healthy editor, which is what `catalog.test.ts` asserts statically.
|
||||
const clashes = keys.conflicts();
|
||||
if (clashes.length) {
|
||||
sections.push(
|
||||
section(
|
||||
"Conflicts",
|
||||
clashes.map((c) =>
|
||||
el("div", { class: cls("sc-row") }, [
|
||||
el("span", { class: cls("sc-name") }, [
|
||||
el("span", { text: c.ids.join(", ") }),
|
||||
]),
|
||||
chordChips([c.chord]),
|
||||
])
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const card = el(
|
||||
"div",
|
||||
{
|
||||
"aria-label": "Keyboard shortcuts",
|
||||
"aria-modal": "true",
|
||||
class: cls("sc"),
|
||||
role: "dialog",
|
||||
},
|
||||
[
|
||||
el("div", { class: cls("sc-bar") }, [
|
||||
icon("keyboard", "sm"),
|
||||
el("span", { class: cls("sc-title"), text: "Shortcuts" }),
|
||||
]),
|
||||
el(
|
||||
"div",
|
||||
{ class: `${cls("sc-body")} ${cls("scroll-y")}`, tabindex: "0" },
|
||||
sections
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
open = openPopover({
|
||||
className: "pop-shortcuts",
|
||||
content: card,
|
||||
modal: true,
|
||||
onClose: () => {
|
||||
open = null;
|
||||
},
|
||||
roving: false,
|
||||
});
|
||||
// The body, not the card: it is the scroller, so it is what Page Down and the
|
||||
// arrows have to be inside for the sheet to be readable without a mouse.
|
||||
card.querySelector<HTMLElement>(`.${cls("sc-body")}`)?.focus();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { PREFIX } from "../dom";
|
||||
|
||||
/**
|
||||
* The two discovery surfaces: the ⌘K palette and the `?` shortcuts sheet.
|
||||
*
|
||||
* Both are `.pop-modal` shells (see `pop.css.ts`), so the card, the centring
|
||||
* and the scrim come from there and this file is only what is inside them.
|
||||
*
|
||||
* They deliberately do not look the same. The palette is a *control* — one
|
||||
* column, one active row, sized like the rest of the editor's chrome — and the
|
||||
* sheet is a *document*, so it reads in two columns at a comfortable measure
|
||||
* and nothing in it is selectable. Making them share a skin would have made the
|
||||
* sheet look operable, which is the one thing it is not.
|
||||
*/
|
||||
export const css = `
|
||||
/* -- Command palette ------------------------------------------------------ */
|
||||
|
||||
.${PREFIX}-palette { display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
/* The search row. A bottom hairline rather than a gap, so the results read as
|
||||
the field's own output rather than as a second list beside it. */
|
||||
.${PREFIX}-palette-head {
|
||||
flex: 0 0 auto; display: flex; align-items: center; gap: var(--ap-space-xs);
|
||||
padding: var(--ap-space-sm) var(--ap-space-base);
|
||||
border-bottom: 1px solid var(--ap-border-default);
|
||||
--${PREFIX}-ic-tone: var(--ap-text-tertiary);
|
||||
}
|
||||
.${PREFIX}-palette-field {
|
||||
flex: 1 1 auto; min-width: 0;
|
||||
background: transparent; border: 0; outline: none;
|
||||
color: var(--ap-text-primary); font-family: var(--ap-font-sans);
|
||||
font-size: var(--ap-font-size-heading); line-height: 1.4;
|
||||
}
|
||||
.${PREFIX}-palette-field::placeholder { color: var(--ap-text-placeholder); }
|
||||
|
||||
.${PREFIX}-palette-list {
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
padding: var(--ap-space-xxs) 0;
|
||||
}
|
||||
|
||||
/* A row is two lines: what it is, and what it does. The second line is what
|
||||
makes a palette teachable rather than a list of names you already know. */
|
||||
.${PREFIX}-palette-row {
|
||||
display: flex; align-items: center; gap: var(--ap-space-xs);
|
||||
padding: var(--ap-space-xs) var(--ap-space-base);
|
||||
cursor: pointer;
|
||||
}
|
||||
.${PREFIX}-palette-title {
|
||||
display: block; color: var(--ap-text-primary);
|
||||
font-size: var(--ap-font-size-title);
|
||||
}
|
||||
.${PREFIX}-palette-doc {
|
||||
display: block; color: var(--ap-text-tertiary);
|
||||
font-size: var(--ap-font-size-label);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The active row is a surface step, not an accent fill: the palette lists forty
|
||||
things and a saturated bar sliding through them on every keystroke is the
|
||||
same "two loud things at once" problem the change chips were demoted for.
|
||||
Driven by a class rather than \`:hover\`, because the keyboard moves it and
|
||||
the pointer must not fight the arrows for which row is current. */
|
||||
.${PREFIX}-palette-row-on { background: var(--ap-surface-hover); }
|
||||
.${PREFIX}-palette-row-on .${PREFIX}-palette-doc { color: var(--ap-text-secondary); }
|
||||
|
||||
.${PREFIX}-palette-empty {
|
||||
padding: var(--ap-space-base); text-align: center;
|
||||
color: var(--ap-text-tertiary); font-size: var(--ap-font-size-label);
|
||||
}
|
||||
|
||||
/* -- Shortcuts sheet ------------------------------------------------------ */
|
||||
|
||||
.${PREFIX}-sc { display: flex; flex-direction: column; min-height: 0; }
|
||||
.${PREFIX}-sc-bar {
|
||||
flex: 0 0 auto; display: flex; align-items: center; gap: var(--ap-space-xs);
|
||||
padding: var(--ap-space-sm) var(--ap-space-base);
|
||||
border-bottom: 1px solid var(--ap-border-default);
|
||||
--${PREFIX}-ic-tone: var(--ap-text-secondary);
|
||||
}
|
||||
.${PREFIX}-sc-title {
|
||||
color: var(--ap-text-primary); font-size: var(--ap-font-size-heading);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Two columns, because this is a reference and a single column of fifty rows is
|
||||
a scroll rather than a map. \`break-inside: avoid\` keeps a section's heading
|
||||
with its rows; without it a group splits across the fold and the heading is
|
||||
left advertising the wrong list. */
|
||||
.${PREFIX}-sc-body {
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
padding: var(--ap-space-base);
|
||||
columns: 2; column-gap: var(--ap-space-xl);
|
||||
outline: none;
|
||||
}
|
||||
.${PREFIX}-sc-sect { break-inside: avoid; margin-bottom: var(--ap-space-base); }
|
||||
.${PREFIX}-sc-head {
|
||||
margin: 0 0 var(--ap-space-xxs);
|
||||
color: var(--ap-text-tertiary);
|
||||
font-size: var(--ap-font-size-caption); font-weight: 500;
|
||||
text-transform: uppercase; letter-spacing: .06em;
|
||||
}
|
||||
.${PREFIX}-sc-row {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
gap: var(--ap-space-sm);
|
||||
padding: 3px 0;
|
||||
font-size: var(--ap-font-size-label);
|
||||
}
|
||||
.${PREFIX}-sc-name {
|
||||
min-width: 0; display: flex; align-items: baseline; gap: 6px;
|
||||
color: var(--ap-text-primary);
|
||||
}
|
||||
|
||||
/* Why a row is not live right now. The whole argument for the sheet showing
|
||||
unavailable rows at all is that this says what to do about it. */
|
||||
.${PREFIX}-sc-why {
|
||||
color: var(--ap-text-tertiary); font-size: var(--ap-font-size-caption);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.${PREFIX}-sc-row-off .${PREFIX}-sc-name { color: var(--ap-text-tertiary); }
|
||||
.${PREFIX}-sc-row-off .${PREFIX}-sc-key { opacity: .5; }
|
||||
|
||||
.${PREFIX}-sc-keys {
|
||||
flex: 0 0 auto; display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.${PREFIX}-sc-key {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 1px 5px;
|
||||
background: var(--ap-surface-hover);
|
||||
border: 1px solid var(--ap-border-subtle);
|
||||
border-radius: var(--ap-radius-xs);
|
||||
color: var(--ap-text-secondary);
|
||||
font-family: var(--ap-font-mono); font-size: var(--ap-font-size-caption);
|
||||
white-space: nowrap;
|
||||
}`;
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* The editor's one line of feedback.
|
||||
*
|
||||
* **A module singleton, not an injected dep.** `keys.ts` ("a singleton because
|
||||
* **A module singleton, not an injected dep.** `keys/registry.ts` ("a singleton because
|
||||
* the document has exactly one keyboard") and `dnd/manager.ts` are already
|
||||
* imported directly from everywhere, and a toast is the same shape of thing:
|
||||
* one document, one place feedback appears, one at a time. Threading an
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Descriptor, Group } from "./inspector/descriptors";
|
||||
// biome-ignore lint/performance/noNamespaceImport: the point is to see exports nobody listed here
|
||||
import * as descriptors from "./inspector/descriptors";
|
||||
import { ALL_COMMANDS } from "./keys/catalog";
|
||||
import { LABEL_MAX_CHARS } from "./styles/const";
|
||||
|
||||
/*
|
||||
* The tooltip copy standard, enforced.
|
||||
@@ -16,20 +21,28 @@ import { describe, expect, it } from "vitest";
|
||||
* on U+2014 only: U+2013 is the right glyph for `lines 12–18` and stays.
|
||||
*
|
||||
* **No keyboard marks in the prose.** A tooltip is two spans: `.tip-text` and,
|
||||
* when `keys.hintFor` finds a binding, a `.tip-key` chip built from `keys.ts`'s
|
||||
* `DISPLAY` map. Those marks belong in the chip. Two tips spelled them into the
|
||||
* sentence instead — `"Drag, or ↑↓ to restack"`, and `"Zoom to fit (⇧1)"`, which
|
||||
* also stopped the real chip resolving, because `hintFor` matches the whole tip
|
||||
* against a binding's label and no binding is called "Zoom to fit (⇧1)".
|
||||
*
|
||||
* **Shortcut chips.** `Tooltips.show` resolves a chord with `keys.hintFor(text)`
|
||||
* and `keys.hintFor` matches the binding whose *label* equals that string. So a
|
||||
* tooltip reworded from "Undo" to "Undo the last change" silently loses its ⌘Z —
|
||||
* nothing throws, nothing renders wrong, the chip is just gone. That is the one
|
||||
* failure in here that no amount of reading the diff would catch.
|
||||
* when the control names a command, a `.tip-key` chip rendered from the
|
||||
* catalog. Those marks belong in the chip. Two tips spelled them into the
|
||||
* sentence instead — `"Drag, or ↑↓ to restack"` and `"Zoom to fit (⇧1)"`.
|
||||
*
|
||||
* Scanned from source rather than asserted against a list, because the point is
|
||||
* to catch the tip somebody adds next year, not the ones fixed today.
|
||||
*
|
||||
* ## What used to be here
|
||||
*
|
||||
* Two more cases and two hand-maintained lists, `CHORD_LABELS` and `CHORD_TIPS`.
|
||||
* The chip was once resolved by comparing a tooltip's own *text* against a
|
||||
* binding's `label`, so rewording a tooltip silently dropped its shortcut —
|
||||
* nothing threw, nothing rendered wrong, the chip was just gone — and the lists
|
||||
* existed to freeze both spellings against that. They were a stopgap and they
|
||||
* leaked: `CHORD_LABELS` never listed "Delete frame", "Put the Hand down",
|
||||
* "Deselect", "Zoom to 100%", "Zoom to selection" or any popover label, and
|
||||
* `CHORD_TIPS` covered two of thirteen, because most controls build their tip
|
||||
* through a variable a scan cannot see.
|
||||
*
|
||||
* A control now names its command with `data-key`, which is a `CommandId`. The
|
||||
* compiler checks it, `keys/catalog.test.ts` checks that every declared command
|
||||
* is really bound, and the copy is free to change without anybody's permission.
|
||||
*/
|
||||
|
||||
/** One line at `TIP_MAX_W`, in characters. See the note on the constant. */
|
||||
@@ -40,7 +53,7 @@ const EM_DASH = "—";
|
||||
/**
|
||||
* Marks that belong in the shortcut chip, not in the sentence.
|
||||
*
|
||||
* `keys.ts`'s `DISPLAY` map, minus `←` and `→`. Those two are the exception on
|
||||
* `keys/catalog.ts`'s `DISPLAY_MAC` and `DISPLAY_PC` maps, minus `←` and `→`. Those two are the exception on
|
||||
* purpose: the change chips build `from → to` readouts, where the arrow is
|
||||
* notation rather than a key, and banning it would fail seven honest tips to
|
||||
* catch none. The vertical pair has no such second life.
|
||||
@@ -53,47 +66,9 @@ const EM_DASH = "—";
|
||||
*/
|
||||
const KEY_MARKS = /[↑↓⌘⇧⌥⌫⌦↩⏎␣]/u;
|
||||
|
||||
/**
|
||||
* Binding labels a tooltip can name, and so must keep spelling exactly.
|
||||
*
|
||||
* The chip is resolved by string equality between the tip and the binding's
|
||||
* `label`, so renaming *either* side alone drops it. These are the labels bound
|
||||
* in `app.ts`, `tools.ts` and `canvas/viewport.ts` that a control also advertises
|
||||
* as its tip. Renaming one is fine; renaming it here, at the `keys.bind`, and at
|
||||
* the control in the same commit is the whole requirement.
|
||||
*/
|
||||
const CHORD_LABELS = [
|
||||
"Undo",
|
||||
"Redo",
|
||||
"Delete",
|
||||
"Duplicate",
|
||||
"Edit text",
|
||||
"Add a frame",
|
||||
"Hand tool",
|
||||
"Send",
|
||||
"Move",
|
||||
"Inspect",
|
||||
"Zoom in",
|
||||
"Zoom out",
|
||||
"Zoom to fit",
|
||||
];
|
||||
|
||||
/**
|
||||
* The subset written out at a `data-tip` site, where this file can see them.
|
||||
*
|
||||
* Most controls get their tip through a variable — `iconButton(name, label)`,
|
||||
* `TOOLS.map(t => t.label)`, `spec.label` — so the string never appears next to
|
||||
* `data-tip` in the source and a scan cannot check it. These two do appear, and
|
||||
* they are the ones a copy pass is most likely to reach for and "improve".
|
||||
*/
|
||||
const CHORD_TIPS = ["Hand tool", "Send"];
|
||||
|
||||
/** Everything a comment can hold except the line breaks that keep it aligned. */
|
||||
const NON_NEWLINE = /[^\n]/g;
|
||||
|
||||
/** A `label: "…"` literal, wherever it is declared. */
|
||||
const LABEL_LITERAL = /label:\s*"([^"]+)"/g;
|
||||
|
||||
/**
|
||||
* Somewhere a tooltip's text is written, up to and including the assignment.
|
||||
*
|
||||
@@ -108,17 +83,23 @@ const LABEL_LITERAL = /label:\s*"([^"]+)"/g;
|
||||
* so without this the note itself — the half that actually carries the words — is
|
||||
* invisible to both the length and the dash check.
|
||||
*
|
||||
* Still outside its reach: `MODE_NOTE` in `descriptors.ts` and `PAINT_NOTE` in
|
||||
* `vector.ts`, two module constants whose names the lookbehind deliberately
|
||||
* excludes. Both are interpolated into tips and both are compliant today; a
|
||||
* regex loose enough to catch them also matches every `NOTE`-suffixed constant
|
||||
* in the tree, which is a worse trade than auditing two lines by hand.
|
||||
* The `*_NOTE` alternative used to be left out. The reasoning was that a regex
|
||||
* loose enough to catch `MODE_NOTE` and `PAINT_NOTE` would match every
|
||||
* `NOTE`-suffixed constant in the tree, and that auditing two lines by hand was
|
||||
* the better trade. The hand audit is what failed: a third constant,
|
||||
* `TRUNCATED_NOTE` in `vector.ts`, was added later carrying an em dash and this
|
||||
* file could not see it.
|
||||
*
|
||||
* Requiring SCREAMING_SNAKE and an `=` is what makes it affordable — it matches
|
||||
* a module constant declaring copy and not a `note` property, a `noteworthy`
|
||||
* identifier, or anything lowercase. The lookbehind alternative still handles
|
||||
* the `note:` object keys.
|
||||
*
|
||||
* The `\b` guard is what stops `"data-tip":` matching twice, and a `tip: string`
|
||||
* in an interface costs nothing: it has no string literal after it to collect.
|
||||
*/
|
||||
const TIP_SITE =
|
||||
/"data-tip"\s*:|\.dataset\.tip\s*=(?!=)|(?<![-\w.])(?:tip|note)\s*[:=](?![=:])/g;
|
||||
/"data-tip"\s*:|\.dataset\.tip\s*=(?!=)|\b[A-Z][A-Z0-9_]*_(?:NOTE|TIP)\s*=(?!=)|(?<![-\w.])(?:tip|note)\s*[:=](?![=:])/g;
|
||||
|
||||
/*
|
||||
* Resolved from the working directory, not from `import.meta.url`.
|
||||
@@ -347,27 +328,212 @@ describe("tooltip copy", () => {
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
expect(glyphed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the tips that name a keybinding alone", () => {
|
||||
const present = new Set(tips.map((t) => t.text));
|
||||
const lost = CHORD_TIPS.filter((label) => !present.has(label));
|
||||
expect(lost).toEqual([]);
|
||||
});
|
||||
/*
|
||||
* The label standard, enforced the same way.
|
||||
*
|
||||
* A rail label is not a tip and does not get the tip's budget. `TIP_MAX_CHARS`
|
||||
* is 44 because a tooltip is a floating box that sizes to its text;
|
||||
* `LABEL_MAX_CHARS` is 14 because the rail is a fixed column that neither
|
||||
* ellipsises nor clips, so the fifteenth character has nowhere to go but a
|
||||
* second line — which takes the row's height, and the control's, with it.
|
||||
*
|
||||
* The rule predates this case by a long way: `descriptors.ts` wrote it out in
|
||||
* prose, named the longest label then shipping, and was wrong about that within
|
||||
* a few commits. Three labels had passed 14 and one had reached 24, wrapping to
|
||||
* three lines in a group that was itself indented twice over. Prose cannot hold
|
||||
* a budget, which is the whole argument for this file.
|
||||
*
|
||||
* Read from the module rather than scanned out of it. Every descriptor is an
|
||||
* exported object or an exported factory, so importing them gets the real
|
||||
* `span`/`fieldIcon`/`fieldLabel` values that decide whether a label reaches a
|
||||
* rail at all — a regex would have to reimplement `fieldCell`'s routing and
|
||||
* would drift from it.
|
||||
*/
|
||||
|
||||
it("keeps the keymap spelling those tips the same way", () => {
|
||||
// The other half of the same contract. `hintFor` compares the tip to the
|
||||
// binding's `label`, so renaming the binding drops the chip just as surely
|
||||
// as renaming the tip — and from the keymap side the tooltip looks
|
||||
// untouched, which is why this is worth its own case.
|
||||
const declared = new Set<string>();
|
||||
for (const path of sourceFiles(SRC)) {
|
||||
for (const [, label] of readFileSync(path, "utf8").matchAll(
|
||||
LABEL_LITERAL
|
||||
)) {
|
||||
declared.add(label as string);
|
||||
/** Both axes and both orientations, so the factory descriptors are covered. */
|
||||
const FACTORY_DESCRIPTORS: Descriptor[] = [
|
||||
descriptors.GRID_GAP("row"),
|
||||
descriptors.GRID_GAP("column"),
|
||||
descriptors.LAYOUT_GAP(true),
|
||||
descriptors.LAYOUT_GAP(false),
|
||||
];
|
||||
|
||||
function isDescriptor(value: unknown): value is Descriptor {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
typeof (value as Descriptor).label === "string" &&
|
||||
typeof (value as Descriptor).cssProperty === "string" &&
|
||||
typeof (value as Descriptor).controlType === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function isGroup(value: unknown): value is Group {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Array.isArray((value as Group).descriptors)
|
||||
);
|
||||
}
|
||||
|
||||
/** Every descriptor the module exports, however it is packaged. */
|
||||
function allDescriptors(): Descriptor[] {
|
||||
const found = [...FACTORY_DESCRIPTORS];
|
||||
for (const value of Object.values(descriptors)) {
|
||||
if (isDescriptor(value)) {
|
||||
found.push(value);
|
||||
} else if (isGroup(value)) {
|
||||
found.push(...value.descriptors);
|
||||
} else if (Array.isArray(value)) {
|
||||
// Through `unknown[]`: the exported arrays are not all descriptors —
|
||||
// `STROKE_SIDES` is a table of icons — so the element type is a union
|
||||
// the predicate has to narrow rather than one it can just accept.
|
||||
found.push(...(value as unknown[]).filter(isDescriptor));
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this descriptor's label reach the 68px rail?
|
||||
*
|
||||
* `fieldCell` gives a descriptor a bare grid cell — no rail, label demoted to
|
||||
* an `aria-label` and a tip — when it carries its own `fieldIcon` or
|
||||
* `fieldLabel` *and* is not full width. Anything else falls through to
|
||||
* `labelled()`. Those glyph cells are exempt on purpose: several run past 20
|
||||
* characters and are correct, because the budget they answer to is the tip's.
|
||||
*/
|
||||
function onRail(d: Descriptor): boolean {
|
||||
return d.span === "full" || !(d.fieldIcon || d.fieldLabel);
|
||||
}
|
||||
|
||||
/** Somewhere a rail label is written as a literal at the call site. */
|
||||
const LABELLED_SITE = /\blabelled\(\s*(?=["'`])/g;
|
||||
|
||||
/** `file:line "text"`, matching `show` above. */
|
||||
interface RailLabel {
|
||||
file: string;
|
||||
line: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function literalRailLabels(): RailLabel[] {
|
||||
const labels: RailLabel[] = [];
|
||||
for (const path of sourceFiles(SRC)) {
|
||||
const src = stripComments(readFileSync(path, "utf8"));
|
||||
const file = path.slice(SRC.length);
|
||||
for (const match of src.matchAll(LABELLED_SITE)) {
|
||||
const at = (match.index as number) + match[0].length;
|
||||
const { text } = readString(src, at);
|
||||
if (text.trim()) {
|
||||
labels.push({
|
||||
file,
|
||||
line: src.slice(0, at).split("\n").length,
|
||||
text,
|
||||
});
|
||||
}
|
||||
}
|
||||
const lost = CHORD_LABELS.filter((label) => !declared.has(label));
|
||||
expect(lost).toEqual([]);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
describe("label copy", () => {
|
||||
const railed = allDescriptors().filter(onRail);
|
||||
const literals = literalRailLabels();
|
||||
|
||||
it("finds the labels to check", () => {
|
||||
// Same guard as the tips: a scanner matching nothing passes everything.
|
||||
// The literal floor is low because most call sites pass a variable — a
|
||||
// descriptor's `label`, or one from a local table. Those reach the rail
|
||||
// too, and `rail-label.test.ts` is what holds them, by reading the rows the
|
||||
// panel actually renders rather than the source that wrote them.
|
||||
expect(railed.length).toBeGreaterThan(10);
|
||||
expect(literals.length).toBeGreaterThan(8);
|
||||
});
|
||||
|
||||
it("keeps every descriptor label inside the rail", () => {
|
||||
const tooLong = railed
|
||||
.filter((d) => d.label.length > LABEL_MAX_CHARS)
|
||||
.map((d) => `${d.key} ${JSON.stringify(d.label)}`)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
expect(tooLong).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every literal row label inside the rail", () => {
|
||||
const tooLong = literals
|
||||
.filter((l) => l.text.length > LABEL_MAX_CHARS)
|
||||
.map((l) => `${l.file}:${l.line} ${JSON.stringify(l.text)}`)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
expect(tooLong).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses no em dashes in a label", () => {
|
||||
const dashed = [
|
||||
...railed.map((d) => d.label),
|
||||
...literals.map((l) => l.text),
|
||||
]
|
||||
.filter((text) => text.includes(EM_DASH))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
expect(dashed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
* A control whose tip names a command must name the command too.
|
||||
*
|
||||
* The chip used to be resolved from the tooltip's own *text*, matched against
|
||||
* the binding's label. When it moved to `data-key`, the controls that were never
|
||||
* migrated kept their bare `data-tip` and silently stopped rendering a chip —
|
||||
* Undo, Redo, Send, Hand tool and Add a frame, five of the most-used keys in the
|
||||
* product, advertised nowhere.
|
||||
*
|
||||
* Nothing failed, because the old guard was a hand-written list of spellings and
|
||||
* the new one only checks the ids that *are* declared. This checks the other
|
||||
* direction: a `data-tip` that spells a command's own title, with no `data-key`
|
||||
* beside it, is a control that should have gone through `tip()`.
|
||||
*/
|
||||
describe("shortcut chips", () => {
|
||||
/** Every `"data-tip": "literal"` written by hand, i.e. not built by `tip()`. */
|
||||
const BARE_TIP = /"data-tip"\s*:\s*"([^"]+)"/g;
|
||||
|
||||
const titles = new Map(
|
||||
ALL_COMMANDS.map((c) => [c.title.toLowerCase(), c.id] as const)
|
||||
);
|
||||
|
||||
const bare: { file: string; id: string; line: number; text: string }[] = [];
|
||||
for (const path of sourceFiles(SRC)) {
|
||||
// `tip()` itself writes the pair, and is the one site allowed to.
|
||||
if (path.endsWith(join("keys", "registry.ts"))) {
|
||||
continue;
|
||||
}
|
||||
const src = stripComments(readFileSync(path, "utf8"));
|
||||
for (const match of src.matchAll(BARE_TIP)) {
|
||||
const id = titles.get(match[1].toLowerCase());
|
||||
if (id) {
|
||||
bare.push({
|
||||
file: path.slice(SRC.length),
|
||||
id,
|
||||
line: src.slice(0, match.index as number).split("\n").length,
|
||||
text: match[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("finds the controls to check", () => {
|
||||
// The scan has to see the catalog and the sources, or the case below is
|
||||
// vacuous — which is exactly how the original regression got through.
|
||||
expect(titles.size).toBeGreaterThan(30);
|
||||
expect(sourceFiles(SRC).length).toBeGreaterThan(20);
|
||||
});
|
||||
|
||||
it("routes every command-titled tip through tip()", () => {
|
||||
const missing = bare
|
||||
.map((b) => `${b.file}:${b.line} "${b.text}" → tip(…, "${b.id}")`)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Meta, StoryObj } from "@storybook/html-vite";
|
||||
import { cls, el } from "./dom";
|
||||
import { type IconName, icon } from "./icons";
|
||||
import { keys } from "./keys";
|
||||
import type { CommandId } from "./keys/catalog";
|
||||
import { keys, tip } from "./keys/registry";
|
||||
import { popoverHost } from "./popover-host";
|
||||
import {
|
||||
type Caption,
|
||||
@@ -67,7 +68,7 @@ const DELAY = 450;
|
||||
* there, a tooltip anchored to any control inside a panel was drawn behind that
|
||||
* panel and never seen.
|
||||
*/
|
||||
function hover(canvasElement: HTMLElement, tip: string): Promise<void> {
|
||||
function hover(canvasElement: HTMLElement, text: string): Promise<void> {
|
||||
const host = popoverHost();
|
||||
if (!host) {
|
||||
throw new Error(
|
||||
@@ -83,9 +84,9 @@ function hover(canvasElement: HTMLElement, tip: string): Promise<void> {
|
||||
// throws. Reading the property back is the same lookup without the escaping.
|
||||
const target = [
|
||||
...canvasElement.querySelectorAll<HTMLElement>("[data-tip]"),
|
||||
].find((node) => node.dataset.tip === tip);
|
||||
].find((node) => node.dataset.tip === text);
|
||||
if (!target) {
|
||||
throw new Error(`No control in this story carries the tip ${tip}`);
|
||||
throw new Error(`No control in this story carries the tip ${text}`);
|
||||
}
|
||||
target.dispatchEvent(new PointerEvent("pointerover", { bubbles: true }));
|
||||
// `reject`, not a bare `throw`: an exception raised inside the timeout escapes
|
||||
@@ -141,7 +142,7 @@ function assertClamped(): void {
|
||||
function assertChipOnFirstLine(): void {
|
||||
const { key, node } = openTip();
|
||||
if (!key) {
|
||||
throw new Error("No chord chip rendered, so `hintFor` found no binding.");
|
||||
throw new Error("No chord chip rendered, so the control names no command.");
|
||||
}
|
||||
const box = node.getBoundingClientRect();
|
||||
const chip = key.getBoundingClientRect();
|
||||
@@ -193,15 +194,20 @@ function assertContained(host: HTMLElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
function tipButton(glyph: IconName, tip: string, extra = ""): HTMLElement {
|
||||
function tipButton(
|
||||
glyph: IconName,
|
||||
text: string,
|
||||
extra = "",
|
||||
id?: CommandId
|
||||
): HTMLElement {
|
||||
return el(
|
||||
"button",
|
||||
{
|
||||
"aria-label": tip,
|
||||
"aria-label": text,
|
||||
class: `${cls("action")} ${cls("action-icon")} ${extra}`.trim(),
|
||||
"data-tip": tip,
|
||||
onClick: noop,
|
||||
type: "button",
|
||||
...tip(text, id),
|
||||
},
|
||||
[icon(glyph, "sm")]
|
||||
);
|
||||
@@ -235,10 +241,12 @@ export const Grid: StoryObj = {
|
||||
/**
|
||||
* A tip that carries its shortcut.
|
||||
*
|
||||
* The whole reason this module exists rather than a `title` attribute.
|
||||
* `keys.hintFor(label)` looks the binding up by its *label*, so the tip and the
|
||||
* keymap cannot drift: a shortcut that is rebound changes here with no edit, and
|
||||
* one that is removed stops being advertised.
|
||||
* The whole reason this module exists rather than a `title` attribute. The chip
|
||||
* is resolved from the control's `data-key`, which is a `CommandId`, so the tip
|
||||
* and the keymap cannot drift: a shortcut that is rebound changes here with no
|
||||
* edit, one that is removed stops being advertised, and rewording the copy
|
||||
* leaves the chord alone. It used to be matched on the tooltip's own *text* —
|
||||
* elegant until the first rewording silently dropped a chip.
|
||||
*
|
||||
* The binding is registered by the story and its disposer handed to the
|
||||
* lifecycle registry — `keys` is a module singleton, so a story that bound
|
||||
@@ -247,25 +255,22 @@ export const Grid: StoryObj = {
|
||||
export const WithShortcut: StoryObj = {
|
||||
play: ({ canvasElement }) => hover(canvasElement, "Undo"),
|
||||
render: () => {
|
||||
onStoryTeardown(keys.bind({ keys: "mod+z", label: "Undo", run: noop }));
|
||||
onStoryTeardown(
|
||||
keys.bind({ keys: "mod+shift+z", label: "Redo", run: noop })
|
||||
);
|
||||
onStoryTeardown(keys.bind({ id: "history.undo", run: noop }));
|
||||
onStoryTeardown(keys.bind({ id: "history.redo", run: noop }));
|
||||
return plainStage(
|
||||
[
|
||||
el("div", { class: cls("bar"), style: "display: flex; gap: 4px;" }, [
|
||||
// Both `rotate-ccw`, as the bottom bar builds them: there is no
|
||||
// redo glyph, and `.bar-redo` mirrors the undo one.
|
||||
tipButton("rotate-ccw", "Undo"),
|
||||
tipButton("rotate-ccw", "Redo", cls("bar-redo")),
|
||||
// No binding registered for this one, so `hintFor` returns null and
|
||||
// the tip is text alone — which is the common case and has to look
|
||||
// deliberate rather than truncated.
|
||||
tipButton("rotate-ccw", "Undo", "", "history.undo"),
|
||||
tipButton("rotate-ccw", "Redo", cls("bar-redo"), "history.redo"),
|
||||
// No command named, so the tip is text alone — the common case, and
|
||||
// it has to look deliberate rather than truncated.
|
||||
tipButton("more", "More actions"),
|
||||
]),
|
||||
],
|
||||
{
|
||||
try: "compare Undo with More actions — the chord is looked up by label, so an unbound control shows text and nothing else",
|
||||
try: "compare Undo with More actions — the chord comes from the control's command, so one that names none shows text and nothing else",
|
||||
what: "A tooltip carrying its keyboard shortcut, which is the thing `title` cannot do.",
|
||||
}
|
||||
);
|
||||
@@ -416,12 +421,11 @@ export const WrappedWithShortcut: StoryObj = {
|
||||
play: ({ canvasElement }) =>
|
||||
hover(canvasElement, STACK_TIP).then(assertChipOnFirstLine),
|
||||
render: () => {
|
||||
// Bound by label, which is how `hintFor` finds it. The disposer goes to the
|
||||
// lifecycle registry: `keys` is a singleton, and a story that bound without
|
||||
// unbinding would leave a live shortcut in every story after it.
|
||||
onStoryTeardown(
|
||||
keys.bind({ keys: "mod+alt+f", label: STACK_TIP, run: noop })
|
||||
);
|
||||
// Any command with a long enough chord will do — what this story is about
|
||||
// is the wrap, not the shortcut. The disposer goes to the lifecycle
|
||||
// registry: `keys` is a singleton, and a story that bound without unbinding
|
||||
// would leave a live shortcut in every story after it.
|
||||
onStoryTeardown(keys.bind({ id: "history.redo", run: noop }));
|
||||
return plainStage(
|
||||
[
|
||||
dock(
|
||||
@@ -429,7 +433,7 @@ export const WrappedWithShortcut: StoryObj = {
|
||||
section(
|
||||
"Text",
|
||||
el("div", { style: "display: grid; gap: 6px;" }, [
|
||||
tipButton("style-text", STACK_TIP),
|
||||
tipButton("style-text", STACK_TIP, "", "history.redo"),
|
||||
])
|
||||
),
|
||||
])
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cls, el } from "./dom";
|
||||
import { sizeOf } from "./inspector/test-support";
|
||||
import { keys } from "./keys";
|
||||
import type { CommandId } from "./keys/catalog";
|
||||
// `tip` is taken here by the local helper that finds the rendered tip node.
|
||||
import { keys, tip as tipAttrs } from "./keys/registry";
|
||||
import { Tooltips } from "./tooltip";
|
||||
|
||||
/*
|
||||
@@ -17,9 +19,15 @@ import { Tooltips } from "./tooltip";
|
||||
* exists at all.
|
||||
*/
|
||||
|
||||
/** Bind a chord to `label`, undone after the test. `keys` is a singleton. */
|
||||
function bindChord(label: string): void {
|
||||
unbind.push(keys.bind({ keys: "mod+z", label, run: () => undefined }));
|
||||
/**
|
||||
* Make a command answerable, undone after the test. `keys` is a singleton.
|
||||
*
|
||||
* The chip does not actually need this any more — `keys.hint` reads the catalog
|
||||
* and does not care whether anything is bound — but leaving it in keeps the
|
||||
* case honest about the shape of the real thing.
|
||||
*/
|
||||
function bindChord(id: CommandId): void {
|
||||
unbind.push(keys.bind({ id, run: () => undefined }));
|
||||
}
|
||||
let unbind: Array<() => void> = [];
|
||||
|
||||
@@ -33,8 +41,12 @@ const tip = (): HTMLElement =>
|
||||
const shown = (): boolean => !tip().classList.contains(cls("hidden"));
|
||||
|
||||
/** A control carrying `data-tip`, placed in `parent`. */
|
||||
function control(text: string, parent: HTMLElement = document.body) {
|
||||
const node = el("button", { "data-tip": text, type: "button" });
|
||||
function control(
|
||||
text: string,
|
||||
parent: HTMLElement = document.body,
|
||||
id?: CommandId
|
||||
) {
|
||||
const node = el("button", { type: "button", ...tipAttrs(text, id) });
|
||||
parent.append(node);
|
||||
sizeOf(node, { height: 24, left: 100, top: 400, width: 24 });
|
||||
return node;
|
||||
@@ -142,9 +154,21 @@ describe("content", () => {
|
||||
expect(text?.textContent).toBe("Remove fill");
|
||||
});
|
||||
|
||||
it("appends the chord for a tip that names a binding", () => {
|
||||
bindChord("Undo");
|
||||
hover(control("Undo"));
|
||||
it("appends the chord for a control that names a command", () => {
|
||||
bindChord("history.undo");
|
||||
hover(control("Undo", document.body, "history.undo"));
|
||||
vi.advanceTimersByTime(400);
|
||||
|
||||
expect(tip().querySelector(`.${cls("tip-key")}`)?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the chord when the copy is reworded", () => {
|
||||
// The failure this whole redesign is about. The chip used to be found by
|
||||
// matching the tooltip's own text against a binding's label, so rewording
|
||||
// a tooltip dropped its shortcut silently — nothing threw, nothing rendered
|
||||
// wrong, the chip was simply gone.
|
||||
bindChord("history.undo");
|
||||
hover(control("Undo the last change", document.body, "history.undo"));
|
||||
vi.advanceTimersByTime(400);
|
||||
|
||||
expect(tip().querySelector(`.${cls("tip-key")}`)?.textContent).toBeTruthy();
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
* time.
|
||||
*/
|
||||
import { cls, el } from "./dom";
|
||||
import { keys } from "./keys";
|
||||
import type { CommandId } from "./keys/catalog";
|
||||
import { keys } from "./keys/registry";
|
||||
import { clamp } from "./num";
|
||||
import { placePopover, type Side } from "./popover";
|
||||
|
||||
@@ -132,10 +133,15 @@ export class Tooltips {
|
||||
// `pop.css.ts` targets, and a bare span would wrap without ever clamping.
|
||||
this.node.replaceChildren(el("span", { class: cls("tip-text"), text }));
|
||||
|
||||
// The shortcut is looked up by the tooltip's own text, so a control and its
|
||||
// binding agree by construction: `data-tip="Undo"` finds the binding
|
||||
// labelled "Undo" and nothing has to repeat the chord.
|
||||
const hint = keys.hintFor(text);
|
||||
// The shortcut is looked up by the control's `data-key`, which is a
|
||||
// `CommandId`. It used to be looked up by the tooltip's own *text* — elegant
|
||||
// right up until someone reworded a tooltip, at which point the chip
|
||||
// silently vanished with nothing failing, or until two commands wanted the
|
||||
// same name and one of them shadowed the other's chord. `tooltip.copy.test.ts`
|
||||
// existed to freeze thirteen spellings by hand against exactly that, and it
|
||||
// was missing four of them. The compiler holds this instead.
|
||||
const id = target.dataset.key as CommandId | undefined;
|
||||
const hint = id ? keys.hint(id) : null;
|
||||
if (hint) {
|
||||
this.node.append(el("span", { class: cls("tip-key"), text: hint }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Types for the two renderers `gen-controls.mjs` exports.
|
||||
*
|
||||
* Hand-written and deliberately structural. The script is plain JavaScript
|
||||
* because every generator in this repo is — it runs from a Makefile target and
|
||||
* a CI step, neither of which builds anything first — but
|
||||
* `packages/overlay/src/keys/controls-doc.test.ts` imports the same two
|
||||
* functions so that the drift gate and the writer cannot diverge, and that test
|
||||
* is TypeScript.
|
||||
*
|
||||
* The parameter shapes are the fields the renderers actually read, not the full
|
||||
* `CommandSpec` and `GestureSpec` — importing those from `packages/overlay`
|
||||
* would point a repo-root script at a workspace package's internals for no gain,
|
||||
* and the real definitions are checked where they live.
|
||||
*/
|
||||
|
||||
interface RenderedCommand {
|
||||
readonly display?: string;
|
||||
readonly doc: string;
|
||||
readonly essential?: boolean;
|
||||
readonly group: string;
|
||||
readonly keys: readonly string[];
|
||||
readonly mode: string;
|
||||
readonly primary?: readonly string[];
|
||||
readonly surface: string;
|
||||
readonly title: string;
|
||||
readonly where?: string;
|
||||
}
|
||||
|
||||
interface RenderedGesture {
|
||||
readonly doc: string;
|
||||
readonly essential?: boolean;
|
||||
readonly input: string;
|
||||
/** The Windows/Linux spelling, when it differs from `input`. */
|
||||
readonly inputPc?: string;
|
||||
readonly mode: string;
|
||||
readonly surface: string;
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
interface RenderInput {
|
||||
readonly commands: readonly RenderedCommand[];
|
||||
readonly displayChord: (chord: string, platform: "mac" | "pc") => string;
|
||||
readonly gestures: readonly RenderedGesture[];
|
||||
readonly groups: readonly string[];
|
||||
readonly notes: readonly string[];
|
||||
}
|
||||
|
||||
/** The whole of `CONTROLS.md`. */
|
||||
export function renderControls(input: RenderInput): string;
|
||||
|
||||
/** The short table between the markers in `README.md`. */
|
||||
export function renderEssentials(
|
||||
input: Omit<RenderInput, "groups" | "notes">
|
||||
): string;
|
||||
@@ -0,0 +1,244 @@
|
||||
// Generates CONTROLS.md, and the short table inside README.md, from the
|
||||
// overlay's command catalog.
|
||||
//
|
||||
// The catalog is the single source of truth for every shortcut and every
|
||||
// pointer gesture — the runtime binds from it, the shortcuts panel and the ⌘K
|
||||
// palette render from it, and this writes the documentation from the same
|
||||
// table. Before it existed the only reference 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. That is the failure mode this removes: not "the docs are wrong",
|
||||
// but "nothing could have told you".
|
||||
//
|
||||
// Usage:
|
||||
// node --experimental-strip-types scripts/gen-controls.mjs # write
|
||||
// node --experimental-strip-types scripts/gen-controls.mjs --check # verify
|
||||
//
|
||||
// The flag is required on Node 22.13–22.17 (the engines floor) and is an
|
||||
// accepted no-op after, so passing it unconditionally is portable. It is what
|
||||
// lets this .mjs `import` a .ts module directly; the only cost is that
|
||||
// catalog.ts may contain no *value* imports, which keys/catalog.test.ts
|
||||
// enforces. Type imports are erased before resolution and are free.
|
||||
//
|
||||
// Run before scripts/sync-readme.mjs — this writes into README.md, and that
|
||||
// copies README.md.
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// `new URL(..., import.meta.url)` throughout, and handed straight to `readFileSync`
|
||||
// / `writeFileSync`, which take a file URL. Nothing here ever converts one to a
|
||||
// path string, which is what sidesteps the `/C:/…` and percent-encoding traps
|
||||
// packages/overlay/scripts/check-css.mjs documents.
|
||||
const CATALOG = new URL(
|
||||
"../packages/overlay/src/keys/catalog.ts",
|
||||
import.meta.url
|
||||
);
|
||||
const CONTROLS = new URL("../CONTROLS.md", import.meta.url);
|
||||
const README = new URL("../README.md", import.meta.url);
|
||||
|
||||
const START = "<!-- controls:start -->";
|
||||
const END = "<!-- controls:end -->";
|
||||
|
||||
const BANNER =
|
||||
"<!-- Generated from packages/overlay/src/keys/catalog.ts by scripts/gen-controls.mjs. Do not edit. -->";
|
||||
|
||||
function die(message) {
|
||||
process.stderr.write(`gen-controls: ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** A markdown table cell that will not break the table it is in. */
|
||||
function cell(text) {
|
||||
return String(text).replace(/\|/g, "\\|");
|
||||
}
|
||||
|
||||
/** Chords as a reader sees them, joined for one platform. */
|
||||
function chordsFor(spec, displayChord, platform) {
|
||||
if (spec.display) {
|
||||
return spec.display;
|
||||
}
|
||||
return (spec.primary ?? spec.keys)
|
||||
.map((k) => displayChord(k, platform))
|
||||
.join(" or ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a command is available, in one phrase.
|
||||
*
|
||||
* Both halves matter and neither is obvious from the chord: a shortcut that
|
||||
* silently does nothing is the complaint this whole table answers, so the
|
||||
* reference has to say *when* rather than leaving you to find out.
|
||||
*/
|
||||
function scopeOf(spec) {
|
||||
// A scoped command's `where` beats both: it is live exactly while some
|
||||
// particular surface is up, which mode and surface cannot say.
|
||||
if (spec.where) {
|
||||
return spec.where;
|
||||
}
|
||||
const where = spec.surface === "both" ? "" : `${spec.surface} only`;
|
||||
const when = spec.mode === "any" ? "" : `${spec.mode} mode`;
|
||||
return [when, where].filter(Boolean).join(", ") || "anywhere";
|
||||
}
|
||||
|
||||
/** The full reference: every command, every gesture, both platforms. */
|
||||
export function renderControls({
|
||||
commands,
|
||||
gestures,
|
||||
notes,
|
||||
groups,
|
||||
displayChord,
|
||||
}) {
|
||||
const out = [
|
||||
BANNER,
|
||||
"",
|
||||
"# 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 `-`.",
|
||||
"",
|
||||
];
|
||||
|
||||
for (const group of groups) {
|
||||
const rows = commands.filter((c) => c.group === group);
|
||||
if (!rows.length) {
|
||||
continue;
|
||||
}
|
||||
out.push(`## ${group}`, "");
|
||||
out.push("| Command | macOS | Windows / Linux | Where | |");
|
||||
out.push("| --- | --- | --- | --- | --- |");
|
||||
for (const spec of rows) {
|
||||
out.push(
|
||||
`| ${cell(spec.title)} | ${cell(chordsFor(spec, displayChord, "mac"))} | ${cell(
|
||||
chordsFor(spec, displayChord, "pc")
|
||||
)} | ${cell(scopeOf(spec))} | ${cell(spec.doc)} |`
|
||||
);
|
||||
}
|
||||
out.push("");
|
||||
}
|
||||
|
||||
// Two input columns, like the command tables above. Only two gestures differ
|
||||
// between platforms, but both of those carry a modifier glyph — so a single
|
||||
// column was Mac-only exactly where it mattered.
|
||||
out.push("## Mouse and trackpad", "");
|
||||
out.push("| Gesture | macOS | Windows / Linux | Where | |");
|
||||
out.push("| --- | --- | --- | --- | --- |");
|
||||
for (const spec of gestures) {
|
||||
out.push(
|
||||
`| ${cell(spec.title)} | ${cell(spec.input)} | ${cell(
|
||||
spec.inputPc ?? spec.input
|
||||
)} | ${cell(scopeOf(spec))} | ${cell(spec.doc)} |`
|
||||
);
|
||||
}
|
||||
out.push("");
|
||||
|
||||
out.push("## In any field", "");
|
||||
for (const note of notes) {
|
||||
out.push(`- ${note}`);
|
||||
}
|
||||
out.push("");
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The short table for README.md — the `essential` subset only.
|
||||
*
|
||||
* A README is read once, by someone deciding whether to try this. The full
|
||||
* forty-row reference belongs behind a link.
|
||||
*/
|
||||
export function renderEssentials({ commands, gestures, displayChord }) {
|
||||
const out = ["| | macOS | Windows / Linux |", "| --- | --- | --- |"];
|
||||
for (const spec of gestures.filter((g) => g.essential)) {
|
||||
out.push(
|
||||
`| ${cell(spec.title.toLowerCase())} | ${cell(spec.input)} | ${cell(
|
||||
spec.inputPc ?? spec.input
|
||||
)} |`
|
||||
);
|
||||
}
|
||||
for (const spec of commands.filter((c) => c.essential)) {
|
||||
out.push(
|
||||
`| ${cell(spec.title.toLowerCase())} | ${cell(
|
||||
chordsFor(spec, displayChord, "mac")
|
||||
)} | ${cell(chordsFor(spec, displayChord, "pc"))} |`
|
||||
);
|
||||
}
|
||||
out.push("");
|
||||
out.push(
|
||||
"Press `?` in the editor for all of them, or see [CONTROLS.md](./CONTROLS.md)."
|
||||
);
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
/** Swap the text between the two markers, keeping everything around it. */
|
||||
function replaceBlock(source, block) {
|
||||
const from = source.indexOf(START);
|
||||
const to = source.indexOf(END);
|
||||
if (from === -1 || to === -1) {
|
||||
die(
|
||||
`README.md is missing the ${START} / ${END} markers that say where the controls table goes.`
|
||||
);
|
||||
}
|
||||
return `${source.slice(0, from + START.length)}\n${block}\n${source.slice(to)}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const catalog = await import(CATALOG.href);
|
||||
const shared = {
|
||||
commands: catalog.ALL_COMMANDS,
|
||||
displayChord: catalog.displayChord,
|
||||
gestures: catalog.ALL_GESTURES,
|
||||
groups: catalog.COMMAND_GROUPS,
|
||||
notes: catalog.NOTES,
|
||||
};
|
||||
const controls = renderControls(shared);
|
||||
const readme = replaceBlock(
|
||||
readFileSync(README, "utf8"),
|
||||
renderEssentials(shared)
|
||||
);
|
||||
|
||||
const check = process.argv.includes("--check");
|
||||
const stale = [];
|
||||
// `\r\n` normalised on both sides: the repo is checked out with native line
|
||||
// endings on Windows and this comparison is about content.
|
||||
const same = (a, b) => a.replace(/\r\n/g, "\n") === b.replace(/\r\n/g, "\n");
|
||||
|
||||
if (check) {
|
||||
let current = "";
|
||||
try {
|
||||
current = readFileSync(CONTROLS, "utf8");
|
||||
} catch {
|
||||
die("CONTROLS.md is missing — run `make controls`.");
|
||||
}
|
||||
if (!same(current, controls)) {
|
||||
stale.push("CONTROLS.md");
|
||||
}
|
||||
if (!same(readFileSync(README, "utf8"), readme)) {
|
||||
stale.push("README.md");
|
||||
}
|
||||
if (stale.length) {
|
||||
die(
|
||||
`${stale.join(" and ")} ${stale.length > 1 ? "are" : "is"} stale — run \`make controls\` and commit the result.`
|
||||
);
|
||||
}
|
||||
process.stdout.write("CONTROLS.md and README.md are up to date\n");
|
||||
return;
|
||||
}
|
||||
|
||||
writeFileSync(CONTROLS, controls);
|
||||
writeFileSync(README, readme);
|
||||
process.stdout.write(
|
||||
`wrote CONTROLS.md — ${shared.commands.length} commands, ${shared.gestures.length} gestures\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Exported for keys/controls-doc.test.ts, which renders from the same functions
|
||||
// and byte-compares against the committed file — so there is one renderer, and
|
||||
// the drift gate holds even where this script cannot run.
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
await main();
|
||||
}
|
||||
Reference in New Issue
Block a user