Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6467e1f263 | ||
|
|
3e927d8d00 | ||
|
|
2193367570 |
+17
@@ -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
|
||||
|
||||
@@ -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++; };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user