@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+36
-14
@@ -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; }
|
||||
}
|
||||
|
||||
+18
-49
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user