diff --git a/tests/browser/full-inspector.spec.mjs b/tests/browser/full-inspector.spec.mjs index db44f20..8622732 100644 --- a/tests/browser/full-inspector.spec.mjs +++ b/tests/browser/full-inspector.spec.mjs @@ -232,5 +232,158 @@ test.describe('full inspector coarse pointer',()=>{ } await expect(panel.locator('.prompt-positive pre')).toHaveText(fullText); } + await panel.getByRole('button',{name:'Open full preview',exact:true}).click();const viewer=page.getByRole('dialog',{name:'Image preview',exact:true}); + await expect(viewer.locator('.viewer-canvas img')).toBeVisible(); + for(const target of await viewer.locator('.image-preview-controls > :is(button,a),.image-preview-header > button').all()) { + const box=await target.boundingBox();expect(box.width).toBeGreaterThanOrEqual(80);expect(box.height).toBeGreaterThanOrEqual(80); + } + const raw=viewer.getByText('Workflow & raw metadata',{exact:true});await raw.scrollIntoViewIfNeeded();await raw.focus();await expect(raw).toBeInViewport(); }); }); + +const mediaDialog=page=>page.getByRole('dialog',{name:'Image preview',exact:true}); +const mediaStage=page=>mediaDialog(page).getByRole('region',{name:'Media preview',exact:true}); +const position=page=>mediaDialog(page).locator('.image-preview-position'); +async function openCollection(page) { + await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click(); + await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible(); + await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); +} + +test('collection viewer follows filtered loaded order beyond mounted cards',async({page,request})=>{ + await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + await expect(await request.post('/__test__/reset',{data:{full_inspector:true,extra_images:200}})).toBeOK(); + await page.goto('/prompt_manager/workspace#/images'); + await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('0'); + await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector'); + await page.getByRole('combobox',{name:'Sort',exact:true}).selectOption('name'); + const more=page.getByRole('button',{name:'Load more images',exact:true}); + await expect(page.locator('.image-toolbar')).toContainText('40 of'); + for(const n of [80,120,160]) {await more.click();await expect(page.locator('.image-toolbar')).toContainText(`${n} of`);} + const listed=await(await request.get('/prompt_manager/images/output?mode=workspace&root=0&subfolder=inspector&sort=name&limit=200')).json(); + const loaded=listed.images.slice(0,160);expect(loaded).toHaveLength(160); + await page.evaluate(()=>scrollTo(0,0));await expect.poll(()=>page.locator('article[data-image-key]').count()).toBeLessThan(120); + let release,started;const held=new Promise(resolve=>{release=resolve;});const hit=new Promise(resolve=>{started=resolve;}); + await page.route('**/images/output?*',async route=>{started();await held;await route.continue();},{times:1}); + await more.click();await hit;await page.evaluate(()=>scrollTo(0,0)); + const opener=page.locator(`article[data-image-key="0:${loaded[0].path}"] .image-card-footer button`); + await opener.click();release();await expect(page.locator('.image-toolbar')).toContainText('200 of'); + const modal=mediaDialog(page);await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); + let pages=0;page.on('request',req=>{if(req.url().includes('/images/output?'))pages++;}); + await modal.getByRole('button',{name:'Previous media',exact:true}).click(); + await expect(position(page)).toHaveText(`160 of 160 loaded: ${loaded[159].filename}`); + await expect(modal.locator('.file-name')).toHaveText(loaded[159].filename); + await expect(modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',loaded[159].url); + await expect(page.locator(`article[data-image-key="0:${loaded[159].path}"]`)).toHaveCount(0); + await modal.getByRole('button',{name:'Next media',exact:true}).click();await expect(position(page)).toHaveText(`1 of 160 loaded: ${loaded[0].filename}`); + await mediaStage(page).focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(`2 of 160 loaded: ${loaded[1].filename}`); + expect(pages).toBe(0);await modal.getByRole('button',{name:'Close preview',exact:true}).click(); + await expect(opener).toBeFocused(); + await page.getByRole('combobox',{name:'Source',exact:true}).selectOption('1'); + await page.getByRole('combobox',{name:'Folder',exact:true}).selectOption('inspector'); + await selectFile(page,'complete.png',1);await openCollection(page);await expect(position(page)).toHaveText('1 of 1 loaded: complete.png'); + for(const name of ['Previous media','Next media'])await expect(mediaDialog(page).getByRole('button',{name,exact:true})).toHaveAttribute('aria-disabled','true'); + await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click(); + const library=await(await request.get('/prompt_manager/library')).json();const id=library.items.find(item=>item.text.startsWith('Inspector portrait')).id; + await page.goto(`/prompt_manager/workspace#/library/${id}`);await page.getByRole('button',{name:'Select negative variant 1',exact:true}).click();await selectFile(page,'complete.png');await openCollection(page); + await expect(position(page)).toContainText('of 1 loaded: complete.png'); + await mediaDialog(page).getByRole('button',{name:'Close preview',exact:true}).click(); + await page.getByRole('button',{name:'All images for this positive',exact:true}).click(); + await selectFile(page,'complete.png');await openCollection(page);await expect(position(page)).toContainText('of 2 loaded:'); +}); + +test('collection viewer uses real public transforms and resets on navigation',async({page,request})=>{ + await fullFixture(page,request); + await page.evaluate(()=>{ + window.viewerCalls=[];window.viewerInstances=[]; + const Native=window.Viewer; + for(const method of ['zoom','reset','rotate','scaleX']) {const original=Native.prototype[method];Native.prototype[method]=function(...args){window.viewerCalls.push([method,...args]);return original.apply(this,args);};} + window.Viewer=class extends Native {constructor(...args){super(...args);window.viewerInstances.push(this);}}; + }); + await openCollection(page);const modal=mediaDialog(page),controls=modal.getByRole('group',{name:'Media controls',exact:true}); + await expect(modal.locator('.viewer-container')).toHaveCount(1);await expect(modal.locator(':is(.viewer-toolbar,.viewer-navbar,.viewer-title,.viewer-button):visible')).toHaveCount(0); + const fitted=await page.evaluate(()=>({...window.viewerInstances.at(-1).imageData})); + await controls.getByRole('button',{name:'Zoom in',exact:true}).click();expect(await page.evaluate(()=>window.viewerInstances.at(-1).imageData.width)).toBeGreaterThan(fitted.width); + await controls.getByRole('button',{name:'Zoom out',exact:true}).click(); + await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Flip image horizontally',exact:true}).click(); + expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:90,scaleX:-1}); + const fit=controls.getByRole('button',{name:'Fit image',exact:true});await fit.click();await expect(fit).toBeFocused(); + expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1}); + expect(await page.evaluate(()=>window.viewerCalls)).toEqual(expect.arrayContaining([['zoom',0.2],['zoom',-0.2],['reset'],['rotate',90],['scaleX',-1]])); + await page.evaluate(()=>{window.currentMedia=document.querySelector('.viewer-canvas img');window.currentFit=document.querySelector('[aria-label="Fit image"]');}); + await modal.getByText('File & provenance',{exact:true}).click();await modal.getByText('Workflow & raw metadata',{exact:true}).click(); + expect(await page.evaluate(()=>window.currentMedia===document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true); + await controls.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click();await controls.getByRole('button',{name:'Next media',exact:true}).click(); + await expect(controls.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');await expect(modal.locator('.viewer-container')).toHaveCount(1); + expect(await page.evaluate(()=>window.currentMedia!==document.querySelector('.viewer-canvas img')&&window.currentFit===document.querySelector('[aria-label="Fit image"]'))).toBe(true); + expect(await page.evaluate(()=>({rotate:window.viewerInstances.at(-1).imageData.rotate,scaleX:window.viewerInstances.at(-1).imageData.scaleX}))).toEqual({rotate:0,scaleX:1}); + await expect(controls.getByRole('button',{name:/slideshow/})).toHaveCount(0); +}); + +test('collection viewer preserves original download and rooted retries',async({page,request})=>{ + await fullFixture(page,request);let deny=true;const originals=[]; + await page.route('**/images/serve/inspector/complete.png?*',route=>{originals.push(route.request().url());return deny?route.fulfill({status:404,body:'Missing'}):route.continue();}); + await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();const modal=mediaDialog(page); + await expect(modal).toContainText('Media could not be loaded. Retry this file.');await expect(modal.locator('.generation-facts dd').first()).toHaveText('9007199254740993'); + await expect(modal.locator('.image-preview-position')).toContainText('Media could not be loaded.'); + const download=modal.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true});const original=await download.getAttribute('href'); + await expect(modal.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); + const denied=page.waitForResponse(response=>response.url().includes('/images/serve/inspector/complete.png?')&&response.status()===404); + await modal.getByRole('button',{name:'Retry media',exact:true}).click();await denied; + await expect.poll(()=>mediaStage(page).locator('.image-preview-media > img').evaluate(node=>node.complete&&node.naturalWidth===0)).toBe(true); + await expect(modal.getByRole('button',{name:'Retry media',exact:true})).toBeVisible(); + deny=false;await modal.getByRole('button',{name:'Retry media',exact:true}).click();await expect(modal.locator('.viewer-canvas img')).toBeVisible(); + await expect(download).toHaveAttribute('href',original); + const stages=originals.map(value=>new URL(value)).filter(url=>url.searchParams.has('_pm_viewer')); + expect(new Set(stages.map(url=>url.searchParams.get('_pm_viewer'))).size).toBe(3); + for(const value of originals) {const actual=new URL(value),expected=new URL(original,'http://127.0.0.1');expect(actual.pathname).toBe(expected.pathname);for(const key of ['root','root_scope','v'])expect(actual.searchParams.get(key)).toBe(expected.searchParams.get(key));expect(actual.searchParams.getAll('_pm_viewer').length).toBeLessThanOrEqual(1);expect([...actual.searchParams.keys()].filter(key=>!expected.searchParams.has(key))).toEqual(actual.searchParams.has('_pm_viewer')?['_pm_viewer']:[]);} + const [saved]=await Promise.all([page.waitForEvent('download'),download.click()]);expect(saved.suggestedFilename()).toBe('complete.png'); + await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{window.availableViewer=window.Viewer;window.Viewer=undefined;}); + await page.route('**/images/inspect/inspector/explicit-empty.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1}); + await selectFile(page,'explicit-empty.png');await expect(inspector(page)).toContainText('Could not read prompts from this file.'); + await page.locator('article[data-image-key="0:inspector/explicit-empty.png"] .image-card-footer button').click(); + await expect(mediaStage(page).locator('img')).toBeVisible();await expect(mediaStage(page).locator('img')).toHaveJSProperty('naturalWidth',720); + await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAccessibleDescription('Image transforms are unavailable because Viewer.js could not load.'); + await page.evaluate(()=>{window.Viewer=window.availableViewer;});await mediaDialog(page).getByRole('button',{name:'Retry prompt details',exact:true}).click(); + await expect(mediaDialog(page).locator('.file-name')).toHaveText('explicit-empty.png');await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); +}); + +test('collection viewer keeps nested dialog focus and route cleanup',async({page,request})=>{ + await fullFixture(page,request);await openCollection(page);const modal=mediaDialog(page); + const send=modal.getByRole('button',{name:'Send prompt pair',exact:true});await send.click();const sender=page.getByRole('dialog',{name:'Send to ComfyUI',exact:true}); + await expect(sender).toBeVisible();const before=await position(page).textContent();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); + await page.keyboard.press('Escape');await expect(sender).toBeHidden();await expect(modal).toBeVisible();await expect(send).toBeFocused(); + const text=modal.locator('.prompt-positive pre');await text.focus();await page.keyboard.press('ArrowRight');await expect(position(page)).toHaveText(before); + await modal.getByText('Workflow & raw metadata',{exact:true}).focus();await page.keyboard.press('ArrowLeft');await expect(position(page)).toHaveText(before); + await modal.getByRole('button',{name:'Next media',exact:true}).click();const key=(await position(page).textContent()).split('loaded: ')[1]; + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(page.locator('.image-open[aria-pressed="true"]')).toBeFocused();await expect(inspector(page).locator('.file-name')).toHaveText(key); + await inspector(page).getByRole('button',{name:'Open full preview',exact:true}).click();await page.evaluate(()=>{location.hash='/overview';}); + await expect(page.locator('h1')).toHaveText('Overview');await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0); +}); + +test('collection viewer matches approved responsive frame',async({page,request},testInfo)=>{ + await page.setViewportSize({width:1600,height:1100});await fullFixture(page,request);await openCollection(page); + const modal=mediaDialog(page),body=modal.locator('.image-preview-body'),rail=modal.locator('.image-preview-details'); + for(const theme of ['dark','light']) { + await page.evaluate(theme=>{const preference=document.getElementById('workspace-appearance');preference.value=theme;preference.dispatchEvent(new Event('change'));},theme); + const geometry=await modal.evaluate(node=>{const style=getComputedStyle(node),box=node.getBoundingClientRect(),rail=node.querySelector('.image-preview-details').getBoundingClientRect();return{width:box.width,height:box.height,padding:style.padding,radius:style.borderRadius,rail:rail.width,gap:getComputedStyle(node.querySelector('.image-preview-body')).columnGap};}); + expect(geometry).toEqual({width:1500,height:940,padding:'18px',radius:'9px',rail:340,gap:'20px'}); + await page.screenshot({path:testInfo.outputPath(`viewer-${theme}-1600.png`)}); + } + for(const [width,expected] of [[1200,300],[900,270]]) {await page.setViewportSize({width,height:1000});await expect(rail).toHaveCSS('width',`${expected}px`);} + await page.setViewportSize({width:390,height:844}); + for(const zoom of [1,2]) { + await page.evaluate(zoom=>{document.documentElement.style.zoom=String(zoom);},zoom); + expect(await modal.evaluate(node=>{const box=node.getBoundingClientRect();return box.top>=0&&box.bottom<=innerHeight+1;})).toBe(true); + await expect.poll(()=>modal.evaluate(node=>node.scrollWidth<=node.clientWidth+1)).toBe(true); + await expect(rail).toHaveCSS('overflow-y','visible');expect(await body.evaluate(node=>getComputedStyle(node).gridTemplateColumns.split(' ').length)).toBe(1); + await rail.getByText('Workflow & raw metadata',{exact:true}).click();const raw=rail.locator('pre code').first();await expect(raw).toBeVisible(); + await rail.getByRole('button',{name:'Send prompt pair',exact:true}).focus();await expect(rail.getByRole('button',{name:'Send prompt pair',exact:true})).toBeInViewport(); + await page.screenshot({path:testInfo.outputPath(`viewer-mobile-${zoom}x.png`)});await rail.getByText('Workflow & raw metadata',{exact:true}).click(); + } + await modal.getByRole('button',{name:'Close preview',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';}); + const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0]; + await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-open`).click();await openCollection(page); + await page.evaluate(()=>{document.documentElement.style.zoom='2';});await modal.evaluate(node=>{node.scrollTop=0;}); + await expect(modal.getByRole('button',{name:'Close preview',exact:true})).toBeInViewport(); +}); diff --git a/tests/browser/prompt-inspector.spec.mjs b/tests/browser/prompt-inspector.spec.mjs index a38fb79..e509be9 100644 --- a/tests/browser/prompt-inspector.spec.mjs +++ b/tests/browser/prompt-inspector.spec.mjs @@ -36,6 +36,34 @@ async function chooseDestination(page) { return sender; } +test('collection viewer rejects stale source and sends only refreshed API evidence',async({page,request,context})=>{ + const host=await fakeHost(context,page); + await page.addInitScript(()=>{window.copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>window.copied.push(text)}});}); + try { + const loaded=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const stale=loaded.images.find(image=>image.root_index===0&&image.path==='shared/same.png'); + await page.goto(workspace);await select(page,'1:shared/same.png');await expect(details(page)).toContainText('Dog landscape'); + await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['input','output']}})).toBeOK(); + const response=page.waitForResponse(result=>result.url().includes('/images/inspect/shared/same.png?root=0')); + await select(page,'0:shared/same.png');const changed=await response;expect(changed.status()).toBe(409);expect((await changed.json()).code).toBe('source-changed'); + await page.locator('article[data-image-key="0:shared/same.png"] .image-card-footer button').click();const preview=page.getByRole('dialog',{name:'Image preview',exact:true}); + const message='The source changed. Refresh the image collection before inspecting this file.'; + await expect(preview).toContainText(message);await expect(preview.locator('[data-copy-role],[data-send-role],.file-inspector a[download]')).toHaveCount(0); + const original=preview.getByRole('group',{name:'Media controls',exact:true}).getByLabel('Download original',{exact:true}); + await expect(original).toHaveAttribute('aria-disabled','true');await expect(original).not.toHaveAttribute('href',/./); + let downloads=0;page.on('download',()=>downloads++);await original.focus();await page.keyboard.press('Enter');expect(downloads).toBe(0); + await expect(preview.getByRole('button',{name:'Retry media',exact:true})).toBeHidden(); + await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0);await expect(preview).not.toContainText('Dog landscape');await expect(preview).not.toContainText('Cat portrait'); + expect(await page.evaluate(()=>window.copied)).toEqual([]);expect(await host.evaluate(()=>window.applied)).toEqual([]); + const refreshed=page.waitForResponse(result=>result.url().includes('/images/output?')); + await preview.getByRole('button',{name:'Refresh image collection',exact:true}).click();await refreshed;await expect(preview).toHaveCount(0); + const collection=await(await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();const fresh=collection.images.find(image=>image.root_index===0&&image.path===stale.path);expect(fresh.root_scope).not.toBe(stale.root_scope); + const evidence=await(await request.get(`/prompt_manager/images/inspect/shared/same.png?root=0&root_scope=${fresh.root_scope}`)).json(); + await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:'Open full preview',exact:true}).click();await expect(preview).toContainText(evidence.prompts.positive.text); + await preview.getByRole('button',{name:'Send prompt pair',exact:true}).click();const sender=await chooseDestination(page);await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();await expect(sender).toContainText('Sent to ComfyUI.'); + expect(await host.evaluate(()=>window.applied)).toEqual([{positive:evidence.prompts.positive.text,negative:evidence.prompts.negative.text}]);expect(await page.evaluate(()=>window.copied)).toEqual([]); + } finally {await expect(await request.post('/prompt_manager/settings',{data:{gallery_root_paths:['output','input']}})).toBeOK();await host.evaluate(()=>window.fixtureHost.dispose());await host.close();} +}); + test('physical image inspector resolves positive and negative metadata separately for duplicate filenames',async({page})=>{ await page.goto(workspace);await select(page,'0:shared/same.png'); await expect(role(details(page),'Positive')).toContainText('Cat portrait');await expect(role(details(page),'Negative')).toContainText('blurry, watermark'); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 219170e..32508f5 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -336,6 +336,44 @@ test('real video media has a usable card and native playback cleans up on close expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true); }); +test('collection viewer ignores delayed media callbacks and keeps inspection retries independent',async({page})=>{ + await page.goto(workspace);await expect(cards(page)).toHaveCount(2); + await page.evaluate(()=>{const Native=window.Viewer;window.liveViewers=[];window.Viewer=class extends Native {constructor(...args){super(...args);window.liveViewers.push(this);}};}); + let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;}); + await page.route('**/images/serve/shared/same.png?*',async route=>{ + const url=new URL(route.request().url()); + if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;} + await route.continue().catch(()=>{}); + }); + await cards(page).first().getByRole('button',{name:'Preview same.png',exact:true}).click();await waiting; + const preview=page.getByRole('dialog',{name:'Image preview',exact:true}); + await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','true'); + await page.route('**/images/inspect/shared/same.png?*',route=>route.fulfill({status:503,json:{success:false,error:'Unavailable'}}),{times:1}); + await preview.getByRole('button',{name:'Next media',exact:true}).click(); + await expect(preview).toContainText('Could not read prompts from this file.');await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false'); + await expect(preview.getByRole('group',{name:'Media controls',exact:true}).getByRole('link',{name:'Download original',exact:true})).toHaveAttribute('href',/root=1&root_scope=/); + await preview.getByRole('button',{name:'Rotate image 90 degrees',exact:true}).click(); + await page.evaluate(()=>{window.latestCanvas=document.querySelector('.viewer-canvas img');window.liveViewers[0].options.viewed();});release(); + expect(await page.evaluate(()=>window.liveViewers.at(-1).imageData.rotate)).toBe(90); + await preview.getByRole('button',{name:'Retry prompt details',exact:true}).click(); + await expect(preview).toContainText('same.png');await expect(preview.getByRole('button',{name:'Retry prompt details',exact:true})).toHaveCount(0); + expect(await page.evaluate(()=>window.latestCanvas===document.querySelector('.viewer-canvas img'))).toBe(true); + await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,.image-preview')).toHaveCount(0); +}); + +test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{ + await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace); + await page.locator('article[data-image-key="0:clip.webm"] .image-card-footer button').click(); + const preview=page.getByRole('dialog',{name:'Image preview',exact:true}),video=preview.locator('video'); + await expect(video).toHaveJSProperty('controls',true);await expect(video).toHaveJSProperty('playsInline',true); + const zoom=preview.getByRole('button',{name:'Zoom in',exact:true});await expect(zoom).toHaveAttribute('aria-disabled','true');await expect(zoom).toHaveAccessibleDescription('Image transforms are unavailable for video.'); + await video.focus();const position=await preview.locator('.image-preview-position').textContent();await page.keyboard.press('ArrowRight');await expect(preview.locator('.image-preview-position')).toHaveText(position); + await video.evaluate(async node=>{window.previousClip=node;node.muted=true;await node.play();}); + await preview.getByRole('button',{name:'Next media',exact:true}).click();await expect(preview.locator('video')).toHaveCount(0);await expect(zoom).toHaveAttribute('aria-disabled','false'); + expect(await page.evaluate(()=>window.previousClip.paused&&!window.previousClip.isConnected&&!window.previousClip.hasAttribute('src'))).toBe(true); + await preview.getByRole('button',{name:'Close preview',exact:true}).click();await expect(page.locator('.image-preview')).toHaveCount(0); +}); + test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => { await page.route('**/prompt_manager/images/output?*', route => route.fulfill({ status: 503, json: { success: false, error: 'Images unavailable' }, @@ -518,9 +556,10 @@ test('Images uses approved geometry while keeping real controls', async ({page,r expect(geometry[0].mediaWidth/geometry[0].mediaHeight).toBeCloseTo(4/3,2); expect((await page.locator('.image-toolbar').boundingBox()).y).toBeLessThan(geometry[0].y); await expect(cards(page).first()).toHaveCSS('outline-style','solid'); - const cover=details(page).locator(':scope > img'); + const cover=details(page).getByRole('button',{name:'Open full preview',exact:true}); await expect(cover).toHaveCSS('height','150px'); - expect(await details(page).evaluate(node=>node.firstElementChild.tagName)).toBe('IMG'); + await expect(cover.locator('img')).toBeVisible();await expect(cover.locator('img')).toHaveCSS('height','150px'); + expect(await cover.evaluate(node=>node===node.parentElement.firstElementChild&&node.tagName==='BUTTON')).toBe(true); await page.setViewportSize({width:390,height:844}); await expect.poll(()=>cards(page).nth(1).evaluate(node=>node.offsetTop)).toBe(0); await expect.poll(()=>cards(page).nth(2).evaluate(node=>node.offsetTop)).toBeGreaterThan(0); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index 9973d81..f514ef8 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -36,6 +36,21 @@ function pendingApi() { return { api, requests }; } +test('original media validation is strict before inspection and preserves rooted URLs', () => { + const validate = contract('originalMediaDownload'), entry = image(); + assert.deepEqual(validate(entry), {url:entry.url,filename:'a #%.png'}); + for (const change of [{url:'https://other.test/prompt_manager/images/serve/folder/a%20%23%25.png?root=0'}, + {url:entry.url.replace('root=0','root=1')},{url:entry.url.replace(scopeA,scopeB)}, + {url:entry.url.replace('folder/a','elsewhere/a')},{url:entry.url.replace(`&root_scope=${scopeA}`,'')}, + {url:entry.url+'&root=0'},{url:entry.url+'#wrong'},{path:'../a.png'},{root_scope:''}]) { + assert.throws(() => validate({...entry,...change}), TypeError); + } + const before = Object.getOwnPropertyDescriptor(globalThis,'window'); + Object.defineProperty(globalThis,'window',{configurable:true,value:{location:{href:'https://example.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:url=>`/proxy${url}`}}}); + try { assert.deepEqual(validate(entry),{url:`/proxy${entry.url}`,filename:'a #%.png'}); } + finally { if (before) Object.defineProperty(globalThis,'window',before); else delete globalThis.window; } +}); + test('record preserves exact projections and unknown roles without execution fact promotion', () => { const entry = image(), data = envelope(entry); const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js new file mode 100644 index 0000000..f72f14e --- /dev/null +++ b/tests/js/workspace-media-viewer.test.js @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createScope} from '../../web/js/shared/toolkit/core.js'; + +const module = await import('../../web/js/workspace/media-viewer.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +test('disposed media viewer touches no DOM observers or requests', () => { + assert.equal(typeof module.createMediaViewer,'function','media viewer contract is exported'); + const forbidden = new Proxy({}, {get() {assert.fail('Disposed viewer touched DOM or inspection');}}); + for (const aborted of [false,true]) { + const parentScope = createScope(); if (aborted) parentScope.dispose(); + const view = module.createMediaViewer({container:forbidden,parentScope,inspectionController:forbidden,sender:forbidden, + onSelect:() => assert.fail('Disposed viewer selected an entry'),onRefresh:() => assert.fail('Disposed viewer refreshed')}); + if (!aborted) view.dispose(); + view.open(forbidden); view.dispose(); view.dispose(); parentScope.dispose(); + } +}); diff --git a/tests/js/workspace-viewer-state.test.js b/tests/js/workspace-viewer-state.test.js new file mode 100644 index 0000000..9607290 --- /dev/null +++ b/tests/js/workspace-viewer-state.test.js @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const module = await import('../../web/js/workspace/viewer-state.mjs').catch(error => { + if (error.code !== 'ERR_MODULE_NOT_FOUND') throw error; + return {}; +}); +function session(options) { + assert.equal(typeof module.createViewerSession, 'function', 'viewer session contract is exported'); + return module.createViewerSession(options); +} +const entries = () => Array.from({length: 161}, (_, index) => ({root_index: index % 2, + path: `folder/${index}.png`, filename: `${index}.png`, root_scope: 'a'.repeat(64), + thumbnail_url: `/prompt_manager/images/serve/thumbnails/${index}.png?root=${index % 2}&v=17&root_scope=${'a'.repeat(64)}`, + url: `/prompt_manager/images/serve/folder/${index}.png?root=${index % 2}&root_scope=${'a'.repeat(64)}`})); + +test('viewer freezes loaded identities and wraps across unmounted entries', () => { + const items = entries(), updates = []; + const view = session({items, initialKey: '0:folder/0.png', onChange: (state, entry) => updates.push({state, entry})}); + items[160].url = '/wrong-root'; items[160].thumbnail_url = '/wrong-version?v=18';items[160].root_scope = 'b'.repeat(64); items.reverse(); items.push(items[0]); + view.navigate(-1); + assert.deepEqual(view.state, {key:'0:folder/160.png',index:160,total:161,phase:'stopped',requested:false,mediaReady:false}); + assert.equal(updates.at(-1).entry.url, `/prompt_manager/images/serve/folder/160.png?root=0&root_scope=${'a'.repeat(64)}`); + assert.equal(updates.at(-1).entry.root_scope, 'a'.repeat(64)); + assert.equal(updates.at(-1).entry.thumbnail_url, `/prompt_manager/images/serve/thumbnails/160.png?root=0&v=17&root_scope=${'a'.repeat(64)}`); + assert.ok(Object.isFrozen(updates.at(-1).entry)); assert.ok(Object.isFrozen(view.state)); + view.navigate(1); assert.equal(view.state.key, '0:folder/0.png'); + view.navigate(1); assert.equal(view.state.key, '1:folder/1.png'); + view.dispose(); +}); + +test('viewer rejects empty duplicate or unknown identity', () => { + for (const [items, initialKey] of [[[], '0:a.png'], [[{path:'a.png'}], 'undefined:a.png'], + [[{root_index:0,path:''}], '0:'], [[{root_index:0,path:'a.png'},{root_index:0,path:'a.png'}], '0:a.png'], + [entries(), '0:missing.png']]) assert.throws(() => session({items,initialKey}), TypeError); + const view = session({items:entries(),initialKey:'0:folder/0.png'}); + for (const direction of [0,2,-2,'1',null]) assert.throws(() => view.navigate(direction), TypeError); + assert.equal(view.state.index, 0); view.dispose(); +}); + +test('viewer ignores stale readiness after navigation and disposal', () => { + let calls = 0; + const view = session({items:entries(),initialKey:'0:folder/0.png',onChange:() => calls++, + setTimer:() => assert.fail('Task4 must not start a timer'),clearTimer:() => assert.fail('No timer exists')}); + view.ready('0:folder/0.png'); assert.equal(view.state.mediaReady,true); + view.navigate(1); view.failed('0:folder/0.png'); view.ready('0:folder/0.png'); + assert.equal(view.state.mediaReady,false); assert.equal(view.state.phase,'stopped'); + view.failed('1:folder/1.png'); assert.equal(view.state.phase,'error'); + view.ready('1:folder/1.png'); assert.equal(view.state.phase,'stopped'); + view.dispose(); const after = calls; + view.ready('1:folder/1.png'); view.failed('1:folder/1.png'); view.navigate(1); view.dispose(); + assert.equal(calls,after); assert.equal(view.state.phase,'disposed'); assert.equal(view.state.requested,false); +}); diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index fa22692..0ae35f1 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -43,18 +43,37 @@ .image-details .workspace-link { display: flex; font-size: 11px; } .image-continuation { text-align: center; padding-block: 24px; font-size: 12px; } .image-continuation [role="alert"] { color: var(--ca-error-text); } -.image-preview { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 10px; inline-size: min(1200px, calc(100% - 32px)); max-inline-size: none; padding: 0; } -.image-preview::backdrop { background: #000b; } -.image-preview header { padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 16px; } -.image-preview h2 { font-size: 15px; margin: 0; overflow-wrap: anywhere; } -.image-preview-stage { position: relative; block-size: min(75dvh, 850px); background: var(--ca-field); } -.image-preview-stage > video { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } -.image-preview-stage .viewer-container { inline-size: 100% !important; block-size: 100% !important; } +.image-preview { box-sizing: border-box; background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-border); border-radius: 9px; inline-size: min(1500px, 96%); block-size: min(940px, 94dvh); max-inline-size: none; max-block-size: 94%; padding: 18px; overflow: hidden; overscroll-behavior: contain; } +.image-preview[open] { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 20px; } +.image-preview::backdrop { background: #080b12b8; overflow: hidden; overscroll-behavior: contain; } +.image-preview-header { display: flex; align-items: center; justify-content: space-between; gap: 18px; min-inline-size: 0; } +.image-preview-header > div { min-inline-size: 0; } +.image-preview-header h2 { font-size: 18px; font-weight: 600; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } +.image-preview-position { font-size: 12px; line-height: 1.5; margin: 0; overflow-wrap: anywhere; } +.ca-tools .image-preview-header > button { align-self: start; min-inline-size: 32px; padding: 4px 10px; font-size: 20px; line-height: 1.5; } +.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 20px; min-block-size: 0; min-inline-size: 0; } +.image-preview-left { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-inline-size: 0; min-block-size: 0; background: var(--ca-field); border: 1px solid var(--ca-line); border-radius: 6px; overflow: hidden; } +.image-preview-stage { position: relative; min-inline-size: 0; min-block-size: 0; } +.image-preview-media { position: absolute; inset: 0; } +.image-preview-media > :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } +.image-preview-media > img[hidden] { display: none; } +.image-preview-media > .viewer-container { position: absolute; inset: 0; inline-size: 100% !important; block-size: 100% !important; background: var(--ca-field); } +.image-preview-toolbar { border-block-start: 1px solid var(--ca-line); padding: 10px; } +.image-preview-controls { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px; } +.ca-tools .image-preview-controls :where(button, a) { display: inline-flex; align-items: center; justify-content: center; min-block-size: 32px; padding: 4px 10px; background: var(--ca-control); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 12px; line-height: 1.5; text-decoration: none; } +.image-preview-controls [aria-disabled="true"] { opacity: .55; cursor: not-allowed; } +.image-preview-reason { text-align: center; font-size: 11px; line-height: 1.6; margin: 8px 0 0; } +.image-preview-failure { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 20px; background: var(--ca-field); text-align: center; font-size: 12px; } +.image-preview-failure[hidden] { display: none; } +.image-preview-details { min-inline-size: 0; min-block-size: 0; overflow: auto; overscroll-behavior: contain; padding: 0 6px 18px; } +.image-preview :where(button, a, .image-preview-stage):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: -2px; } @media (max-width: 1250px) { .image-workspace { grid-template-columns: minmax(0, 1fr) 275px; gap: 15px; } + .image-preview-body { grid-template-columns: minmax(0, 1fr) 300px; } } @media (max-width: 950px) { .image-workspace { grid-template-columns: minmax(0, 1fr) 280px; } + .image-preview-body { grid-template-columns: minmax(0, 1fr) 270px; } } @media (max-width: 700px) { .image-workspace { grid-template-columns: minmax(0, 1fr); } @@ -65,6 +84,12 @@ .image-filters .ca-field:first-child { flex-basis: 100%; } .image-view-controls { gap: 8px; } .image-view-controls > .workspace-link { margin-inline-start: 0; } + .image-preview { block-size: 94dvh; overflow: auto; } + .image-preview[open] { grid-template-rows: auto auto; } + .image-preview-body { grid-template-columns: minmax(0, 1fr); } + .image-preview-left { block-size: 48dvh; min-block-size: 300px; } + .image-preview-details { overflow: visible; } + .ca-tools .image-preview-controls :where(button, a) { font-size: 10px; } } @container images-page (inline-size < 600px) { .image-workspace { grid-template-columns: minmax(0, 1fr); } @@ -91,10 +116,7 @@ .prompt-actions .workspace-icon { inline-size: 14px; block-size: 14px; } .prompt-copy-status { font-size: 11px; margin-block-end: 0; } .prompt-copy-status:empty { display: none; } -.image-preview-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(270px, 340px); } -.image-preview-details { padding: 0; max-block-size: 75dvh; overflow: auto; min-inline-size: 0; } .image-preview-details .prompt-inspector { margin-block-start: 0; } -.image-preview > header { border-block-end: 1px solid var(--ca-line); } .prompt-send-button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--ca-focus); } .prompt-send-button svg { inline-size: 16px; block-size: 16px; flex-shrink: 0; } .prompt-sender { background: var(--ca-surface); color: var(--ca-text); border: 1px solid var(--ca-line); border-radius: 12px; padding: 22px; inline-size: min(760px, calc(100% - 32px)); max-inline-size: none; max-block-size: calc(100dvh - 32px); overflow: auto; } @@ -113,9 +135,9 @@ .prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; } .prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; } @media (max-width: 780px) { - .image-preview-body { grid-template-columns: minmax(0, 1fr); } - .image-preview-stage { block-size: 52dvh; } - .image-preview-details { max-block-size: none; } - .image-preview { max-block-size: calc(100dvh - 24px); overflow: auto; } .prompt-sender-options { grid-template-columns: minmax(0, 1fr); } } +@media (pointer: coarse) { + .ca-tools .image-preview :is(button, a) { min-inline-size: 40px; min-block-size: 40px; } + .ca-tools .image-preview .prompt-actions button { min-inline-size: 36px; min-block-size: 36px; } +} diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index b44ca49..5576241 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -5,6 +5,7 @@ import { mountFileInspector } from './file-inspector.mjs'; import { createInspectionController } from './inspection-state.mjs'; import { createPromptSender } from './prompt-sender.mjs'; import { createWorkspaceIcon } from './icons.mjs'; +import { createMediaViewer } from './media-viewer.mjs'; function el(tag, text, className) { const element = document.createElement(tag); @@ -27,15 +28,15 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (parentScope.signal.aborted) { scope.dispose(); return {dispose}; } const { resolveUrl } = window.ApiClient; const sender = createPromptSender({scope}); - let detailInspector, previewInspector; + let detailInspector; const inspection = createInspectionController({api}); - scope.cleanup(inspection.subscribe(next => {detailInspector?.setState(next); previewInspector?.setState(next);})); + scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next))); scope.cleanup(() => inspection.dispose()); - scope.cleanup(() => { detailInspector?.dispose(); previewInspector?.dispose(); }); + scope.cleanup(() => detailInspector?.dispose()); const visit = embedded ? null : lastVisit; let density = visit?.density || 'comfortable'; let metrics = workspaceImageMetrics(window.innerWidth, density); - let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer, viewer; + let layout = visit?.layout || 'grid', selection = visit?.selection || null, frame = 0, loadFrame = 0, restoreFrame = 0, observer; let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest; const rendered = new Map(); const itemIndexes = new Map(); @@ -67,7 +68,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (!embedded) main.append(filtersBar, optionsStatus); const controls = el('div', undefined, 'image-view-controls'); const toolbar = el('div', undefined, 'image-toolbar'); - toolbar.append(el('h2', embedded ? 'Linked images' : 'All images')); + const collectionHeading = el('h2', embedded ? 'Linked images' : 'All images');collectionHeading.tabIndex = -1; + toolbar.append(collectionHeading); const count = el('p', 'Loading images…', 'muted'); const layouts = el('div', undefined, 'image-layouts'); layouts.setAttribute('role', 'group'); layouts.setAttribute('aria-label', 'Image layout'); @@ -132,29 +134,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage details.append(el('h2', 'Image details'), el('p', 'Select an image to inspect it.')); body.append(collection, details); main.append(body); - const dialog = el('dialog', undefined, 'image-preview'); - dialog.setAttribute('aria-label', 'Image preview'); - const dialogHeader = el('header'); - const previewTitle = el('h2'); - const close = createButton('Close preview'); - dialogHeader.append(previewTitle, close); - const stage = el('div', undefined, 'image-preview-stage'); - const previewBody = el('div', undefined, 'image-preview-body'); - const previewDetails = el('aside', undefined, 'image-preview-details'); - previewDetails.setAttribute('aria-label', 'Preview prompt details'); - previewBody.append(stage, previewDetails); - dialog.append(dialogHeader, previewBody); - main.append(dialog); - scope.listen(close, 'click', () => dialog.close()); - scope.listen(dialog, 'close', () => { destroyPreview(); scheduleLoad(); }); - function destroyPreview() { - previewInspector?.dispose(); previewInspector = null; - previewDetails.replaceChildren(); - viewer?.destroy(); viewer = null; - for (const video of stage.querySelectorAll('video')) releaseVideo(video); - stage.replaceChildren(); - } - scope.cleanup(() => { dialog.close(); destroyPreview(); dialog.remove(); }); + const mediaViewer = createMediaViewer({container:main,parentScope:scope,inspectionController:inspection,sender, + onSelect:image => {if (selection !== imageKey(image)) inspect(image);else inspection.select(image);}, + onRefresh:refreshInspectionCollection,onRestoreFocus:image => { + const index = state?.items.findIndex(entry => imageKey(entry) === imageKey(image)); + if (index >= 0) focusImage(index);else collectionHeading.focus({preventScroll:true}); + }}); + scope.cleanup(() => mediaViewer.dispose()); + main.addEventListener('close',event => {if (event.target.matches('.image-preview')) scheduleLoad();},{capture:true,signal:scope.signal}); function mediaElement(image, lazy = false) { const video = image.is_video && !image.thumbnail_url; @@ -182,24 +169,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage } function previewImage(image) { if (!image) return; - if (selection !== imageKey(image)) inspect(image); - destroyPreview(); - previewTitle.textContent = image.filename; - previewInspector = mountFileInspector(previewDetails, {sender, parentScope:scope, - onRetry:() => inspection.retry(), onRefresh:refreshInspectionCollection}); - previewInspector.setState(inspection.state); - if (!dialog.open) dialog.showModal(); - if (image.is_video) { - const video = el('video'); video.controls = true; video.src = resolveUrl(image.url); video.preload = 'metadata'; - stage.append(video); - } else if (window.Viewer) { - const original = el('img'); original.src = resolveUrl(image.url); original.alt = image.filename; - original.hidden = true; stage.append(original); - viewer = new window.Viewer(original, { inline: true, container: stage, navbar: false, title: false, - transition: false, keyboard: false, viewed() { viewer?.resize(); } }); - } else { - stage.append(el('p', 'The image viewer could not load. Refresh the page to try again.')); - } + mediaViewer.open({items:state.items,selectedKey:imageKey(image),trigger:document.activeElement}); } function cardFor(image) { const key = imageKey(image); @@ -373,7 +343,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scope.listen(more, 'click', () => stream.loadMore()); scope.listen(retry, 'click', () => stream.loadMore()); function maybeLoad() { - if (!observer || scope.signal.aborted || !state?.hasMore || state.loading || state.error || dialog.open) return; + if (!observer || scope.signal.aborted || !state?.hasMore || state.loading || state.error || main.querySelector('.image-preview[open]')) return; const bounds = continuation.getBoundingClientRect(); if (bounds.top <= window.innerHeight + 250 && bounds.bottom >= 0) stream.loadMore(); } @@ -387,8 +357,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage observer.observe(continuation); } function reset() { - if (dialog.open) dialog.close(); - destroyPreview(); + main.querySelector('.image-preview[open]')?.close(); inspection.clear(); detailInspector?.dispose(); detailInspector = null; selection = null; diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index c1e4234..6efd2af 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -59,6 +59,11 @@ function downloadFor(image, required) { } } +export function originalMediaDownload(image) { + validateIdentity(image); + return downloadFor(image, true); +} + function promptsFor(value) { if (!object(value)) throw new TypeError('Invalid inspection prompts'); const confirmed = promptRoles(value); diff --git a/web/js/workspace/inspector.css b/web/js/workspace/inspector.css index dac5a7b..f8090c4 100644 --- a/web/js/workspace/inspector.css +++ b/web/js/workspace/inspector.css @@ -1,6 +1,8 @@ .image-details.file-inspector { padding: 0; } .file-inspector { min-inline-size: 0; overflow-wrap: anywhere; } .file-inspector-inner { padding: 17px; } +.image-preview-details .file-inspector-inner { padding: 0; } +.image-preview-details .file-inspector-footer { padding-inline: 0; } .ca-tools .file-cover { display: block; padding: 0; border: 0; border-radius: 6px 6px 0 0; inline-size: 100%; block-size: 150px; background: var(--ca-field); } .file-cover :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } .file-title { display: flex; align-items: start; justify-content: space-between; gap: 8px; } diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs new file mode 100644 index 0000000..ea19ab6 --- /dev/null +++ b/web/js/workspace/media-viewer.mjs @@ -0,0 +1,169 @@ +import {createScope, createButton} from '../shared/toolkit/core.js'; +import {createViewerSession} from './viewer-state.mjs'; +import {originalMediaDownload} from './inspection-state.mjs'; +import {mountFileInspector} from './file-inspector.mjs'; + +let viewerId = 0; +function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus}) { + const scope = createScope(); + let active = null, retryVersion = 0; + const dispose = () => scope.dispose(); + if (parentScope.signal.aborted) { scope.dispose(); return {open() {},dispose}; } + parentScope.signal.addEventListener('abort',dispose,{once:true}); + scope.cleanup(() => parentScope.signal.removeEventListener('abort',dispose)); + scope.cleanup(() => active?.close(false)); + + function open(options) { + if (scope.signal.aborted) return; + active?.close(false); + const {items,selectedKey,trigger = document.activeElement} = options; + let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false; + let originalDownload = null, inspectionState = inspectionController.state; + const session = createViewerSession({items,initialKey:selectedKey,onChange:(state,selected) => { + if (!mounted) { initial = [state,selected]; return; } + update(state,selected); + }}); + const local = createScope(); + const dialog = element('dialog','image-preview'); + dialog.setAttribute('aria-label','Image preview'); + const header = element('header','image-preview-header'); + const heading = element('div'), title = element('h2'); + const position = element('p','image-preview-position muted'); + position.setAttribute('role','status'); + heading.append(title,position); + const close = createButton('×');close.setAttribute('aria-label','Close preview');close.autofocus = true; + header.append(heading,close); + const body = element('div','image-preview-body'), left = element('div','image-preview-left'); + const stage = element('section','image-preview-stage');stage.tabIndex = 0;stage.setAttribute('aria-label','Media preview'); + const media = element('div','image-preview-media'), failure = element('div','image-preview-failure'); + const error = element('p',undefined,'Media could not be loaded. Retry this file.'); + const retry = createButton('Retry media');failure.append(error,retry);failure.hidden = true;stage.append(media,failure); + const toolbar = element('div','image-preview-toolbar'), controls = element('div','image-preview-controls'); + controls.setAttribute('role','group');controls.setAttribute('aria-label','Media controls'); + const reason = element('p','image-preview-reason muted');reason.id = `media-viewer-reason-${++viewerId}`; + function button(label,text,action) { + const node = createButton(text);node.setAttribute('aria-label',label); + local.listen(node,'click',() => { if (node.getAttribute('aria-disabled') !== 'true') action(); }); + controls.append(node);return node; + } + const previous = button('Previous media','← Previous',() => session.navigate(-1)); + const transforms = [button('Zoom out','−',() => viewer.zoom(-0.2)),button('Zoom in','+',() => viewer.zoom(0.2)), + button('Fit image','Fit',() => viewer.reset()),button('Rotate image 90 degrees','Rotate',() => viewer.rotate(90)), + button('Flip image horizontally','Flip',() => viewer.scaleX(viewer.imageData.scaleX === 1 ? -1 : 1))]; + const download = element('a','image-preview-download','Download');download.setAttribute('aria-label','Download original');download.setAttribute('download',''); + local.listen(download,'click',event => {if (download.getAttribute('aria-disabled') === 'true') event.preventDefault();}); + controls.append(download); + const next = button('Next media','Next →',() => session.navigate(1)); + toolbar.append(controls,reason);left.append(stage,toolbar); + const details = element('aside','image-preview-details');details.setAttribute('aria-label','Preview prompt details'); + body.append(left,details);dialog.append(header,body);container.append(dialog); + const inspector = mountFileInspector(details,{sender,parentScope:local,onRetry:() => inspectionController.retry(), + onRefresh:onRefresh ? () => { closeViewer();onRefresh(); } : undefined}); + function closeViewer(restore = true) { + if (closed) return; + closed = true;active = null;local.dispose(); + if (restore && !scope.signal.aborted) { + if (trigger?.isConnected) trigger.focus({preventScroll:true}); + else onRestoreFocus?.(entry); + } + } + active = {close:closeViewer}; + local.cleanup(() => { + session.dispose();mediaScope?.dispose();inspector.dispose(); + if (dialog.open) dialog.close();dialog.remove(); + }); + local.listen(close,'click',() => dialog.close());local.listen(dialog,'close',() => closeViewer()); + 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); + }); + function rebuild(selected) { + mediaScope?.dispose();mediaScope = createScope();viewer = null;media.replaceChildren(); + failure.hidden = true; + const mediaLocal = mediaScope, key = session.state.key; + const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key; + let original; + try { original = originalMediaDownload(selected); } + catch { session.failed(key);return; } + const url = new URL(original.url,window.location.href); + url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`); + const node = element(selected.is_video ? 'video' : 'img'); + mediaLocal.listen(node,'error',() => {if (current()) session.failed(key);}); + if (selected.is_video) { + node.controls = true;node.playsInline = true;node.preload = 'metadata'; + mediaLocal.listen(node,'loadedmetadata',() => {if (current()) session.ready(key);}); + mediaLocal.cleanup(() => {node.pause();node.removeAttribute('src');node.load();}); + } else { + node.alt = selected.filename;node.decoding = 'async'; + node.hidden = !!window.Viewer; + if (!window.Viewer) mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);}); + mediaLocal.cleanup(() => node.removeAttribute('src')); + } + node.src = url.href;media.append(node); + if (!selected.is_video && window.Viewer) { + const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false, + keyboard:false,fullscreen:false,transition:false,viewed() { + if (!current()) return; + instance.resize();session.ready(key); + }}); + viewer = instance;mediaLocal.cleanup(() => instance.destroy()); + } + updateControls(session.state); + } + function updateControls(state) { + const unavailable = inspectionState.refreshRequired ? inspectionState.message : state.phase === 'error' ? 'Media could not be loaded. Retry this file.' : entry.is_video ? + 'Image transforms are unavailable for video.' : !viewer ? + 'Image transforms are unavailable because Viewer.js could not load.' : !state.mediaReady ? 'Image transforms are available when the image is ready.' : ''; + reason.textContent = unavailable;reason.hidden = !unavailable; + for (const node of transforms) { + node.setAttribute('aria-disabled',String(!!unavailable)); + if (unavailable) node.setAttribute('aria-describedby',reason.id);else node.removeAttribute('aria-describedby'); + } + for (const node of [previous,next]) { + node.setAttribute('aria-disabled',String(state.total === 1)); + if (state.total === 1) {node.setAttribute('aria-describedby',position.id);} + } + const canDownload = originalDownload && !inspectionState.refreshRequired; + download.setAttribute('aria-disabled',String(!canDownload)); + if (canDownload) { + download.href = originalDownload.url;download.download = originalDownload.filename; + download.removeAttribute('tabindex');download.removeAttribute('aria-describedby'); + } else { + download.removeAttribute('href');download.tabIndex = 0;download.setAttribute('aria-describedby',reason.id); + } + failure.hidden = state.phase !== 'error' || inspectionState.refreshRequired; + } + function update(state,selected) { + if (closed || state.phase === 'disposed') return; + entry = selected; + const label = `${state.index + 1} of ${state.total} loaded: ${entry.filename}${state.phase === 'error' ? ' — Media could not be loaded.' : ''}`; + if (position.textContent !== label) position.textContent = label; + if (currentKey !== state.key) { + currentKey = state.key;title.textContent = entry.filename; + try { originalDownload = originalMediaDownload(entry); } catch { originalDownload = null; } + onSelect?.(entry); + rebuild(entry); + } + updateControls(session.state); + } + position.id = `media-viewer-position-${viewerId}`; + dialog.showModal();mounted = true;update(...initial); + local.cleanup(inspectionController.subscribe(state => { + inspectionState = state;updateControls(session.state); + inspector.setState(state); + if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} · ${entry.filename}`; + })); + } + return {open,dispose}; +} diff --git a/web/js/workspace/viewer-state.mjs b/web/js/workspace/viewer-state.mjs new file mode 100644 index 0000000..638c0b0 --- /dev/null +++ b/web/js/workspace/viewer-state.mjs @@ -0,0 +1,42 @@ +import {imageKey} from './images-state.mjs'; +import {inspectPath} from './inspection-state.mjs'; + +/** A viewer visits only the descriptors loaded by its opening query. */ +export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout}) { + if (!Array.isArray(items) || !items.length) throw new TypeError('A loaded media collection is required'); + const keys = new Set(); + const loaded = Object.freeze(items.map(item => { + inspectPath(item); + const key = imageKey(item); + if (keys.has(key)) throw new TypeError('Duplicate viewer identity'); + keys.add(key); + return Object.freeze({...item}); + })); + const index = loaded.findIndex(item => imageKey(item) === initialKey); + if (index < 0) throw new TypeError('Selected media is not loaded'); + let state = Object.freeze({key:initialKey,index,total:loaded.length,phase:'stopped',requested:false,mediaReady:false}); + function publish(update) { + state = Object.freeze({...state,...update}); + onChange(state, loaded[state.index]); + } + publish({}); + return { + get state() { return state; }, + navigate(direction) { + if (state.phase === 'disposed') return; + if (direction !== -1 && direction !== 1) throw new TypeError('Viewer direction must be -1 or 1'); + if (loaded.length === 1) return; + const next = (state.index + direction + loaded.length) % loaded.length; + publish({index:next,key:imageKey(loaded[next]),phase:'stopped',mediaReady:false}); + }, + ready(key) { + if (state.phase !== 'disposed' && key === state.key) publish({mediaReady:true,phase:'stopped'}); + }, + failed(key) { + if (state.phase !== 'disposed' && key === state.key) publish({mediaReady:false,phase:'error'}); + }, + dispose() { + if (state.phase !== 'disposed') publish({phase:'disposed',requested:false,mediaReady:false}); + }, + }; +}