diff --git a/docs/application-redesign.md b/docs/application-redesign.md index 3107e9a..0d920b3 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -9,8 +9,9 @@ code. This plan proposes implementation order and acceptance gates. - Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198). - [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available. - Shared role-aware sending and the physical-file prompt inspector merged in [#203](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/203) and [#206](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/206). -- [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208) adds the native Prompt Library: one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. [Durable history storage](prompt-history-storage.md) now records new job outcomes; prompt contexts, output associations and the execution timeline remain pending under #209. -- Prompt execution contexts and timeline, tagging/review and System consolidation remain subsequent slices under #196; #209 now records durable job-level outcomes. +- The native Prompt Library merged in [#210](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/210) for [#208](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/208): one positive parent, saved negative variants, full selected text and a shared filtered image gallery. It does not invent chronological runs from counters or scan dates. +- [Durable history storage](prompt-history-storage.md) records job attempts and outcomes from [#212](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/212), frozen prompt contexts from [#213](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/213), and observed run-owned output locators from [#214](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/214). Those locators do not verify file bytes or establish gallery-image associations. That association contract and the chronological execution timeline remain pending under [#209](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/209). +- [#215](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/215) brings the shell, Images and natural-height Prompt Library presentation into the approved geometry and themes while retaining their existing operations. Full inspector/viewer parity remains pending under [#216](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/216); this presentation slice does not establish whole-application parity. Tagging/review, System consolidation and the remaining organization capabilities continue under #196. ## Scope and compatibility diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 182f95b..a980ee2 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -97,6 +97,25 @@ test('failed library reads retry while late search responses cannot overwrite ne await expect(cards(page)).toHaveCount(1);await expect(cards(page).first()).toContainText('Dog landscape');release();await expect(cards(page).first()).toContainText('Dog landscape'); }); +test('refresh atomically discards old row geometry and renders changed parent data',async({page,request})=>{ + await page.goto(route);await expect(cards(page)).toHaveCount(3); + const card=cards(page).filter({has:page.getByText('Dog landscape',{exact:true})});const id=await card.getAttribute('data-prompt-id'); + await expect(await request.put(`/prompt_manager/prompts/${id}`,{data:{text:'Revised dog landscape'}})).toBeOK(); + await expect(await request.put(`/prompt_manager/prompts/${id}/rating`,{data:{rating:5}})).toBeOK(); + await page.evaluate(()=>{ + const native=requestAnimationFrame;const held=[];let paused=true;window.releaseLibraryFrames=()=>{paused=false;window.requestAnimationFrame=native;for(const callback of held)callback();}; + window.requestAnimationFrame=callback=>native(time=>{if(paused)held.push(()=>callback(time));else callback(time);}); + }); + try { + await page.getByRole('button',{name:'Refresh library',exact:true}).click(); + await expect(page.locator('.library-status')).not.toHaveText('Loading prompt library…'); + } finally {await page.evaluate(()=>window.releaseLibraryFrames());} + await expect(cards(page)).toHaveCount(3); + const changed=page.locator(`article[data-prompt-id="${id}"]`);await expect(changed).toContainText('Revised dog landscape'); + await expect(changed.getByRole('img',{name:'Rated 5 out of 5',exact:true})).toHaveCount(1); + await expect(page.getByRole('button',{name:'Retry prompt library',exact:true})).toBeHidden(); +}); + test('returning from history restores library context and mobile content stays inside the viewport',async({page})=>{ await page.setViewportSize({width:390,height:844});await page.goto(route); await page.getByRole('combobox',{name:'Category',exact:true}).selectOption('Animals'); @@ -110,6 +129,29 @@ test('returning from history restores library context and mobile content stays i await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); }); +test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{ + await page.setViewportSize({width:1600,height:300});await page.goto(route); + await page.getByRole('searchbox',{name:'Search prompts',exact:true}).fill('No matching saved positive'); + await expect(cards(page)).toHaveCount(0);await expect(page.locator('.library-status')).toContainText('No matching prompts.'); + await page.evaluate(()=>scrollTo(0,document.documentElement.scrollHeight)); + await expect.poll(()=>page.evaluate(()=>scrollY)).toBeGreaterThan(0); + const position=await page.evaluate(()=>scrollY); + await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Overview',exact:true}).evaluate(node=>node.click()); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await page.getByRole('navigation',{name:'Workspace',exact:true}).getByRole('link',{name:'Prompt library',exact:true}).evaluate(node=>node.click()); + await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('No matching saved positive'); + await expect(cards(page)).toHaveCount(0); + await expect.poll(()=>page.evaluate(expected=>Math.abs(scrollY-expected),position)).toBeLessThanOrEqual(1); + const search=page.getByRole('searchbox',{name:'Search prompts',exact:true});await search.focus();await search.fill('Dog landscape'); + await expect(cards(page)).toHaveCount(1);await expect(search).toBeFocused(); + await page.evaluate(()=>scrollTo(0,0));await page.getByRole('button',{name:'Refresh library',exact:true}).click();await expect(cards(page)).toHaveCount(1); + expect(await page.evaluate(()=>scrollY)).toBe(0); + const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); + await cards(page).getByRole('link',{name:'Explore history',exact:true}).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); + await page.setViewportSize({width:390,height:844});await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await retainedParent(page,anchor);await expect(page.getByRole('searchbox',{name:'Search prompts',exact:true})).toHaveValue('Dog landscape'); +}); + test('history detail failures retry and returning to the list ignores late detail responses',async({page})=>{ let fail=true;let release;const hold=new Promise(resolve=>{release=resolve;}); await page.route(/\/prompt_manager\/library\/\d+(?:\?|$)/,async route=>{ @@ -168,3 +210,165 @@ test('large variant histories append bounded pages without replacing the selecte await expect(history(page).getByRole('button',{name:'Select negative variant 1',exact:true})).toHaveAttribute('aria-pressed','true'); await expect(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')).toHaveText(chosenNegative); }); + +async function observeLibrary(page, hold=false) { + await page.addInitScript(({hold})=>{ + const NativeResizeObserver=window.ResizeObserver; + const records=[];let holding=hold; + const raf=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window); + const pending=new Set();let requested=0,mutations=0,unmounted=0; + window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{pending.delete(id);callback(time);});pending.add(id);return id;}; + window.cancelAnimationFrame=id=>{pending.delete(id);cancel(id);}; + window.ResizeObserver=class { + constructor(callback) { + const record={callback,targets:new Set(),entries:new Map(),latest:new Map(),disconnected:false}; + record.native=new NativeResizeObserver(entries=>{ + const ready=[]; + for(const entry of entries) { + if(entry.target.matches('.library-card')) { + if(!entry.target.isConnected)unmounted++; + record.latest.set(entry.target,entry); + if(holding) {record.entries.set(entry.target,entry);continue;} + } + ready.push(entry); + } + if(ready.length)callback(ready,this); + });records.push(record);this.record=record; + } + observe(target,options){this.record.targets.add(target);this.record.native.observe(target,options);} + unobserve(target){this.record.targets.delete(target);this.record.entries.delete(target);this.record.latest.delete(target);this.record.native.unobserve(target);} + disconnect(){this.record.disconnected=true;this.record.targets.clear();this.record.entries.clear();this.record.native.disconnect();} + }; + new MutationObserver(entries=>{mutations+=entries.filter(entry=>entry.target.matches('.library-card,.library-row,.library-row-spacer')).length;}).observe(document,{subtree:true,attributes:true,attributeFilter:['style']}); + window.libraryObservation={ + hold(){holding=true;}, + release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}}, + equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}}, + stats(){return {requested,pending:pending.size,mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};}, + }; + },{hold}); +} +async function settleLibrary(page) { + await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0); + await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,60))); + await expect.poll(async()=>page.evaluate(()=>window.libraryObservation.stats().pending)).toBe(0); +} +async function firstVisibleParent(page) { + return cards(page).evaluateAll(nodes=>{ + const card=nodes.find(node=>{const box=node.getBoundingClientRect();return box.bottom>0&&box.topMath.abs(await card.evaluate(node=>node.getBoundingClientRect().top)-anchor.offset)).toBeLessThanOrEqual(2); +} + +test('Library natural rows retain long prompt content without overflow',async({page,request})=>{ + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + const data=await(await request.get('/prompt_manager/library')).json(); + for(const [width,height] of [[1600,1100],[390,844]]) { + await page.setViewportSize({width,height});await page.goto(route);await expect(cards(page)).toHaveCount(6); + expect(await cards(page).filter({hasText:'A study in very long creative context'}).evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560); + await expect(page.locator('.workspace-heading-copy > .intro')).toContainText('One positive.'); + await expect(page.locator('.library-shelf > .intro')).toHaveCount(0); + for(const theme of ['dark','light']) { + await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + await expect(page.locator('.prompt-positive h3').first()).toHaveCSS('color',theme==='dark'?'rgb(115, 221, 169)':'rgb(23, 107, 70)'); + } + await expect(page.locator('.library-card-art').first()).toHaveCSS('height',width===1600?'170px':'150px'); + await expect(page.locator('.library-card-content').first()).toHaveCSS('padding',width===1600?'18px':'15px'); + await expect(page.locator('.library-grid')).toHaveCSS('gap','18px'); + const long=cards(page).filter({hasText:'A study in very long creative context'}); + const prompt=long.locator('.prompt-text');expect((await prompt.textContent()).length).toBeGreaterThan(1500); + expect(await long.evaluate(node=>node.getBoundingClientRect().height)).toBeGreaterThan(560); + for(const selector of ['.library-card-title','.library-card-prompts','.prompt-text','.library-card-content > .library-tags']) { + expect(await long.locator(selector).evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBe(true); + } + await prompt.focus();await expect(prompt).toBeFocused(); + await long.getByRole('link',{name:'Explore history',exact:true}).scrollIntoViewIfNeeded(); + await expect(long.getByRole('link',{name:'Explore history',exact:true})).toBeVisible(); + for(const item of data.items) { + const card=page.locator(`article[data-prompt-id="${item.id}"]`); + await expect(card.getByRole('img',{name:item.rating?`Rated ${item.rating} out of 5`:'Unrated',exact:true})).toHaveCount(1); + await expect(card.locator('.library-card-content > .library-tags .library-chip')).toHaveText(item.tags); + } + expect(await page.evaluate(()=>document.documentElement.scrollWidth-document.documentElement.clientWidth)).toBeLessThanOrEqual(1); + await expect(page.locator('.library-shelf')).not.toContainText(/\b\d+ (?:tracked )?runs\b|Used today/i); + const boxes=await cards(page).evaluateAll(nodes=>nodes.map(node=>{const b=node.getBoundingClientRect();return {x:b.x,y:b.y,right:b.right,bottom:b.bottom};})); + for(let i=0;i{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page,true);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + let release;const held=new Promise(resolve=>{release=resolve;}); + await page.route('**/images/serve/**',async route=>{await held;await route.continue().catch(()=>{});}); + try { + await page.setViewportSize({width:1600,height:1100});await page.goto(route); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} + await expect(page.locator('.library-caption')).toContainText('160 of 203'); + await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+40*486+25)); + await settleLibrary(page);const anchor=await firstVisibleParent(page);expect(anchor).not.toBeNull(); + const pinned=cards(page).first();const pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); + await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,anchor); + expect(await cards(page).count()).toBeLessThan(80);await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + const after=await firstVisibleParent(page);release();await page.evaluate(async()=>{await Promise.all([...document.querySelectorAll('.library-card img')].map(image=>image.decode().catch(()=>{})));});await settleLibrary(page);await retainedParent(page,after); + await more.evaluate(node=>node.click());await expect(page.locator('.library-caption')).toContainText('200 of 203');await settleLibrary(page);await retainedParent(page,after); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + await page.setViewportSize({width:390,height:844});await settleLibrary(page);await retainedParent(page,after); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + expect(await cards(page).count()).toBeLessThan(80); + const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(new Set(ids).size).toBe(ids.length); + for(const changedWidth of [false,true]) { + const before=await firstVisibleParent(page); + await page.locator(`article[data-prompt-id="${before.id}"] .library-history-link`).evaluate(node=>node.click());await expect(history(page)).toBeVisible(); + if(changedWidth)await page.setViewportSize({width:1600,height:1100}); + await page.getByRole('link',{name:'Back to library',exact:true}).click();await settleLibrary(page);await retainedParent(page,before); + expect(await cards(page).count()).toBeLessThan(80); + } + expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0); + } finally {release();} +}); + +test('unchanged row observations settle without resize feedback',async({page,request,browserName})=>{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + let session,breakpoint; + try { + if(browserName==='chromium') { + session=await page.context().newCDPSession(page);await session.send('Debugger.enable'); + await page.addInitScript(()=>{window.libraryLayoutCalls=0;}); + const scripts=[];session.on('Debugger.scriptParsed',script=>scripts.push(script)); + await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page); + const script=scripts.find(script=>script.url.endsWith('/workspace/library-layout.mjs'));expect(script).toBeTruthy(); + const {scriptSource}=await session.send('Debugger.getScriptSource',{scriptId:script.scriptId}); + const lines=scriptSource.split('\n');const start=lines.findIndex(line=>line.includes('export function layoutLibraryRows('));expect(start).toBeGreaterThanOrEqual(0); + const possible=await session.send('Debugger.getPossibleBreakpoints',{start:{scriptId:script.scriptId,lineNumber:start},end:{scriptId:script.scriptId,lineNumber:start+3},restrictToFunction:false}); + const location=possible.locations.find(location=>location.lineNumber>start);expect(location).toBeTruthy(); + breakpoint=(await session.send('Debugger.setBreakpoint',{location,condition:'(window.libraryLayoutCalls++, false)'})).breakpointId; + } else {await page.goto(route);await expect(cards(page).first()).toBeVisible();await settleLibrary(page);} + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} + await expect(page.locator('.library-caption')).toContainText('160 of 203');await settleLibrary(page); + if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBeGreaterThan(0); + const before=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls})); + await page.evaluate(()=>{for(let i=0;i<12;i++){window.libraryObservation.equal();dispatchEvent(new Event('scroll'));}});await settleLibrary(page); + const after=await page.evaluate(()=>({stats:window.libraryObservation.stats(),calls:window.libraryLayoutCalls})); + expect(after.stats.requested-before.stats.requested).toBeLessThanOrEqual(1);expect(after.stats.mutations).toBe(before.stats.mutations);if(session)expect(after.calls).toBe(before.calls); + for(let i=0;i<3;i++) {await page.evaluate(()=>scrollBy(0,1));await settleLibrary(page);} + if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)).toBe(after.calls); + const equalBefore=await page.evaluate(()=>window.libraryObservation.stats().requested);await page.evaluate(()=>{for(let i=0;i<12;i++)window.libraryObservation.equal();});await settleLibrary(page); + expect((await page.evaluate(()=>window.libraryObservation.stats())).requested).toBe(equalBefore); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='35px';});await settleLibrary(page); + const calls=await page.evaluate(()=>window.libraryLayoutCalls);await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page); + if(session)expect(await page.evaluate(()=>window.libraryLayoutCalls)-calls).toBe(1); + await page.evaluate(()=>{dispatchEvent(new Event('scroll'));location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);await settleLibrary(page); + expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0); + } finally {if(breakpoint)await session.send('Debugger.removeBreakpoint',{breakpointId:breakpoint});if(session)await session.detach();} +}); diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index daec0b1..0084617 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -26,7 +26,7 @@ async function attachEvidence(page, testInfo, name, fullPage = true) { const screenshotPath = testInfo.outputPath(`${name}.png`); await page.screenshot({ path: screenshotPath, fullPage }); await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }); - const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading'].map(selector => { + const geometry = await page.evaluate(() => Object.fromEntries(['.sidebar', '.topbar', '#workspace-main', '.workspace-heading', 'h1', '.image-workspace', '.image-grid', '.image-card', '.image-details', '.prompt-block-heading', '.library-toolbar', '.library-grid', '.library-card', '.library-card-art', '.library-card-content'].map(selector => { const node = document.querySelector(selector); if (!node) return [selector, null]; const box = node.getBoundingClientRect(), style = getComputedStyle(node); @@ -97,6 +97,7 @@ test('shell matches approved stable geometry in both themes', async ({ page, req await expect(page.locator('h1')).toHaveCSS('letter-spacing', '-0.7px'); await expect(page.locator('.workspace-heading')).toHaveCSS('margin-block-end', '22px'); await expect(page.locator('h1')).toHaveCSS('font-size', width === 1600 ? '28px' : '25px'); + await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCSS('line-height', '18px'); await expect(page.locator('#workspace-main')).toHaveCSS('padding-inline-start', width === 1600 ? '30px' : '14px'); if (width === 1600) { await expect(page.locator('.sidebar')).toHaveCSS('width', '218px'); @@ -131,8 +132,7 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r await text.focus(); await expect(text).toBeFocused(); expect(await text.evaluate(node => { const scroller = node.closest('.library-card-prompts'); - scroller.scrollTop = scroller.scrollHeight; - return scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1; + return scroller.scrollHeight <= scroller.clientHeight + 1 && node.scrollHeight <= node.clientHeight + 1; })).toBe(true); await noOverflow(page); await page.evaluate(() => { document.documentElement.style.zoom = ''; }); @@ -145,6 +145,27 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r } }); +test('Library visual evidence keeps natural cards and returns from real history in both themes',async({browser,browserName,request},testInfo)=>{ + await request.post('/__test__/reset',{data:{workspace_visual:true}}); + for(const [width,height] of [[1600,1100],[390,844]]) for(const theme of ['dark','light']) { + const context=await browser.newContext({viewport:{width,height},locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'}); + const page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(error.message));await startCoverage(page,browserName); + try { + await page.goto(`${workspace}#/library`);await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); + await page.getByRole('combobox',{name:'Appearance',exact:true}).selectOption(theme); + await attachEvidence(page,testInfo,`after-library-${width}-${theme}`,false); + await noOverflow(page); + await page.getByRole('link',{name:'Explore history',exact:true}).first().click(); + await expect(page.getByRole('region',{name:'Prompt history',exact:true})).toBeVisible(); + await expect(page.locator('.library-history > div > .intro')).toHaveCount(1); + await page.getByRole('link',{name:'Back to library',exact:true}).click(); + await expect(page.locator('article[data-prompt-id]')).toHaveCount(6); + await expect(page.locator('.workspace-heading-copy > .intro')).toHaveCount(1); + expect(errors).toEqual([]); + } finally {await saveCoverage(page,browserName);await context.close();} + } +}); + test('approved icons are decorative safe SVGs and reject unknown vocabulary', async ({ page }) => { await page.goto(`${workspace}#/images`); diff --git a/tests/js/workspace-library-layout.test.js b/tests/js/workspace-library-layout.test.js new file mode 100644 index 0000000..c6a9822 --- /dev/null +++ b/tests/js/workspace-library-layout.test.js @@ -0,0 +1,56 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/library-layout.mjs'); + +test('natural Library rows use the tallest measured card and estimate only missing heights', async () => { + const {layoutLibraryRows} = await source; + const heights = Object.freeze([300, 470, 600, 250, null]); + const layout = layoutLibraryRows(heights, 2); + assert.deepEqual(layout.rows, [ + {first: 0, last: 1, top: 0, height: 470}, + {first: 2, last: 3, top: 488, height: 600}, + {first: 4, last: 4, top: 1106, height: 468}, + ]); + assert.equal(layout.height, 1574); + assert.deepEqual(layoutLibraryRows([300, 470], 1), { + rows: [{first: 0, last: 0, top: 0, height: 300}, {first: 1, last: 1, top: 318, height: 470}], height: 788, + }); + assert.deepEqual(layoutLibraryRows([], 2), {rows: [], height: 0}); +}); + +test('row measurements validate geometry and invalid heights reserve the configured estimate', async () => { + const {layoutLibraryRows} = await source; + assert.deepEqual(layoutLibraryRows([0, -1, NaN, Infinity, undefined, '300'], 2, {estimate: 448, gap: 0}).rows.map(row => row.height), [448, 448, 448]); + for (const columns of [0, -1, 1.5, Infinity, NaN, '2']) assert.throws(() => layoutLibraryRows([], columns), TypeError); + for (const estimate of [0, -1, NaN, Infinity]) assert.throws(() => layoutLibraryRows([], 2, {estimate}), TypeError); + for (const gap of [-1, NaN, Infinity]) assert.throws(() => layoutLibraryRows([], 2, {gap}), TypeError); + const before = layoutLibraryRows([300, 470, 600], 2); + const appended = layoutLibraryRows([300, 470, 600, 250, 310], 2); + assert.deepEqual(appended.rows[0], before.rows[0]); + assert.deepEqual(appended.rows[1], {first: 2, last: 3, top: 488, height: 600}); + assert.equal(appended.height, 1416); +}); + +test('measured windows add two overscan rows and pin distant focus in ascending DOM order', async () => { + const {layoutLibraryRows, visibleLibraryRows} = await source; + const {rows} = layoutLibraryRows(Array(40).fill(100), 2, {gap: 10}); + for (const row of rows) Object.freeze(row); + Object.freeze(rows); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, 0), [0, 3, 4, 5, 6, 7]); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, 39), [3, 4, 5, 6, 7, 19]); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, 40), [3, 4, 5, 6, 7]); + assert.deepEqual(visibleLibraryRows(rows, 550, 100, -1), [3, 4, 5, 6, 7]); + assert.deepEqual(visibleLibraryRows([], 0, 900), []); + assert.deepEqual(visibleLibraryRows(rows, NaN, Infinity), [0, 1, 2]); +}); + +test('measured-window lookup binary searches a 50000-row shelf without walking it', async () => { + const {visibleLibraryRows} = await source; + let reads = 0; + const rows = new Proxy({length: 50000}, {get(target, key) { + if (/^\d+$/.test(String(key))) { reads++; const index = Number(key); return {first: index * 2, last: index * 2 + 1, top: index * 118, height: 100}; } + return Reflect.get(target, key); + }}); + assert.deepEqual(visibleLibraryRows(rows, 1180000, 100, 0), [0, 9998, 9999, 10000, 10001, 10002]); + assert.ok(reads < 500, `${reads} numeric row reads`); +}); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 7efa59a..219db80 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -42,6 +42,12 @@ test('parent shelf window bounds DOM and pins focused rows without changing logi const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24); const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]); }); +test('uniform compatibility window handles huge counts without collection allocation',async()=>{ + const {libraryWindow}=await source;const original=Array.from;const lengths=[]; + Array.from=function(value,...args){lengths.push(value.length);assert.ok(!Number.isFinite(value.length)||value.length<32,'collection-sized allocation');return original.call(this,value,...args);}; + try {const indexes=libraryWindow(Number.MAX_SAFE_INTEGER,2,50000,900,0);assert.ok(indexes.length<32);assert.ok(indexes.includes(0));assert.ok(indexes.includes(2*Math.floor(50000/578)));assert.deepEqual(indexes,[...new Set(indexes)].sort((a,b)=>a-b));assert.ok(lengths.every(length=>length<32));} + finally {Array.from=original;} +}); test('negative variants append continuously without losing selected identity, and failures retry same cursor',async()=>{ const {createLibraryDetail}=await source;const calls=[];let count=0;const v=id=>({id,negative_text:id===9?'':'bad',explicit_empty:id===9}); const loader=createLibraryDetail({api:{get:async url=>{calls.push(url);count++;if(count===2)throw Error('offline');return {positive:item(1),variants:count===1?[v(9)]:[v(9),v(10)],variant_total:2,variants_has_more:count===1,variants_next_cursor:count===1?'next':null,unassigned_image_count:0};}},onChange:()=>{}}); diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 532fc1f..4bfb204 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -220,5 +220,11 @@ "branches": 98, "functions": 98, "statements": 98 + }, + "workspace/library-layout.mjs": { + "lines": 98, + "branches": 98, + "functions": 98, + "statements": 98 } } diff --git a/web/js/workspace/library-layout.mjs b/web/js/workspace/library-layout.mjs new file mode 100644 index 0000000..0723690 --- /dev/null +++ b/web/js/workspace/library-layout.mjs @@ -0,0 +1,42 @@ +export function layoutLibraryRows(heights, columns, {gap = 18, estimate = 468} = {}) { + if (!Number.isSafeInteger(columns) || columns < 1) throw new TypeError('Invalid Library columns'); + if (!Number.isFinite(gap) || gap < 0 || !Number.isFinite(estimate) || estimate <= 0) throw new TypeError('Invalid Library geometry'); + const rows = []; + let top = 0; + for (let first = 0; first < heights.length; first += columns) { + const last = Math.min(first + columns, heights.length) - 1; + let height = 0; + for (let index = first; index <= last; index++) { + const measured = heights[index]; + height = Math.max(height, Number.isFinite(measured) && measured > 0 ? measured : estimate); + } + rows.push({first, last, top, height}); + top += height + gap; + } + return {rows, height: rows.length ? top - gap : 0}; +} + +function lowerBound(rows, after) { + let low = 0, high = rows.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (after(rows[middle])) low = middle + 1; + else high = middle; + } + return low; +} + +export function visibleLibraryRows(rows, top, viewportHeight, focusedIndex = -1) { + if (!rows.length) return []; + top = Number.isFinite(top) ? Math.max(0, top) : 0; + viewportHeight = Number.isFinite(viewportHeight) ? Math.max(0, viewportHeight) : 0; + const first = Math.min(rows.length - 1, lowerBound(rows, row => row.top + row.height <= top)); + const end = Math.max(first + 1, lowerBound(rows, row => row.top < top + viewportHeight)); + const visible = new Set(); + for (let index = Math.max(0, first - 2); index < Math.min(rows.length, end + 2); index++) visible.add(index); + if (Number.isSafeInteger(focusedIndex) && focusedIndex >= 0) { + const focus = lowerBound(rows, row => row.last < focusedIndex); + if (focus < rows.length && rows[focus].first <= focusedIndex) visible.add(focus); + } + return [...visible].sort((a, b) => a - b); +} diff --git a/web/js/workspace/library.css b/web/js/workspace/library.css index 48d560a..b0c8405 100644 --- a/web/js/workspace/library.css +++ b/web/js/workspace/library.css @@ -1,29 +1,31 @@ -main.library-main { --library-positive: #6bd6a4; } +main.library-main { --library-positive: #73dda9; } .library-main :where(.library-shelf, .library-history, .library-card, .library-card-content, .library-card-prompts, .library-linked-images) { min-inline-size: 0; } -.library-main .library-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-block: 20px 16px; } -.library-main .library-toolbar .ca-field { display: flex; flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 5px; } +.library-main .library-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-block: 0 20px; } +.library-main .library-toolbar .ca-field { position: relative; display: flex; min-inline-size: 0; flex: 1 1 140px; align-items: stretch; } .library-main .library-toolbar .ca-field:first-child { flex: 3 1 250px; } -.library-main .library-toolbar :where(input, select) { inline-size: 100%; min-inline-size: 0; min-block-size: 36px; } -.library-main .library-caption { display: flex; justify-content: space-between; font-size: 12px; color: var(--ca-muted); margin-block: 16px; } -.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; } -.library-main .library-card { box-sizing: border-box; block-size: 560px; display: grid; grid-template-rows: 170px minmax(0, 1fr) auto; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); } +.library-main .library-toolbar .ca-field > span { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: clip; clip-path: inset(50%); white-space: nowrap; } +.library-main .library-toolbar :where(input, select, button), .ca-tools .library-main .library-toolbar > button { box-sizing: border-box; inline-size: 100%; min-inline-size: 0; block-size: 36px; min-block-size: 36px; font-size: 12px; } +.ca-tools .library-main .library-toolbar > button { inline-size: auto; } +.library-main .library-caption { display: flex; justify-content: space-between; font-size: 10px; color: var(--ca-muted); margin-block: 0 13px; } +.library-main .library-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; overflow-anchor: none; } +.library-main .library-row { grid-column: 1 / -1; display: grid; grid-template-columns: inherit; gap: 18px; min-inline-size: 0; } +.library-main .library-card { box-sizing: border-box; display: grid; grid-template-rows: 170px 1fr auto; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 8px; background: var(--ca-surface); } .library-main .library-card-art { display: grid; grid-template-columns: 2fr 1fr; gap: 3px; block-size: 170px; border-radius: 7px 7px 0 0; background: var(--ca-field); overflow: clip; } .library-main .library-card-art img { display: block; inline-size: 100%; block-size: 170px; aspect-ratio: 4 / 3; object-fit: cover; } .library-main .library-card-art > :only-child { grid-column: 1 / -1; } .library-main .library-art-empty { display: grid; place-content: center; min-inline-size: 0; font-size: 12px; color: var(--ca-muted); } .library-main .library-card-content { min-block-size: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; } -.library-main .library-card-title { max-block-size: 72px; overflow: auto; flex-shrink: 0; display: flex; align-items: start; justify-content: space-between; gap: 12px; } -.library-main .library-card-title h2 { margin: 0; font-size: 16px; min-inline-size: 0; overflow-wrap: anywhere; } +.library-main .library-card-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; } +.library-main .library-card-title h2 { margin: 0; font-size: 16px; font-weight: 600; min-inline-size: 0; overflow-wrap: anywhere; } .library-main .library-title-link { color: var(--ca-text); text-decoration: none; } .library-main .library-title-link:hover { color: var(--ca-accent); } -.library-main .library-card-prompts { flex: 1 1 auto; overflow: auto; } -.library-main .library-card-prompts .prompt-block { margin: 0; } -.library-main .library-card-prompts .prompt-text { max-block-size: 100px; } -.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 11px; color: var(--ca-muted); } -.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 4px 8px; border: 1px solid var(--ca-line); border-radius: 5px; font-size: 11px; color: var(--ca-muted); overflow-wrap: anywhere; } +.library-main .library-card-prompts .prompt-block { margin: 0; padding-block: 10px 0; } +.library-main .library-card-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: 10px; color: var(--ca-muted); } +.library-main .library-chip { display: inline-block; max-inline-size: 100%; padding: 3px 8px; border: 1px solid var(--ca-line); border-radius: 4px; font-size: 10px; color: var(--ca-muted); background: var(--ca-field); overflow-wrap: anywhere; } .library-main .library-chip-negative { color: var(--ca-error-text); border-color: color-mix(in srgb, var(--ca-error) 35%, var(--ca-line)); } -.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; max-block-size: 50px; overflow: auto; } -.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 12px; } +.library-main .library-tags { display: flex; flex-wrap: wrap; gap: 5px; } +.library-main .library-card-footer { padding: 11px 18px; border-block-start: 1px solid var(--ca-line); display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 10px; } +.library-main .library-rating { color: var(--ca-muted); } .library-main .library-history-link { display: inline-flex; align-items: center; gap: 7px; color: var(--ca-accent); } .library-main .library-history-link svg { inline-size: 18px; block-size: 18px; } .library-main .library-row-spacer { grid-column: 1 / -1; pointer-events: none; } @@ -41,6 +43,6 @@ main.library-main { --library-positive: #6bd6a4; } .library-main .library-shared-gallery { margin-block-start: 24px; } .library-main .library-variant-context { color: var(--ca-muted); font-size: 12px; } @media (min-width: 1700px) { .library-main .library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } } -@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar > button { inline-size: 100%; } } +@media (max-width: 700px) { .library-main .library-grid { grid-template-columns: minmax(0, 1fr); } .library-main .library-surface { padding: 16px; } .library-main .library-card-title { flex-wrap: wrap; gap: 6px; } .library-main .library-toolbar { gap: 8px; } .library-main .library-toolbar .ca-field:first-child { flex-basis: 100%; } .library-main .library-card { grid-template-rows: 150px 1fr auto; } .library-main .library-card-art, .library-main .library-card-art img { block-size: 150px; } .library-main .library-card-content { padding: 15px; } } -[data-ca-theme="light"] main.library-main { --library-positive: #14784f; } +[data-ca-theme="light"] main.library-main { --library-positive: #176b46; } diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index eab6e44..5140b8c 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -2,7 +2,9 @@ import {createScope,createButton,createField,createSelect} from '../shared/toolk import {createPromptSender} from './prompt-sender.mjs'; import {mountPromptBlocks} from './prompt-inspector.mjs'; import {mountImages} from './images.mjs'; -import {createLibraryStream,createLibraryDetail,libraryPromptRoles,libraryWindow} from './library-state.mjs'; +import {createLibraryStream,createLibraryDetail,libraryPromptRoles} from './library-state.mjs'; +import {layoutLibraryRows,visibleLibraryRows} from './library-layout.mjs'; +import {createWorkspaceIcon} from './icons.mjs'; let shelfVisit=null; const variantSelections=new Map(); @@ -11,17 +13,15 @@ function el(tag,text,className) { } function link(text,href,className) {const node=el('a',text,className);node.href=href;return node;} function title(text) {const first=text.split('\n')[0].trim();return first.length>80?`${first.slice(0,80)}…`:first;} -function branchIcon() { - const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('aria-hidden','true');svg.setAttribute('fill','none');svg.setAttribute('stroke','currentColor');svg.setAttribute('stroke-width','1.8'); - const path=document.createElementNS('http://www.w3.org/2000/svg','path');path.setAttribute('d','M6 8v8m0-4h6a6 6 0 0 0 6-6M9 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm0 14a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM21 3a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z');svg.append(path);return svg; -} export function mountLibrary(main,api,parentScope,{positiveId}={}) { const scope=createScope();parentScope.cleanup(()=>scope.dispose()); - main.classList.add('library-main');scope.cleanup(()=>main.classList.remove('library-main')); + main.classList.add('library-main'); const sender=createPromptSender({scope}); - if(positiveId!==undefined) {mountHistory();return;} - mountShelf(); + if(positiveId!==undefined) mountHistory(); + else mountShelf(); + // Save the visit before removing the class that defines the shelf's scroll range. + scope.cleanup(()=>main.classList.remove('library-main')); function mountShelf() { const visit=shelfVisit;const filters={text:'',category:'',tags:[],sort:'newest',...visit?.filters}; @@ -41,10 +41,17 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const continuation=el('div',undefined,'library-continuation'); const more=createButton('Load more prompts');const retry=createButton('Retry prompt library');retry.hidden=true; const legacy=link('Current prompt library',window.ApiClient.resolveUrl('/prompt_manager/admin'),'workspace-link'); - continuation.append(message,retry,more);section.append(introduction,toolbar,optionsStatus,legacy,caption,grid,continuation);main.append(section); - const cards=new Map();let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1,restoreFrame=0; + (main.querySelector('.workspace-heading-copy') || section).append(introduction); + continuation.append(message,retry,more);section.append(toolbar,optionsStatus,caption,grid,continuation,legacy);main.append(section); + const cards=new Map(), rowNodes=new Map(), spacers=new Map(), heights=new Map(), pendingMeasurements=new Map(); + let state,frame=0,loadFrame=0,observer,optionsRequest,focusOverride=-1; const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; - function removeCard(card) {card.blocks.dispose();card.node.remove();} + const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64; + let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map(); + let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, correctedScrollY=null; + let restoreScrollY=Number.isFinite(visit?.scrollY) && visit.scrollY>=0?visit.scrollY:null; + if(visit?.layout?.inlineSize===inlineSize && visit.layout.columns===columnCount) for(const [id,height] of visit.layout.heights) heights.set(id,height); + function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();} function cardFor(item,index) { const article=el('article',undefined,'library-card');article.dataset.promptId=item.id;article.setAttribute('role','listitem'); const art=el('div',undefined,'library-card-art');art.setAttribute('aria-hidden','true'); @@ -65,38 +72,96 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const stats=el('div',undefined,'library-card-meta');stats.append(el('span',item.variant_count?`${item.variant_count} saved negative variants`:'No saved negatives','library-chip library-chip-negative'),el('span',`${item.image_count} linked images`)); const vocabulary=el('div',undefined,'library-tags');for(const tag of item.tags || []) vocabulary.append(el('span',tag,'library-chip')); content.append(heading,prompts,stats,vocabulary); - const footer=el('footer',undefined,'library-card-footer');footer.append(el('span',item.rating?`${item.rating} / 5`:'Unrated','muted')); - const history=link('',`#/library/${item.id}`,'library-history-link');history.append(branchIcon(),el('span','Explore history'));footer.append(history); + const footer=el('footer',undefined,'library-card-footer'); + const rating=el('span',item.rating?'★'.repeat(item.rating)+'☆'.repeat(5-item.rating):'Unrated','library-rating');rating.setAttribute('role','img');rating.setAttribute('aria-label',item.rating?`Rated ${item.rating} out of 5`:'Unrated');footer.append(rating); + const history=link('',`#/library/${item.id}`,'library-history-link');history.append(createWorkspaceIcon('branch'),el('span','Explore history'));footer.append(history); article.append(art,content,footer);article.dataset.index=index;return {node:article,blocks}; } - function renderWindow() { - if(!state || scope.signal.aborted) return; - const active=document.activeElement?.closest('article[data-prompt-id]'); - const focused=focusOverride>=0?focusOverride:active?Number(active.dataset.index):-1; - const size=columns();const top=Math.max(0,-grid.getBoundingClientRect().top); - const indexes=libraryWindow(state.items.length,size,top,window.innerHeight,focused);const keep=new Set(indexes.map(index=>state.items[index].id)); - for(const [id,card] of cards) if(!keep.has(id)) {removeCard(card);cards.delete(id);} - const children=[];let previousRow=-1; - for(const index of indexes) { - const row=Math.floor(index/size); - if(index%size===0 && row>previousRow+1) { - const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${(row-previousRow-1)*578-18}px`;children.push(gap); - } - let card=cards.get(state.items[index].id); - if(!card) {card=cardFor(state.items[index],index);cards.set(state.items[index].id,card);} - card.node.setAttribute('aria-posinset',index+1);card.node.setAttribute('aria-setsize',state.total);children.push(card.node);previousRow=row; + function captureAnchor() { + // Mounted rows can already differ from estimates before their observer batch. + for(const row of grid.children) { + if(!row.classList.contains('library-row'))continue; + const bounds=row.getBoundingClientRect(); + if(bounds.bottom>0 && bounds.top0) {const gap=el('div',undefined,'library-row-spacer');gap.setAttribute('aria-hidden','true');gap.style.blockSize=`${remaining*578-18}px`;children.push(gap);} - // Remove obsolete spacers first: moving a focused card past an old spacer loses focus. - for(const child of [...grid.children]) if(!children.includes(child)) child.remove(); - let previous=null; - for(const child of children) {if(previous?previous.nextElementSibling!==child:grid.firstElementChild!==child) grid.insertBefore(child,previous?previous.nextElementSibling:grid.firstChild);previous=child;} + return null; } - const schedule=()=>{if(!frame) frame=requestAnimationFrame(()=>{frame=0;renderWindow();});}; + function spacer(key,height) { + let node=spacers.get(key); + if(!node) {node=el('div',undefined,'library-row-spacer');node.setAttribute('aria-hidden','true');spacers.set(key,node);} + const size=`${height}px`;if(node.style.blockSize!==size)node.style.blockSize=size; + return node; + } + function renderWindow(topOverride) { + if(!state || scope.signal.aborted) return; + const active=document.activeElement, activeCard=active?.closest('article[data-prompt-id]'); + const focused=focusOverride>=0?focusOverride:activeCard?Number(activeCard.dataset.index):-1; + const top=topOverride??Math.max(0,-grid.getBoundingClientRect().top); + const visible=state.items.length<=120?layout.rows.map((_,index)=>index):visibleLibraryRows(layout.rows,top,window.innerHeight,focused); + const keep=new Set(), children=[];let previous=-1; + for(const rowIndex of visible) { + const row=layout.rows[rowIndex]; + if(rowIndex>previous+1) { + const end=previous<0?0:layout.rows[previous].top+layout.rows[previous].height+18; + children.push(spacer(`${previous}:${rowIndex}`,row.top-end-18)); + } + let rowNode=rowNodes.get(rowIndex); + if(!rowNode) {rowNode=el('div',undefined,'library-row');rowNode.setAttribute('role','presentation');rowNodes.set(rowIndex,rowNode);} + if(!rowNode.isConnected)grid.append(rowNode); + for(let index=row.first;index<=row.last;index++) { + const item=state.items[index];keep.add(item.id);let card=cards.get(item.id); + if(!card) {card=cardFor(item,index);cards.set(item.id,card);rowNode.append(card.node);measurementObserver?.observe(card.node);} + else if(card.node.parentElement!==rowNode)rowNode.append(card.node); + card.node.dataset.index=index;card.node.setAttribute('aria-posinset',index+1);card.node.setAttribute('aria-setsize',state.total); + } + children.push(rowNode);previous=rowIndex; + } + for(const [id,card] of cards) if(!keep.has(id)) {removeCard(card);cards.delete(id);} + if(previous>=0 && previous.5) {heights.set(id,measurement.height);dirty=true;} + } + pendingMeasurements.clear(); + const restore=dirty?(pendingAnchor || anchor):null; + if(dirty) {layout=layoutLibraryRows(state.items.map(item=>heights.get(item.id)??null),columnCount,{estimate:columnCount===1?448:468});dirty=false;} + const index=restore?indexesById.get(restore.id):undefined; + const target=index===undefined?undefined:Math.max(0,layout.rows[Math.floor(index/columnCount)].top-restore.offset); + renderWindow(target); + const card=index===undefined?null:cards.get(restore.id); + if(card) { + const delta=card.node.getBoundingClientRect().top-restore.offset;if(Math.abs(delta)>.5){window.scrollBy(0,delta);correctedScrollY=window.scrollY;} + anchor=restore; + } else { + if(restoreScrollY!==null) {window.scrollTo(0,restoreScrollY);correctedScrollY=window.scrollY;} + anchor=captureAnchor(); + } + restoreScrollY=null;pendingAnchor=null;scheduleLoad(); + } + const schedule=()=>{if(!frame)frame=requestAnimationFrame(flush);}; + const measurementObserver=typeof ResizeObserver==='function'?new ResizeObserver(entries=>{ + const width=readInlineSize();let changed=width!==inlineSize || columns()!==columnCount; + for(const entry of entries) { + const id=Number(entry.target.dataset.promptId);if(!cards.has(id) || cards.get(id).node!==entry.target)continue; + const height=entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height; + if(Number.isFinite(height) && height>0 && (width!==inlineSize || Math.abs((pendingMeasurements.get(id)?.height??heights.get(id)??0)-height)>.5)) {pendingMeasurements.set(id,{height,width});changed=true;} + } + if(changed) {pendingAnchor ||= anchor;schedule();} + }):null; + measurementObserver?.observe(grid); function focusCard(index,last=false) { if(index<0 || index>=state.items.length) return; - focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node; + if(dirty) {cancelAnimationFrame(frame);flush();}focusOverride=index;renderWindow();const card=cards.get(state.items[index].id).node; const controls=card.querySelectorAll('a,button');(last?controls[controls.length-1]:controls[0]).focus();card.scrollIntoView({block:'nearest'});focusOverride=-1;renderWindow(); } scope.listen(grid,'keydown',event=>{ @@ -110,18 +175,26 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } }); function render(next) { + if(items!==next.items) { + pendingAnchor ||= captureAnchor();items=next.items;indexesById=new Map(items.map((item,index)=>[item.id,index]));dirty=true; + if(!items.length) { + anchor=null;pendingAnchor=null;heights.clear();layout={rows:[],height:0}; + for(const card of cards.values())removeCard(card); + cards.clear();rowNodes.clear();spacers.clear();grid.replaceChildren(); + } + } state=next; caption.textContent=`${next.items.length} of ${next.total} positive prompts · Images are grouped by positive prompt`; message.textContent=next.error?'Prompt library could not be loaded. Retry or refresh.':next.loading?'Loading prompt library…':!next.items.length?'No matching prompts. Try changing the filters.':!next.hasMore?'You’re all caught up.':''; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); - renderWindow();scheduleLoad(); + schedule();scheduleLoad(); } const stream=createLibraryStream({api,onChange:render,restore:visit?{filters,state:visit.state}:undefined}); function maybeLoad() { if(!observer || !state?.hasMore || state.loading || state.error || scope.signal.aborted) return; const bounds=continuation.getBoundingClientRect();if(bounds.top<=window.innerHeight+250 && bounds.bottom>=0) stream.loadMore(); } - function scheduleLoad() {if(!loadFrame) loadFrame=requestAnimationFrame(()=>{loadFrame=0;maybeLoad();});} + function scheduleLoad() {if(observer && !loadFrame) loadFrame=requestAnimationFrame(()=>{loadFrame=0;maybeLoad();});} if(typeof IntersectionObserver==='function') {observer=new IntersectionObserver(entries=>{if(entries.some(entry=>entry.isIntersecting)) maybeLoad();},{rootMargin:'250px'});observer.observe(continuation);} async function loadCategories() { optionsRequest?.abort();const request=optionsRequest=new AbortController(); @@ -130,14 +203,14 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const values=[...new Set([...data.categories,filters.category].filter(Boolean))];category.replaceChildren(new Option('All categories',''),...values.map(value=>new Option(value,value)));category.value=filters.category;optionsStatus.hidden=true; } catch {if(!scope.signal.aborted && optionsRequest===request) {optionsStatus.textContent='Categories could not be loaded. Refresh library to retry.';optionsStatus.hidden=false;}} } - const reset=()=>stream.reset(filters); + const reset=()=>{restoreScrollY=null;return stream.reset(filters);}; scope.listen(search,'input',scope.debounce(()=>{filters.text=search.value.trim();reset();},200)); scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200)); scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();}); scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore()); - scope.listen(window,'scroll',schedule);scope.listen(window,'resize',schedule); - scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);cancelAnimationFrame(restoreFrame);for(const card of cards.values()) removeCard(card);cards.clear();}); - loadCategories();if(visit) {render(stream.state);restoreFrame=requestAnimationFrame(()=>{if(!scope.signal.aborted) {window.scrollTo(0,visit.scrollY);renderWindow();scheduleLoad();}});if(visit.state.loading || visit.state.error) stream.loadMore();} else reset(); + scope.listen(window,'scroll',()=>{if(window.scrollY===correctedScrollY)correctedScrollY=null;else if(!pendingAnchor)anchor=captureAnchor();schedule();});scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;schedule();}); + scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:pendingAnchor || anchor || captureAnchor()}}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); + loadCategories();if(visit) {render(stream.state);if(visit.state.loading || visit.state.error)stream.loadMore();} else reset(); } function mountHistory() { diff --git a/web/js/workspace/style.css b/web/js/workspace/style.css index 7baa396..5fe407a 100644 --- a/web/js/workspace/style.css +++ b/web/js/workspace/style.css @@ -36,7 +36,7 @@ main { flex: 1; padding: 30px; inline-size: 100%; max-inline-size: 1900px; min-i h1 { font-size: 28px; line-height: 1.3; letter-spacing: -0.7px; margin: 0; font-weight: 620; overflow-wrap: anywhere; } .workspace-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-block-end: 22px; } .workspace-heading-copy { min-inline-size: 0; } -.workspace-heading-copy .intro { font-size: 12px; margin: 6px 0 0; } +.workspace-heading-copy .intro { font-size: 12px; line-height: 1.5; margin: 6px 0 0; } h2 { font-size: 18px; margin: 0 0 8px; } h3 { font-size: 15px; margin: 0; } p { line-height: 1.6; }