Files
immich-folio/lib/lightboxShortcuts.ts

197 lines
6.9 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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. */
2026-10-02 18:14:23 +02:00
| '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'
2026-10-02 18:14:23 +02:00
| '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'
2026-10-02 18:14:23 +02:00
| '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.',
},
2026-10-02 18:14:23 +02:00
{
// `=` 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<string, LightboxAction> = 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;
}