/** * The lightbox's keyboard shortcuts, in one place (#473). * * Three things read this list: the `?` overlay the visitor sees, the help * section in the admin panel where the site owner finds out these keys exist * at all, and — since this commit — the viewer's own key handler. * * That last one is the point. The catalogue used to describe the keys while * the handler independently decided which ones it listened for, so a key added * to the viewer and forgotten here stayed exactly as undiscoverable as the * whole set was before, and nothing would have said so. Now a key reaches the * handler only by being declared here, and `LightboxAction` is a closed union * the handler switches over exhaustively: declaring an action the viewer does * not implement is a type error, not a silent gap. * * The labels stay out of here: they live in the dictionaries, and the two * dynamic ones (fullscreen flips, the slideshow names its speed) are resolved * by the viewer, which is the only place that knows the current state. */ /** What has to be true for a shortcut to do anything. */ export type ShortcutAvailability = /** Always present. */ | 'always' /** Journal entries hide the EXIF toggle, so `i` does nothing there. */ | 'exifPanel' /** Absent where the browser has no element fullscreen to give (iPhone). */ | 'fullscreen' /** Only where the album offers its originals. */ | 'download' /** Only where the photo on screen can be zoomed (#467). */ | 'zoom'; /** * What a key does. Closed on purpose: the viewer switches over this union * exhaustively, so adding a member without handling it fails to compile. */ export type LightboxAction = | 'close' | 'prev' | 'next' | 'info' | 'fullscreen' | 'slideshow' | 'copyLink' | 'download' | 'zoomIn' | 'zoomOut' | 'zoomReset' | 'shortcutList'; export interface ShortcutBinding { /** * How the key is written in the two lists. Arrows are stored as names rather * than glyphs so the list survives an editor that mangles them; both * renderers ask for the display form via `shortcutKeyLabel()`. */ display: string; /** * The exact `KeyboardEvent.key` values that trigger it, case variants * included. `?` is matched on the produced character, not the physical key: * Shift+/ on a US layout, Shift+ß on a German one. */ eventKeys: readonly string[]; action: LightboxAction; } export interface LightboxShortcut { bindings: readonly ShortcutBinding[]; /** Key into the `lightbox` dictionary section. */ labelKey: | 'shortcutNavigate' | 'shortcutInfo' | 'shortcutFullscreen' | 'shortcutSlideshow' | 'shortcutCopyLink' | 'shortcutDownload' | 'shortcutZoom' | 'shortcutZoomReset' | 'shortcutList' | 'shortcutClose'; availability: ShortcutAvailability; /** Shown in the admin help to explain when the key is absent. */ note?: string; } /** In the order they are worth learning. */ export const LIGHTBOX_SHORTCUTS: readonly LightboxShortcut[] = [ { // One row, two actions: the arrows are learnt together but do opposite // things, which is why a binding carries the action rather than the row. bindings: [ { display: 'ARROW_LEFT', eventKeys: ['ArrowLeft'], action: 'prev' }, { display: 'ARROW_RIGHT', eventKeys: ['ArrowRight'], action: 'next' }, ], labelKey: 'shortcutNavigate', availability: 'always', }, { bindings: [{ display: 'I', eventKeys: ['i', 'I'], action: 'info' }], labelKey: 'shortcutInfo', availability: 'exifPanel', note: 'Hidden where every EXIF group is switched off, and in journal entries.', }, { bindings: [{ display: 'F', eventKeys: ['f', 'F'], action: 'fullscreen' }], labelKey: 'shortcutFullscreen', availability: 'fullscreen', note: 'Absent on browsers with no element fullscreen, such as Safari on iPhone.', }, { bindings: [{ display: 'S', eventKeys: ['s', 'S'], action: 'slideshow' }], labelKey: 'shortcutSlideshow', availability: 'always', note: 'Cycles off, 3s, 5s, 10s and back to off. Any arrow or swipe stops it.', }, { bindings: [{ display: 'C', eventKeys: ['c', 'C'], action: 'copyLink' }], labelKey: 'shortcutCopyLink', availability: 'always', note: 'Copies a link to the photo on screen. Positional: reordering the album moves where it lands.', }, { bindings: [{ display: 'D', eventKeys: ['d', 'D'], action: 'download' }], labelKey: 'shortcutDownload', availability: 'download', note: 'Only for albums with download: true in gallery.yaml.', }, { // `=` is `+` without Shift on US and many other layouts; the numpad sends // `+` and `-` directly. Ctrl/Cmd with these stays the browser's own zoom: // the viewer only listens to bare keys. bindings: [ { display: '+', eventKeys: ['+', '='], action: 'zoomIn' }, { display: '-', eventKeys: ['-'], action: 'zoomOut' }, ], labelKey: 'shortcutZoom', availability: 'zoom', note: 'Only where zoom is on (settings, page or album) and the photo has a full-resolution source.', }, { bindings: [{ display: '0', eventKeys: ['0'], action: 'zoomReset' }], labelKey: 'shortcutZoomReset', availability: 'zoom', note: 'Back to the whole photo. Esc does the same before it closes the viewer.', }, { // `h` is the escape hatch for layouts where `?` is awkward — and the one // key someone guesses without having been told. bindings: [ { display: '?', eventKeys: ['?'], action: 'shortcutList' }, { display: 'H', eventKeys: ['h', 'H'], action: 'shortcutList' }, ], labelKey: 'shortcutList', availability: 'always', }, { bindings: [{ display: 'Esc', eventKeys: ['Escape'], action: 'close' }], labelKey: 'shortcutClose', availability: 'always', }, ]; /** * Arrow names to glyphs. Ordinary keys pass through, so both renderers can map * every display key through this without asking which kind it is. */ export function shortcutKeyLabel(key: string): string { if (key === 'ARROW_LEFT') return String.fromCharCode(0x2190); if (key === 'ARROW_RIGHT') return String.fromCharCode(0x2192); return key; } /** The keys of one shortcut, ready to render. */ export function shortcutDisplayKeys(shortcut: LightboxShortcut): string[] { return shortcut.bindings.map((binding) => shortcutKeyLabel(binding.display)); } const ACTION_BY_EVENT_KEY: ReadonlyMap = new Map( LIGHTBOX_SHORTCUTS.flatMap((shortcut) => shortcut.bindings.flatMap((binding) => binding.eventKeys.map((key) => [key, binding.action] as const), ), ), ); /** * What a `KeyboardEvent.key` should do, or null if the viewer does not bind it. * * This is the only route from a keypress to an action, so a key the catalogue * does not list cannot reach the viewer, and one it does list cannot be * silently missing from the help. */ export function lightboxActionFor(eventKey: string): LightboxAction | null { return ACTION_BY_EVENT_KEY.get(eventKey) ?? null; }