fix(viewer): arrow keys navigate from anywhere; a shortcuts overlay (#532) (#534)

Closes #532
This commit is contained in:
Vito Sansevero
2026-10-30 16:52:44 -07:00
parent 06ea91da66
commit 494a5d0bfa
12 changed files with 502 additions and 13 deletions
+3 -1
View File
@@ -24,7 +24,9 @@ const paths = Object.freeze({
"review": "M4 3h16v18H4zM8 7h8M8 12h8M8 17h5",
// #450 (D156): the viewer's Full screen / Centered switch.
"maximize": "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5",
"minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"
"minimize": "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5",
// #532 (D170): the viewer's keyboard shortcuts.
"command": "M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"
});
/** Create an approved decorative outline icon without injecting markup. */
+60 -8
View File
@@ -13,6 +13,13 @@ export function readViewerPreferences(getStorage = () => window.localStorage, no
const {videoAutoplay,videoMute,videoLoop} = readPreferences(getStorage,normalize).values;
return {videoAutoplay,videoMute,videoLoop};
}
// #532: where ← and → stay with what has focus (a video's seek, text carets, a disclosure, scrolling text).
const KEEPS_ARROWS = 'video,input,textarea,select,pre,summary,[contenteditable]';
const SHORTCUTS = [
['Move',[['←','Previous file'],['→','Next file'],['Space','Play or pause the slideshow']]],
['Image',[['+','Zoom in'],['-','Zoom out'],['0','Fit'],['R','Rotate'],['H','Flip']]],
['View',[['F','Full screen or centered'],['I','Show or hide details'],['?','These shortcuts'],['Esc','Close the viewer']]],
['File',[['D','Download the original'],['Delete','Delete…, which asks first']]]];
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -60,7 +67,10 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
// #450 (D156): the view switch before ✕ names the view it switches to; the stored default picks the first view.
let fullscreen = displayPreferences()?.viewerOpenMode === 'fullscreen';
const mode = createButton('','icon-btn');mode.id = 'viewer-mode';
header.append(heading,mode,close);
// #532 (D170): ⌘ before the view switch lists the viewer's shortcuts.
const keys = createButton('','icon-btn');keys.id = 'viewer-keys';keys.title = 'Keyboard shortcuts';
keys.setAttribute('aria-label','Keyboard shortcuts');keys.append(createWorkspaceIcon('command'));
header.append(heading,keys,mode,close);
const body = element('div','viewer-layout'), left = element('div','viewer-left');
const stage = element('div');stage.id = 'viewer-stage';stage.tabIndex = 0;
stage.setAttribute('role','region');stage.setAttribute('aria-label','Media preview');
@@ -83,8 +93,9 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
local.listen(download,'click',event => {session.stop();if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();});
controls.append(download);
// #480: Delete… (the Images viewer only) pauses the media, then opens the delete review for this file.
let remove = null;
if (onDelete) {
const remove = createButton('Delete…','danger');
remove = createButton('Delete…','danger');
local.listen(remove,'click',() => {
session.stop();
for (const video of dialog.querySelectorAll('video')) video.pause();
@@ -123,6 +134,29 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
}
local.listen(detailsToggle,'click',() => {details.hidden = !details.hidden;paintToggle();});
local.listen(mode,'click',() => {fullscreen = !fullscreen;paintMode();syncToggle();});
/** A modal inside the viewer: Esc closes only the top dialog, `close` and `cancel` do not bubble, focus returns to ⌘. */
function openShortcuts() {
session.stop();
const overlay = element('dialog');overlay.id = 'viewer-keys-dialog';overlay.setAttribute('aria-labelledby','viewer-keys-title');
const top = element('div','dialog-header'), name = element('h2',undefined,'Keyboard shortcuts');name.id = 'viewer-keys-title';
const dismiss = createButton('×');dismiss.setAttribute('aria-label','Close keyboard shortcuts');dismiss.autofocus = true;
top.append(name,dismiss);
const groups = element('div','viewer-keys-groups');
for (const [group,rows] of SHORTCUTS) {
const section = element('section'), list = element('dl');
for (const [key,action] of rows) {
if (key === 'Delete' && !remove) continue;
const row = element('div'), term = element('dt'), kbd = element('kbd',undefined,key);
term.append(kbd);row.append(term,element('dd',undefined,action));list.append(row);
}
section.append(element('h3','small muted',group),list);groups.append(section);
}
overlay.append(top,groups,element('p','muted small','A video player or text with focus keeps its keys. A key does nothing when its button is unavailable for this file.'));
local.listen(dismiss,'click',() => overlay.close());
local.listen(overlay,'close',() => overlay.remove());
dialog.append(overlay);overlay.showModal();
}
local.listen(keys,'click',openShortcuts);
paintMode();syncToggle();
body.append(left,details);dialog.append(header,body);container.append(dialog);
const localSender = {open(roles,trigger) {session.stop();sender.open(roles,trigger);}};
@@ -154,12 +188,30 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
local.listen(document,'visibilitychange',() => {if (document.hidden) session.stop();});
local.listen(window,'pagehide',() => closeViewer(false));
local.listen(retry,'click',() => {if (!inspectionState.refreshRequired) rebuild(entry);});
local.listen(dialog,'keydown',event => {
if (!['ArrowLeft','ArrowRight'].includes(event.key) || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey ||
(!stage.contains(event.target) && !controls.contains(event.target)) ||
event.target.closest('video,input,textarea,select,pre,summary,[contenteditable]') ||
!window.getSelection()?.isCollapsed || [...document.querySelectorAll('dialog[open]')].at(-1) !== dialog) return;
event.preventDefault();session.navigate(event.key === 'ArrowLeft' ? -1 : 1);
// #532: on the document, as the legacy gallery does: focus starts on ✕ and can fall to the page (a click on
// header text, a focused video replaced by navigating), where a key never reaches the dialog.
// A key clicks the toolbar's own control, whose handler checks `aria-disabled`, so the two cannot disagree; a
// control that is missing or hidden has no client rects. Only zoom repeats, so holding D downloads once.
const [zoomOut,zoomIn,fit,rotate,flip] = transforms;
const hotkeys = new Map([['+',zoomIn],['=',zoomIn],['-',zoomOut],['0',fit],['r',rotate],['h',flip],[' ',slideshow],
['f',mode],['i',detailsToggle],['d',download],['Delete',remove]]);
const repeats = new Set(['+','=','-']);
const keysAreOurs = ({target,altKey,ctrlKey,metaKey,isComposing}) => !altKey && !ctrlKey && !metaKey && !isComposing &&
(dialog.contains(target) || target === document.body || target === document.documentElement) &&
!target.closest(KEEPS_ARROWS) && (!details.contains(target) || target.closest('button,a')) &&
window.getSelection()?.isCollapsed && [...document.querySelectorAll('dialog[open]')].at(-1) === dialog;
local.listen(document,'keydown',event => {
if (!keysAreOurs(event)) return;
const {key,target,repeat,shiftKey} = event;
if (key === 'ArrowLeft' || key === 'ArrowRight') {
if (shiftKey) return;
event.preventDefault();session.navigate(key === 'ArrowLeft' ? -1 : 1);return;
}
if (repeat && !repeats.has(key)) return;
if (key === '?') { event.preventDefault();openShortcuts();return; }
const control = hotkeys.get(key.length === 1 ? key.toLowerCase() : key);
if (!control?.getClientRects().length || (key === ' ' && target.closest('button,a'))) return;
event.preventDefault();control.click();
});
function rebuild(selected) {
mediaScope?.dispose();mediaScope = createScope();viewer = null;
+15 -2
View File
@@ -206,10 +206,23 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* #450 D156: the view switch sits before ✕ (8px apart; the header's gap is the concept's 18px), and Full screen fills the
browser window edge to edge (no outer margin), beating the concept's box, D34's max-height and the ≤700px height.
At ≤700px the viewer is already full-bleed, so the switch hides there. */
#viewer-dialog .dialog-header > #viewer-mode { margin-inline-start: auto; margin-inline-end: -10px; }
#viewer-dialog .dialog-header > #viewer-mode { margin-inline-end: -10px; }
/* #532 D170: ⌘ sits before the view switch, 8px apart, and takes the header's free space. */
#viewer-dialog .dialog-header > #viewer-keys { margin-inline-start: auto; margin-inline-end: -10px; }
.ca-tools #viewer-dialog.viewer-fullscreen[open] { inset: 0; width: auto; height: auto; max-width: none; max-height: none; margin: 0; border: 0; border-radius: 0; }
/* `!important` only to beat the concept's `.icon-btn { display: inline-grid !important }`. */
@media (max-width: 700px) { #viewer-dialog .dialog-header > #viewer-mode { display: none !important; } }
@media (max-width: 700px) { #viewer-dialog .dialog-header > :is(#viewer-mode, #viewer-keys) { display: none !important; } }
/* #532 D170: the shortcuts overlay, a small dialog over the viewer. */
.ca-tools #viewer-keys-dialog { width: min(600px, calc(100vw - 32px)); height: fit-content; max-height: 90dvh; overflow: auto; }
#viewer-keys-dialog .viewer-keys-groups { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 18px 24px; margin-bottom: 14px; }
#viewer-keys-dialog h3 { margin: 0 0 8px; }
#viewer-keys-dialog .dialog-header > button { margin-inline-start: auto; }
#viewer-keys-dialog dl { display: grid; gap: 8px; margin: 0; }
#viewer-keys-dialog dl > div { display: flex; align-items: center; gap: 12px; }
#viewer-keys-dialog dt { flex: 0 0 64px; }
#viewer-keys-dialog dd { margin: 0; }
#viewer-keys-dialog kbd { display: inline-block; min-width: 28px; padding: 2px 8px; border: 1px solid var(--line); border-radius: 6px; font: inherit; text-align: center; }
#viewer-keys-dialog > p { margin: 0; }
#viewer-title { overflow-wrap: anywhere; /* D34 */ }
#viewer-dialog, #viewer-dialog::backdrop { overscroll-behavior: contain; }
#viewer-details { overscroll-behavior: contain; /* D34 */ }