Compare commits

...
Author SHA1 Message Date
Vito Sansevero 6467e1f263 feat(viewer): single-key shortcuts for the toolbar's actions (#532)
+ and - zoom, 0 fits, R rotates, H flips, Space plays or pauses, F switches
the view, I toggles details, D downloads, Delete opens the review and ?
opens the shortcuts. Each key clicks the toolbar's own control, so an
unavailable one does nothing. The keys stay out of form fields, a focused
video, raw metadata and open dialogs, and Ctrl, Cmd or Alt turns them off.

The overlay lists them in groups and fits at 390 px (D170).
2026-10-30 12:26:44 -07:00
Vito Sansevero 3e927d8d00 test(viewer): cover the shortcuts overlay and arrows in the unit suite (#532) 2026-10-30 09:36:04 -07:00
Vito Sansevero 2193367570 fix(viewer): arrow keys navigate from anywhere; a shortcuts overlay (#532)
The viewer opens with focus on the close button, and the arrow handler
ignored targets outside the stage and toolbar and listened on the dialog,
so the keys did nothing until the user tabbed or clicked into the image.
It now listens on the document and declines only where the arrows belong
to what has focus.

Adds a command-icon button that lists the shortcuts in a nested modal; Esc
or its close button leaves the viewer open (D170).
2026-10-30 09:06:32 -07:00
12 changed files with 502 additions and 13 deletions
+17
View File
@@ -3992,6 +3992,23 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
defaults" ("Restore this browser’s display defaults, then save. Other settings are
kept.", button "Reset"), which drafts the gallery's defaults for the browser rows only.
The node search timeout shows the server's real value, not the sample 30. CSS under D97.
- **D170** (#532, maintainer request 2026-10-30, beyond the concept): every viewer has
`button#viewer-keys.icon-btn` right before the D156 view switch (accessible name and
`title` "Keyboard shortcuts", a `command` outline icon, hidden at ≤700px with the
switch). It opens `dialog#viewer-keys-dialog`, a modal built on demand inside the
viewer, so a closed overlay adds nothing to the concept comparisons but the button:
`.dialog-header` with × and the keys grouped as Move (← → Space), Image (+ - 0 R H),
View (F I ? Esc) and File (D, Delete where the viewer has Delete…), one column at
390px. Esc or × closes only the overlay and focus returns to the button; opening it
stops the slideshow. ← and → navigate from anywhere in the viewer, on `document` as the
legacy gallery does, unless a modifier is held, text is selected, another dialog is on
top, or focus is in a video, form field, `pre`, `summary`, or anything in the details
but a button or link. The other keys share that gate but allow Shift, and each clicks
the toolbar's own control, so an aria-disabled, missing or hidden control does nothing
(Rotate on a video, I when Centered, F at ≤700px, Delete without Delete…). `?` opens
the overlay directly. Delete only opens the review. Space on a focused button or link
activates it instead, and only + and - repeat. `workspace-viewer-keys.spec.mjs` pins
this.
- **D156** (#450, maintainer request 2026-10-28, beyond the concept): every viewer (the
shared `dialog#viewer-dialog` of Images, Library, History, collections and Inspect) has
`button#viewer-mode.icon-btn` in `.dialog-header`, right before ✕ (a direct child, so
+4
View File
@@ -627,6 +627,10 @@ test('viewer matches the concept shell and toolbar order',async({page,request})=
expect(modePaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-mode.icon-btn','button#viewer-mode.icon-btn > svg.icon','button#viewer-mode.icon-btn > svg.icon > path']);
expect(production.paths[production.paths.indexOf(modePaths[2])+1]).toBe('dialog#viewer-dialog > div.dialog-header > button#viewer-close');
production.paths=production.paths.filter(path=>!modePaths.includes(path));
// #532 D170: production's shortcuts button sits right before the view switch; the concept has none.
const keysPaths=production.paths.filter(path=>path.includes('button#viewer-keys'));
expect(keysPaths.map(path=>path.split(' > ').slice(2).join(' > '))).toEqual(['button#viewer-keys.icon-btn','button#viewer-keys.icon-btn > svg.icon','button#viewer-keys.icon-btn > svg.icon > path']);
production.paths=production.paths.filter(path=>!keysPaths.includes(path));
// #480 D159: the Images viewer's Delete… (`danger`) follows Download; the concept has none.
expect(production.toolbar[8].text).toBe('Delete…');
production.toolbar.splice(8,1);
@@ -764,6 +764,11 @@ test('Collections… in the viewer keeps the viewer until it closes (CUJ 7)', as
await page.getByRole('button', { name: 'Open landscape_01.png', exact: true }).click();
const viewer = page.locator('#viewer-dialog[open]');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// #532: the arrows move through the collection from the first moment.
await page.keyboard.press('ArrowRight');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_02.png');
await page.keyboard.press('ArrowLeft');
await expect(viewer.locator('#viewer-title')).toContainText('landscape_01.png');
// Review L7: no listing request while the viewer is open.
let listings = 0;
const countListing = request => { if (new URL(request.url()).pathname === '/prompt_manager/images/output') listings++; };
+2
View File
@@ -231,6 +231,8 @@ test('every ordered receipt remains selected through distinct current locations,
const loadedReads=outputReads;const view=receipts.nth(1).getByRole('button',{name:'View current file',exact:true});await view.click();
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
await expect(viewer).toContainText('1 of 2 loaded');await expect(viewer.getByRole('region',{name:'Negative prompt',exact:true})).not.toContainText('same visible negative');
// #532: the arrows work over the recorded run from the first moment.
await page.keyboard.press('ArrowRight');await expect(viewer).toContainText('2 of 2 loaded');await page.keyboard.press('ArrowLeft');await expect(viewer).toContainText('1 of 2 loaded');
await viewer.getByRole('button',{name:'Next media',exact:true}).click();await expect(viewer).toContainText('2 of 2 loaded');
await page.keyboard.press('Escape');await expect(viewer).toHaveCount(0);await expect(dialog(page)).toBeVisible();
await expect(receipts.nth(2)).toHaveAttribute('aria-current','true');await expect(receipts.nth(2).getByRole('button',{name:'View current file',exact:true})).toBeFocused();
+8
View File
@@ -90,6 +90,14 @@ test('queue shows persisted items and the nav count', async ({ page, request })
// The card's image opens the shared viewer.
await cards(page).nth(1).getByRole('button', { name: 'Open review_0001.png', exact: true }).click();
await expect(page.locator('#viewer-dialog')).toBeVisible();
// #532: the arrows move through the queue from the first moment, with focus still on ✕.
await expect(page.locator('#viewer-close')).toBeFocused();
const before = await page.locator('#viewer-dialog .media-position').textContent();
await page.keyboard.press('ArrowRight');
await expect(page.locator('#viewer-dialog .media-position')).not.toHaveText(before);
// This viewer has no Delete…, so the Delete key does nothing.
await page.keyboard.press('Delete');
await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog')).toHaveCount(0);
// Open activity.
@@ -0,0 +1,304 @@
// #532 (TESTING.md D170): ← and → move through the viewer's files wherever focus is, except where the arrows belong to
// what has focus; ⌘ beside the view switch lists the shortcuts in an overlay that closes on its own.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept, openProduction } from './concept-inventory.mjs';
test.use({ locale: 'en-US', timezoneId: 'UTC', reducedMotion: 'reduce' });
const CARDS = '.gallery-body > .image-grid > .media-card';
const viewer = page => page.locator('dialog#viewer-dialog[open]');
const position = page => viewer(page).locator('.media-position');
const keysButton = page => viewer(page).locator('.dialog-header > button#viewer-keys');
const ALL_KEYS = ['←', '→', 'Space', '+', '-', '0', 'R', 'H', 'F', 'I', '?', 'Esc', 'D', 'Delete'];
const overlay = page => page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true });
test.beforeEach(async ({ page, request, browserName }, testInfo) => {
await startCoverage(page, browserName);
testInfo.manifest = await seedConcept(request);
});
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
async function openImages(page, width = 1600) {
await page.setViewportSize({ width, height: 1100 });
await openProduction(page, 'images', 'dark');
await page.locator(CARDS).first().locator('.image-open').click();
await expect(viewer(page).locator('#viewer-details .panel-heading')).toBeVisible();
await expect(position(page)).toContainText('1 of ');
}
async function movesBothWays(page) {
await page.keyboard.press('ArrowRight');
await expect(position(page)).toContainText('2 of ');
await page.keyboard.press('ArrowLeft');
await expect(position(page)).toContainText('1 of ');
}
async function staysPut(page) {
const before = await position(page).textContent();
await page.keyboard.press('ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
}
test('the arrows move through the files as soon as the viewer opens, with focus on ✕', async ({ page }) => {
await openImages(page);
await expect(viewer(page).locator('#viewer-close')).toBeFocused();
await movesBothWays(page);
});
test('the arrows keep working from every header control and when focus falls to the page', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-mode').click();
await movesBothWays(page);
await viewer(page).locator('#viewer-mode').click();
// Header text is not focusable: the click leaves focus outside any control.
await viewer(page).locator('.dialog-header p').click();
await movesBothWays(page);
await page.evaluate(() => document.activeElement?.blur());
await movesBothWays(page);
});
test('the arrows move from a details button but stay with focused text and with modifier keys', async ({ page }) => {
await openImages(page);
await viewer(page).locator('#viewer-details button').first().focus();
await movesBothWays(page);
await viewer(page).locator('#viewer-details p.prompt-text').first().focus();
await staysPut(page);
await viewer(page).locator('#viewer-close').focus();
const before = await position(page).textContent();
await page.keyboard.press('Shift+ArrowRight');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(position(page)).toHaveText(before);
});
test('⌘ lists the shortcuts; Esc closes only the overlay and the arrows wait for it', async ({ page }) => {
await openImages(page);
await expect(keysButton(page)).toHaveAccessibleName('Keyboard shortcuts');
await expect(keysButton(page)).toHaveAttribute('title', 'Keyboard shortcuts');
await expect(keysButton(page).locator('svg.icon')).toHaveCount(1);
await expect(page.locator('#viewer-keys-dialog')).toHaveCount(0);
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
// A small dialog sized to its content, its title inset from the edge.
const box = await overlay(page).evaluate(node => {
const frame = node.getBoundingClientRect(), title = node.querySelector('h2').getBoundingClientRect();
return { height: frame.height, inset: title.left - frame.left };
});
expect(box.height).toBeLessThan(700);
expect(box.inset).toBeGreaterThan(8);
await staysPut(page);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await movesBothWays(page);
await page.keyboard.press('Escape');
await expect(viewer(page)).toHaveCount(0);
});
test('the overlay × closes only the overlay; closing the viewer never leaves one behind', async ({ page }) => {
await openImages(page);
await keysButton(page).click();
await overlay(page).getByRole('button', { name: 'Close keyboard shortcuts', exact: true }).click();
await expect(overlay(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
await expect(keysButton(page)).toBeFocused();
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.evaluate(() => { location.hash = '/overview'; });
await expect(page.locator('#viewer-dialog,#viewer-keys-dialog')).toHaveCount(0);
});
test('opening the shortcuts stops the slideshow', async ({ page }) => {
await openImages(page);
const play = viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
await play.click();
await expect(play).toHaveAttribute('aria-label', 'Pause slideshow');
await keysButton(page).click();
await expect(overlay(page)).toBeVisible();
await page.keyboard.press('Escape');
await expect(play).toHaveAttribute('aria-label', 'Play slideshow');
});
test('the shortcuts button hides at phone width, like the view switch', async ({ page }) => {
await openImages(page, 600);
await expect(keysButton(page)).toBeHidden();
await movesBothWays(page);
});
// ── #532: the toolbar's actions on single keys. Each key clicks the toolbar's own control, so the two cannot disagree.
const stage = page => viewer(page).locator('#viewer-stage');
const image = page => viewer(page).locator('.viewer-canvas img');
const control = (page, name) => viewer(page).getByRole('button', { name, exact: true });
const transform = page => image(page).evaluate(node => node.style.transform + '|' + node.style.width);
const playing = page => viewer(page).locator('#viewer-toolbar').getByRole('button', { name: /slideshow$/ });
const review = page => page.locator('dialog#modal[open]');
/** A key event a browser would keep for itself, or that repeats, sent straight to what has focus. */
const synth = (page, key, init = {}) => page.evaluate(([key, init]) => {
(document.activeElement || document.body).dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
}, [key, init]);
async function ready(page) {
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'false');
await stage(page).focus();
}
/** Nothing the keys can change has changed. */
const snapshot = async page => ({ position: await position(page).textContent(), transform: await transform(page),
play: await playing(page).getAttribute('aria-label'), classes: await viewer(page).getAttribute('class'),
details: await viewer(page).locator('#viewer-details').isVisible(), modals: await page.locator('dialog[open]').count() });
test('+ - 0 zoom and fit, R rotates, H flips', async ({ page }) => {
await openImages(page);await ready(page);
const fit = await transform(page);
await page.keyboard.press('+');
await expect.poll(() => transform(page)).not.toBe(fit);
const zoomed = await transform(page);
await page.keyboard.press('-');
await expect.poll(() => transform(page)).not.toBe(zoomed);
await page.keyboard.press('+');await page.keyboard.press('0');
await expect.poll(() => transform(page)).toBe(fit);
await page.keyboard.press('r');
await expect.poll(() => transform(page)).toContain('rotate(90deg)');
await page.keyboard.press('Shift+R');
await expect.poll(() => transform(page)).toContain('rotate(180deg)');
await page.keyboard.press('h');
await expect.poll(() => transform(page)).toContain('scaleX(-1)');
});
test('Space plays and pauses on the stage, and activates a focused button instead', async ({ page }) => {
await openImages(page);await ready(page);
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Pause slideshow');
await page.keyboard.press('Space');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await control(page, 'Next media').focus();
await page.keyboard.press('Space');
await expect(position(page)).toContainText('2 of ');
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
});
test('F switches the view, I shows and hides the details the toggle offers', async ({ page }) => {
await openImages(page);await ready(page);
const details = viewer(page).locator('#viewer-details');
// Centered without the collapse setting: the toolbar has no toggle, so I does nothing.
await page.keyboard.press('i');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('i');
await expect(details).toBeHidden();
await page.keyboard.press('i');
await expect(details).toBeVisible();
await page.keyboard.press('f');
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
test('F does nothing where the view switch is hidden; ? still opens the shortcuts, which fit at 390 px', async ({ page }) => {
await openImages(page, 390);await ready(page);
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
const fit = await overlay(page).evaluate(node => {
const box = node.getBoundingClientRect();
return { inside: box.left >= 0 && box.right <= innerWidth && box.top >= 0 && box.bottom <= innerHeight,
noSideScroll: node.scrollWidth <= node.clientWidth };
});
expect(fit).toEqual({ inside: true, noSideScroll: true });
expect(await overlay(page).locator('dt').allTextContents()).toEqual(ALL_KEYS);
await overlay(page).locator('dt').last().scrollIntoViewIfNeeded();
await expect(overlay(page).locator('dt').last()).toBeInViewport();
});
test('D downloads the original', async ({ page }) => {
await openImages(page);await ready(page);
const [download] = await Promise.all([page.waitForEvent('download'), page.keyboard.press('d')]);
expect(download.suggestedFilename()).toMatch(/\.(png|jpe?g|webp)$/);
});
test('Delete opens the review and never deletes; every viewer key waits while the review is open', async ({ page }) => {
const writes = [];
page.on('request', request => { if (request.method() !== 'GET' && !request.url().includes('/__test__/')) writes.push(request.url()); });
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
writes.length = 0;
await page.keyboard.press('Delete');
await expect(review(page)).toBeVisible();
// The review reads its preview; repeating the key must not ask again.
await page.waitForLoadState('networkidle');
const opened = writes.length;
for (const key of ['Delete', 'r', 'f', 'i', 'd', '?']) await page.keyboard.press(key);
// With focus fallen to the page, too.
await page.evaluate(() => document.activeElement?.blur());
for (const key of ['Delete', 'ArrowRight', 'r', ' ']) await page.keyboard.press(key === ' ' ? 'Space' : key);
await page.waitForLoadState('networkidle');
expect(writes.length).toBe(opened);
// Inside the review the arrows and Space are its own: they may choose a mode, which only reads a new preview.
await review(page).getByRole('radio').first().focus();
for (const key of ['ArrowRight', 'Space']) await page.keyboard.press(key);
await expect(overlay(page)).toHaveCount(0);
await page.keyboard.press('Escape');
await expect(review(page)).toHaveCount(0);
await expect(viewer(page)).toBeVisible();
expect(writes.filter(url => /delete|trash/i.test(url) && !url.endsWith('/delete/preview'))).toEqual([]);
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
});
test('the keys do nothing with Ctrl, Cmd or Alt held, on a repeat, or while the overlay is open', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
const downloads = [];page.on('download', download => downloads.push(download));
for (const modifier of ['ctrlKey', 'metaKey', 'altKey'])
for (const key of ['+', '-', '0', 'r', 'h', ' ', 'f', 'd', 'Delete', '?']) await synth(page, key, { [modifier]: true });
for (const key of [' ', 'r', 'h', 'f', 'd', 'Delete', '?']) await synth(page, key, { repeat: true });
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
expect(downloads).toEqual([]);
await page.keyboard.press('?');
await expect(overlay(page)).toBeVisible();
for (const key of ['r', 'h', 'f', 'i', '0', 'Delete']) await page.keyboard.press(key);
await page.keyboard.press('Escape');
await expect(overlay(page)).toHaveCount(0);
expect(await snapshot(page)).toEqual(before);
await expect(viewer(page)).toBeVisible();
});
test('the keys stay out of a form field, raw metadata and a disclosure', async ({ page }) => {
await openImages(page);await ready(page);
const before = await snapshot(page);
await viewer(page).locator('#viewer-details').evaluate(node => {
const field = document.createElement('input');field.id = 'probe';node.append(field);
});
await viewer(page).locator('#probe').focus();
await page.keyboard.type('rhfid0+-? ');
await expect(viewer(page).locator('#probe')).toHaveValue('rhfid0+-? ');
await viewer(page).locator('#probe').evaluate(node => node.remove());
const summary = viewer(page).getByText('Workflow & raw metadata', { exact: true });
await summary.focus();
for (const key of ['r', 'f', '0']) await page.keyboard.press(key);
await page.evaluate(() => new Promise(requestAnimationFrame));
expect(await snapshot(page)).toEqual(before);
});
test('on a video the image keys and Space do nothing, as the toolbar disables them; a focused video keeps its keys', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.setViewportSize({ width: 1600, height: 1100 });
await page.goto('/prompt_manager/workspace#/images');
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
const video = viewer(page).locator('video');
await expect(video).toHaveJSProperty('controls', true);
await expect(control(page, 'Zoom in')).toHaveAttribute('aria-disabled', 'true');
const errors = [];page.on('pageerror', error => errors.push(error.message));
await stage(page).focus();
for (const key of ['+', '-', '0', 'r', 'h', 'Space']) await page.keyboard.press(key);
await expect(playing(page)).toHaveAttribute('aria-label', 'Play slideshow');
await expect(viewer(page).locator('.viewer-canvas')).toHaveCount(0);
expect(errors).toEqual([]);
await video.focus();
await page.keyboard.press('f');
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(viewer(page)).not.toHaveClass(/\bviewer-fullscreen\b/);
});
+1 -1
View File
@@ -32,7 +32,7 @@ const frame = page => viewer(page).evaluate(node => {
test('the button beside ✕ switches between Centered and Full screen and names the view it switches to', async ({ page }) => {
await openImages(page);
const header = await viewer(page).locator('.dialog-header').evaluate(node => [...node.children].map(child => child.id || child.localName));
expect(header).toEqual(['div', 'viewer-mode', 'viewer-close']);
expect(header).toEqual(['div', 'viewer-keys', 'viewer-mode', 'viewer-close']);
await expect(modeButton(page)).toHaveAccessibleName('Full screen view');
await expect(modeButton(page)).toHaveAttribute('title', 'Full screen view');
await expect(modeButton(page).locator('svg.icon')).toHaveCount(1);
+3
View File
@@ -21,11 +21,14 @@ export function mountedDom() {
removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));}
fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});}
focus(){this.focused=true;}
click(){this.fire('click');}
getClientRects(){return this.isConnected&&!this.layoutHidden?[{}]:[];}
showModal(){this.open=true;}
close(){this.open=false;this.fire('close');}
contains(node){return node===this||this.children.some(c=>c.contains(node));}
closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;}
querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;}
querySelectorAll(selector){return walk(this,n=>n.tagName===selector);}
pause(){this.paused=true;}
load(){this.loaded=true;}
play(){this.played=true;return Promise.resolve();}
+80 -1
View File
@@ -200,7 +200,9 @@ test('viewer renders the concept shell and toolbar order', async () => {
assert.equal(dialog.attributes['aria-label'],undefined,'the title names the dialog');
const [header,layout] = dialog.children;
assert.equal(header.className,'dialog-header');
const [heading,mode,close] = header.children, [title,subtitle] = heading.children;
const [heading,keys,mode,close] = header.children, [title,subtitle] = heading.children;
// #532 D170: ⌘ before the view switch opens the shortcuts.
assert.deepEqual([keys.tagName,keys.id,keys.className,keys.attributes['aria-label'],keys.title],['button','viewer-keys','icon-btn','Keyboard shortcuts','Keyboard shortcuts']);
// #450 D156: the view switch before ✕ names the view it switches to (Centered is the stored default).
assert.deepEqual([mode.tagName,mode.id,mode.className,mode.attributes['aria-label'],mode.title],['button','viewer-mode','icon-btn','Full screen view','Full screen view']);
mode.fire('click');
@@ -233,6 +235,83 @@ test('viewer renders the concept shell and toolbar order', async () => {
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('⌘ opens the shortcuts on top of the viewer and closing them leaves it open (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, [keys] = f.walk(dialog,n => n.id === 'viewer-keys');
const position = () => f.walk(dialog,n => n.attributes.role === 'status')[0].textContent;
const overlays = () => f.walk(dialog,n => n.id === 'viewer-keys-dialog');
assert.equal(overlays().length,0,'built on demand');
assert.match(position(),/^1 of 2/);
f.doc.fire('keydown',{key:'ArrowRight',target:f.walk(dialog,n => n.id === 'viewer-close')[0]});
assert.match(position(),/^2 of 2/,'the arrows work with focus on ×');
keys.fire('click');
const [overlay] = overlays();
assert.equal(overlay.open,true);
assert.equal(overlay.attributes['aria-labelledby'],'viewer-keys-title');
assert.deepEqual(f.walk(overlay,n => n.tagName === 'kbd').map(n => n.textContent),['←','→','Space','+','-','0','R','H','F','I','?','Esc','D']);
f.doc.fire('keydown',{key:'ArrowLeft',target:keys});
assert.match(position(),/^2 of 2/,'the arrows wait for the overlay');
f.walk(overlay,n => n.attributes['aria-label'] === 'Close keyboard shortcuts')[0].fire('click');
assert.equal(overlays().length,0);
assert.equal(dialog.open,true,'the viewer stays open');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
});
test('hotkeys click the toolbar\'s own controls, so an unavailable control does nothing (#532)', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')], deletes = [];
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onDelete:(entry,button) => deletes.push([entry,button])});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [dialog] = f.container.children, by = (test) => f.walk(dialog,test)[0];
const stage = by(n => n.id === 'viewer-stage'), mode = by(n => n.id === 'viewer-mode'), remove = by(n => n.textContent === 'Delete…');
const press = (key,init = {}) => { let prevented = false; f.doc.fire('keydown',{key,target:stage,preventDefault(){prevented = true;},...init});return prevented; };
// No media is ready: every transform is aria-disabled, so its key is swallowed by the same check as a click.
for (const key of ['+','=','-','0','r','H']) assert.equal(press(key),true,key);
// Modifiers, repeats and composition leave every key alone; Shift does not.
for (const init of [{ctrlKey:true},{metaKey:true},{altKey:true},{repeat:true},{isComposing:true}]) assert.equal(press('f',init),false);
assert.equal(press('+',{repeat:true}),true,'zoom may repeat');
assert.equal(press('Unbound'),false);
// F clicks the switch, and does nothing while the switch is hidden.
assert.equal(mode.attributes['aria-label'],'Full screen view');
press('F',{shiftKey:true});assert.equal(mode.attributes['aria-label'],'Centered view');
mode.layoutHidden = true;assert.equal(press('f'),false);assert.equal(mode.attributes['aria-label'],'Centered view');mode.layoutHidden = false;
// I needs the toggle: it is there in full screen and gone when Centered.
const details = by(n => n.id === 'viewer-details');
press('i');assert.equal(details.hidden,true);press('i');assert.equal(details.hidden,false);
press('f');assert.equal(press('i'),false,'no toggle when Centered');
// Delete only opens the page's review, for this file.
press('Delete');assert.equal(deletes.length,1);assert.equal(deletes[0][1],remove);
press('Delete',{repeat:true});assert.equal(deletes.length,1);
// D goes through the link's own check while the file cannot be downloaded.
controller.publish({loading:false,error:false,record:null,refreshRequired:true,message:'Refresh required.'});
const download = by(n => n.attributes['aria-label'] === 'Download original');
let blocked = false;download.fire('click',{preventDefault(){blocked = true;}});assert.equal(blocked,true);
assert.equal(press('d'),true);
// Space and ? on the stage; a target that keeps its keys leaves them alone.
assert.equal(press(' '),true);
assert.equal(press('?'),true);
const overlay = by(n => n.id === 'viewer-keys-dialog');assert.equal(overlay.open,true);
assert.equal(press('r'),false,'off under the overlay');
overlay.close();
assert.equal(press('r',{target:new f.Node('body')}),false,'a target outside the viewer that is not the page');
} finally { view.dispose(); parentScope.dispose(); f.restore(); }
const g = mountedDom(), scope = createScope();
const plain = module.createMediaViewer({container:g.container,parentScope:scope,inspectionController:borrowedController(),adapter,sender:{open(){}}});
try {
plain.open({items,selectedKey:'c'.repeat(64)});
const stage = g.walk(g.container,n => n.id === 'viewer-stage')[0];
let prevented = false;g.doc.fire('keydown',{key:'Delete',target:stage,preventDefault(){prevented = true;}});
assert.equal(prevented,false,'no Delete… in this viewer, so no Delete key');
g.doc.fire('keydown',{key:'?',target:stage});
assert.ok(!g.walk(g.container,n => n.tagName === 'kbd').some(n => n.textContent === 'Delete'),'and none listed');
} finally { plain.dispose(); scope.dispose(); g.restore(); }
});
test('viewer titles a linked card "<title> · <filename>" before any inspection arrives', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController();
+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
@@ -190,10 +190,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 */ }