From 4a37ac4fde979969a929d1ac5195e9d6a0e1732f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 18 Oct 2026 02:30:01 -0700 Subject: [PATCH] fix(workspace): contain zoomed controls and image metadata (#215) --- tests/browser/workspace-visual.spec.mjs | 250 ++++++++++++++++++++++++ tests/js/workspace-layout.test.js | 16 ++ web/js/workspace/image-layout.mjs | 6 +- web/js/workspace/images.css | 15 +- web/js/workspace/images.mjs | 41 +++- web/js/workspace/index.mjs | 4 +- web/js/workspace/style.css | 5 +- 7 files changed, 320 insertions(+), 17 deletions(-) diff --git a/tests/browser/workspace-visual.spec.mjs b/tests/browser/workspace-visual.spec.mjs index 00846173..75d2f1a8 100644 --- a/tests/browser/workspace-visual.spec.mjs +++ b/tests/browser/workspace-visual.spec.mjs @@ -145,6 +145,256 @@ test('long content and zoom keep grouped navigation reachable', async ({ page, r } }); +test('Appearance label and control fit the narrow shell at 200 percent zoom', async ({ page, request }, testInfo) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${workspace}#/images`); + await expect(page.locator('article[data-image-key]')).toHaveCount(24); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + const preference = page.getByRole('combobox', { name: 'Appearance', exact: true }); + const bounds = await preference.evaluate(select => { + const field = select.closest('label'); + return { intendedWrapper: field.classList.contains('workspace-appearance'), + boxes: [field, field.querySelector('.ca-field-label'), select].map(node => { + const box = node.getBoundingClientRect(); return { left: box.left, right: box.right, width: box.width }; + }), width: document.documentElement.clientWidth }; + }); + for (const box of bounds.boxes) { + expect(box.width).toBeGreaterThan(0); + expect(box.left).toBeGreaterThanOrEqual(-1); + expect(box.right).toBeLessThanOrEqual(bounds.width + 1); + } + expect(bounds.intendedWrapper).toBe(true); + for (const theme of ['follow', 'light', 'dark']) { + await preference.selectOption(theme); await expect(preference).toHaveValue(theme); + } + await preference.focus(); await expect(preference).toBeFocused(); + await noOverflow(page); + await attachEvidence(page, testInfo, 'zoom-shell-390', false); +}); + +async function containedImageContent(page) { + return page.locator('article[data-image-key]').evaluateAll(nodes => { + const failures = []; + for (const card of nodes) { + const outer = card.getBoundingClientRect(); + for (const selector of ['.image-card-footer', 'strong', 'small', '.image-card-footer button']) { + const node = card.querySelector(selector), box = node.getBoundingClientRect(); + if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 || + box.top < outer.top - 1 || box.bottom > outer.bottom + 1 || + (selector === 'small' && node.scrollHeight > node.clientHeight + 1)) { + failures.push({ key: card.dataset.imageKey, selector, + card: { left: outer.left, right: outer.right, top: outer.top, bottom: outer.bottom }, + content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } }); + } + } + const action = card.querySelector('.image-card-footer button').getBoundingClientRect(); + const range = document.createRange(); range.selectNodeContents(card.querySelector('small')); + for (const text of range.getClientRects()) { + if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 && + Math.min(text.bottom, action.bottom) - Math.max(text.top, action.top) > .5) { + failures.push({ key: card.dataset.imageKey, selector: 'facts/action overlap', + text: { left: text.left, right: text.right, top: text.top, bottom: text.bottom }, + action: { left: action.left, right: action.right, top: action.top, bottom: action.bottom } }); + } + } + } + return failures; + }); +} + +for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of ['Grid', 'List', 'Masonry']) { + test(`${layout} contains real card text and keyboard actions at ${width}px with 200 percent zoom`, async ({ page, request }, testInfo) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + await page.setViewportSize({ width, height }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'); + await expect(cards).toHaveCount(24); + await page.getByRole('button', { name: layout, exact: true }).click(); + const firstKey = await cards.first().getAttribute('data-image-key'); + for (const zoom of [1, 2]) { + await page.evaluate(zoom => { document.documentElement.style.zoom = String(zoom); }, zoom); + for (const density of ['comfortable', 'large', 'compact']) { + await page.getByRole('combobox', { name: 'Thumbnail size', exact: true }).selectOption(density); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await orderedMedia(page); + const columns = await cards.evaluateAll(nodes => { + const top = nodes[0].getBoundingClientRect().top; + return nodes.filter(node => Math.abs(node.getBoundingClientRect().top - top) < 1).length; + }); + if (zoom === 1) expect(columns).toBe(layout === 'List' ? 1 : width === 390 || density === 'large' ? 2 : density === 'compact' ? 4 : 3); + else expect(columns).toBe(layout === 'List' || width === 390 ? 1 : density === 'large' ? 2 : density === 'compact' ? 4 : 3); + if (zoom === 1 && layout === 'List') await expect(cards.first()).toHaveCSS('height', '97px'); + await cards.first().locator('.image-open').focus(); await page.keyboard.press('ArrowDown'); + await expect(cards.nth(columns).locator('.image-open')).toBeFocused(); + await page.keyboard.press('ArrowUp'); await expect(cards.first().locator('.image-open')).toBeFocused(); + expect(await cards.first().getAttribute('data-image-key')).toBe(firstKey); + } + const longCard = cards.filter({ hasText: 'a-very-long-geometric-composition-' }); + const filename = await longCard.locator('strong').textContent(); + expect(filename).toMatch(/layered-context-.*\.png/); + await longCard.locator('.image-open').focus(); await page.keyboard.press('Enter'); + await expect(page.locator('.image-details h2')).toHaveText(filename); + const fullPreview = page.getByRole('button', { name: 'Open full preview', exact: true }); + await fullPreview.focus(); await expect(fullPreview).toBeFocused(); await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { name: 'Image preview', exact: true })).toBeVisible(); + await expect(page.locator('.image-preview > header h2')).toHaveText(filename); + await page.keyboard.press('Escape'); + await longCard.locator('.image-open').focus(); await page.keyboard.press('Tab'); + await expect(longCard.getByRole('button', { name: `Preview ${filename}`, exact: true })).toBeFocused(); + await page.keyboard.press('Enter'); await expect(page.locator('dialog[open]')).toHaveCount(1); + await page.keyboard.press('Escape'); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await orderedMedia(page); + await page.evaluate(() => scrollTo(0, 0)); + await attachEvidence(page, testInfo, `zoom-${layout.toLowerCase()}-${width}-${zoom}`, false); + } + }); +} + +test('zoomed virtual Images keeps the visible real media mounted during native scrolling', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await request.post('/__test__/reset', { data: { extra_images: 200 } }); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'); + await expect(cards).toHaveCount(40); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0); + const stride = await cards.first().evaluate(node => node.getBoundingClientRect().height + 26); + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + for (const loaded of [80, 120, 160, 200, 202]) { + await more.click(); await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`); + } + await expect.poll(() => cards.count()).toBeLessThan(120); + await page.evaluate(stride => { + const grid = document.querySelector('.image-grid'); + scrollTo(0, grid.getBoundingClientRect().top + scrollY + stride * 100 - 120); + }, stride); + await expect(page.locator('article[data-index="100"]')).toBeVisible(); + await expect(page.locator('article[data-index="101"]')).toHaveCount(1); + const visible = page.locator('article[data-index="100"]'); + await visible.locator('.image-open').focus(); await page.keyboard.press('ArrowDown'); + await expect(page.locator('article[data-index="101"] .image-open')).toBeFocused(); + await page.keyboard.press('ArrowUp'); await expect(visible.locator('.image-open')).toBeFocused(); + await expect(visible).toBeInViewport(); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await orderedMedia(page); + await expect.poll(() => cards.count()).toBeLessThan(120); +}); + +test('supplemental extreme zoom grows real footers preserves the visible anchor and resets reservations', async ({ page, request }) => { + await request.post('/__test__/reset', { data: { workspace_visual: true } }); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid'); + await expect(cards).toHaveCount(24); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0); + const selected = cards.nth(16), key = await selected.getAttribute('data-image-key'); + await selected.locator('.image-open').click(); await selected.locator('.image-open').focus(); + const filename = await selected.locator('strong').textContent(); + await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120)); + await page.evaluate(() => { + const grid = document.querySelector('.image-grid'), footers = [...grid.querySelectorAll('.image-card-footer')]; + const nativeHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight').get; + const descriptors = footers.map(node => [node, Object.getOwnPropertyDescriptor(node, 'offsetHeight')]); + const restore = () => { for (const [node, descriptor] of descriptors) { + if (descriptor) Object.defineProperty(node, 'offsetHeight', descriptor); else delete node.offsetHeight; + } }; + window.footerMeasurementProbe = { before: null, restore }; + for (const footer of footers) Object.defineProperty(footer, 'offsetHeight', { configurable: true, get() { + const height = nativeHeight.call(this); + if (document.documentElement.style.zoom === '4' && grid.style.getPropertyValue('--image-footer-height') === '54px' && + Math.abs(Number.parseFloat(this.closest('.image-card').style.width) - Number.parseFloat(getComputedStyle(grid).width)) < .01) { + const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight); + const first = [...grid.children].find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; }); + window.footerMeasurementProbe.before = first ? { index: Number(first.dataset.index), key: first.dataset.imageKey, + top: first.getBoundingClientRect().top - top } : null; + restore(); + } + return height; + } }); + document.documentElement.style.zoom = '4'; + const box = grid.getBoundingClientRect(); + // The approved initial reservation is 54px with 4:3 media and a 13px row gap. + const stride = (box.width / 4 - 2) * .75 + 54 + 2 + 13; + // Place row eight inside the 52px scaled gap so the preceding row is not visible. + const index = 8; + scrollTo(0, box.top + scrollY + index * stride * 4 - 16); + }); + try { + await expect.poll(() => page.evaluate(() => window.footerMeasurementProbe.before?.index)).toBe(8); + const anchor = await page.evaluate(() => window.footerMeasurementProbe.before); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); + await expect.poll(() => containedImageContent(page)).toEqual([]); + const visibleAnchor = page.locator(`article[data-index="${anchor.index}"]`); + const anchorTop = await visibleAnchor.evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)); + expect(Math.abs(anchorTop - anchor.top)).toBeLessThanOrEqual(2); + await expect(page.locator(`article[data-image-key="${key}"]`)).toHaveClass(/selected/); + await expect(selected.locator('.image-open')).toBeFocused(); + await expect(page.locator('.image-details h2')).toHaveText(filename); + const mutations = await grid.evaluate(async node => { + let changes = 0; + const observer = new MutationObserver(records => { changes += records.length; }); + observer.observe(node, { subtree: true, attributes: true, attributeFilter: ['style'] }); + for (let frame = 0; frame < 6; frame++) await new Promise(requestAnimationFrame); + observer.disconnect(); return changes; + }); + expect(mutations).toBe(0); + await page.getByRole('button', { name: 'List', exact: true }).click(); + await expect.poll(() => containedImageContent(page)).toEqual([]); + expect(await cards.first().evaluate(node => node.offsetHeight)).toBeGreaterThan(97); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('no-real-media-match'); + await expect(cards).toHaveCount(0); + expect(await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill(''); + await expect(cards).toHaveCount(24); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); + const extremeFooter = await grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height'))); + await page.evaluate(() => { document.documentElement.style.zoom = '2'; }); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeLessThan(extremeFooter); + await expect(cards.first()).toHaveCSS('height', '97px'); + await expect.poll(() => containedImageContent(page)).toEqual([]); + await page.getByRole('button', { name: 'Grid', exact: true }).click(); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBe(54); + await orderedMedia(page); + } finally { await page.evaluate(() => { + window.footerMeasurementProbe?.restore(); delete window.footerMeasurementProbe; + }); } +}); + +test('supplemental wrapped real footer reservation survives append without moving the visible card', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await request.post('/__test__/reset', { data: { extra_images: 200 } }); + await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`); + const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid'); + await expect(cards).toHaveCount(40); + await cards.first().locator('.image-open').click(); + const selectedKey = await cards.first().getAttribute('data-image-key'); + await page.evaluate(() => { document.documentElement.style.zoom = '4'; }); + await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54); + let release, pending = false; + const held = new Promise(resolve => { release = resolve; }); + await page.route('**/images/output?**', async route => { + if (new URL(route.request().url()).searchParams.has('cursor')) { pending = true; await held; } + await route.continue(); + }); + try { + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + await more.click(); await expect.poll(() => pending).toBe(true); + const anchor = await cards.evaluateAll(nodes => { + const top = visualViewport?.offsetTop || 0, bottom = top + (visualViewport?.height || innerHeight); + const node = nodes.find(node => { const box = node.getBoundingClientRect(); return box.bottom > top && box.top < bottom; }); + return node ? { key: node.dataset.imageKey, top: node.getBoundingClientRect().top - top } : null; + }); + expect(anchor).not.toBeNull(); + release(); await expect(page.locator('.image-toolbar')).toContainText('80 of 202 images'); + await expect(cards).toHaveCount(80); + await expect.poll(async () => Math.abs(await page.locator(`article[data-image-key="${anchor.key}"]`).evaluate(node => node.getBoundingClientRect().top - (visualViewport?.offsetTop || 0)) - anchor.top)).toBeLessThanOrEqual(1); + await expect(page.locator(`article[data-image-key="${selectedKey}"]`)).toHaveClass(/selected/); + await expect(more).toBeFocused(); + await expect.poll(() => containedImageContent(page)).toEqual([]); + } finally { release(); } +}); + 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']) { diff --git a/tests/js/workspace-layout.test.js b/tests/js/workspace-layout.test.js index 18368737..59039dcd 100644 --- a/tests/js/workspace-layout.test.js +++ b/tests/js/workspace-layout.test.js @@ -119,3 +119,19 @@ test('masonry uses real finite proportions and falls back for invalid dimensions assert.equal(positions[1].height,(positions[1].width-2)/10+56); for (const position of positions) assert.ok(Number.isFinite(position.height)); }); + +test('gallery width limits density columns without changing ordinary workspace geometry', async () => { + const {workspaceImageMetrics, layoutImages} = await source; + for (const [viewport, width, density, columns] of [ + [1600,990,'comfortable',3], [1600,990,'compact',4], + [951,428,'compact',3], [390,362,'comfortable',2], + [1600,190,'comfortable',1], [1600,190,'compact',1], + [390,167,'large',1], [390,167,'compact',1], [390,69.5,'comfortable',1], [390,0,'comfortable',1], + ]) { + const metrics = workspaceImageMetrics(viewport,density,width); + assert.equal(metrics.columns,columns,`${viewport}/${width}/${density}`); + const {positions} = layoutImages(Array(8).fill({}),width,'grid',240,metrics); + assert.equal(positions[columns].x,0); + assert.ok(positions[columns].y > 0); + } +}); diff --git a/web/js/workspace/image-layout.mjs b/web/js/workspace/image-layout.mjs index 67945aa9..0fdfb427 100644 --- a/web/js/workspace/image-layout.mjs +++ b/web/js/workspace/image-layout.mjs @@ -1,9 +1,11 @@ -export function workspaceImageMetrics(viewportWidth, density = 'comfortable') { +export function workspaceImageMetrics(viewportWidth, density = 'comfortable', availableWidth = Infinity) { if (!['comfortable', 'large', 'compact'].includes(density)) throw new TypeError('Unknown thumbnail density'); const columns = viewportWidth <= 700 || density === 'large' ? 2 : density === 'compact' ? (viewportWidth >= 1700 ? 5 : viewportWidth <= 1250 ? 3 : 4) : viewportWidth >= 1700 ? 4 : viewportWidth <= 1250 ? 2 : 3; - return {columns, gap:13, footerHeight:54, listHeight:97}; + const gap = 13, minimumCardWidth = 120; + const fittingColumns = Math.max(1, Math.floor((availableWidth + gap) / (minimumCardWidth + gap))); + return {columns: Math.min(columns, fittingColumns), gap, footerHeight:54, listHeight:97}; } export function layoutImages(images, width, mode, minWidth = 240, metrics = {}) { diff --git a/web/js/workspace/images.css b/web/js/workspace/images.css index 463575b6..89001c76 100644 --- a/web/js/workspace/images.css +++ b/web/js/workspace/images.css @@ -16,22 +16,23 @@ .image-layouts button { display: inline-flex; align-items: center; gap: 6px; } .image-layouts .workspace-icon { inline-size: 14px; block-size: 14px; } .ca-tools .image-layouts button[aria-pressed="true"] { background: var(--ca-accent); color: white; border-color: var(--ca-accent); } +.images-main { container: images-page / inline-size; } .image-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; gap: 22px; align-items: start; } .image-collection { min-inline-size: 0; } .image-grid { position: relative; inline-size: 100%; } -.image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) 54px; } +.image-card { position: absolute; min-inline-size: 0; border: 1px solid var(--ca-line); border-radius: 7px; background: var(--ca-surface); display: grid; grid-template-rows: minmax(0, 1fr) var(--image-footer-height, 54px); } .image-card.selected { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: 0; } .image-card .image-open { display: block; padding: 0; inline-size: 100%; block-size: 100%; min-block-size: 0; border: 0; border-radius: 6px 6px 0 0; background: var(--ca-field); } .image-open :where(img, video) { display: block; inline-size: 100%; block-size: 100%; object-fit: cover; border-radius: inherit; } .image-grid[data-layout="grid"] .image-open { aspect-ratio: 4 / 3; } -.image-card-footer { position: relative; min-inline-size: 0; padding: 10px 11px; } +.image-card-footer { position: relative; align-self: start; min-inline-size: 0; min-block-size: 54px; padding: 10px 11px; } .image-card-footer strong { display: block; padding-inline-end: 24px; font-size: 11px; line-height: 1.4; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 2px; overflow-wrap: anywhere; } +.image-card-footer small { display: block; color: var(--ca-muted); font-size: 9px; line-height: 1.4; margin-block-start: 6px; overflow-wrap: anywhere; } .ca-tools .image-card-footer button { position: absolute; inset-inline-end: 6px; inset-block-start: 5px; inline-size: 26px; block-size: 26px; min-block-size: 26px; padding: 5px; } .image-card-footer .workspace-icon { inline-size: 14px; block-size: 14px; } -.image-grid[data-layout="list"] .image-card { grid-template-columns: 160px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); } +.image-grid[data-layout="list"] .image-card { grid-template-columns: min(160px, 45%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); } .image-grid[data-layout="list"] .image-open { border-radius: 6px 0 0 6px; } -.image-grid[data-layout="list"] .image-card-footer { align-self: center; } +.image-grid[data-layout="list"] .image-card-footer { align-self: center; min-block-size: 0; } .image-details { position: sticky; inset-block-start: 20px; padding: 17px; border-radius: 7px; min-inline-size: 0; max-block-size: calc(100dvh - 40px); overflow: auto; } .image-details h2 { font-size: 14px; overflow-wrap: anywhere; margin-block: 0 12px; } .image-details > :where(img, video) { display: block; inline-size: calc(100% + 34px); block-size: 150px; margin: -17px -17px 17px; object-fit: cover; background: var(--ca-field); border-radius: 6px 6px 0 0; } @@ -65,6 +66,10 @@ .image-view-controls { gap: 8px; } .image-view-controls > .workspace-link { margin-inline-start: 0; } } +@container images-page (inline-size < 600px) { + .image-workspace { grid-template-columns: minmax(0, 1fr); } + .image-details { position: static; max-block-size: none; } +} .image-continuation button[aria-disabled="true"] { opacity: .55; cursor: wait; } .options-status { font-size: 12px; padding-inline: 4px; } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 928edffd..8e38d407 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -32,7 +32,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage 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 state, geometry = { positions: [], height: 0, maxHeight: 0 }, optionsRequest; + let state, geometry = { positions: [], height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest; const rendered = new Map(); const itemIndexes = new Map(); const filters = { root: 'all', q: '', subfolder: '', media: 'all', sort: 'newest', ...visit?.filters }; @@ -243,11 +243,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage function renderVisible() { if (!state || scope.signal.aborted) return; - const top = Math.max(0, -grid.getBoundingClientRect().top); + const bounds = grid.getBoundingClientRect(); + const scale = bounds.width / Number.parseFloat(getComputedStyle(grid).width) || 1; + const top = Math.max(0, -bounds.top / scale); + const viewportHeight = window.innerHeight / scale; const active = document.activeElement?.closest('article[data-image-key]')?.dataset.imageKey; const focused = active ? itemIndexes.get(active) ?? -1 : -1; const indexes = state.items.length <= 120 ? state.items.map((_, index) => index) : - visibleImages(geometry.positions, top, window.innerHeight, focused, geometry.maxHeight); + visibleImages(geometry.positions, top, viewportHeight, focused, geometry.maxHeight); const keep = new Set(indexes.map(index => imageKey(state.items[index]))); for (const [key, card] of rendered) if (!keep.has(key)) { for (const video of card.querySelectorAll('video')) releaseVideo(video); @@ -266,6 +269,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage if (previous ? previous.nextElementSibling !== card : grid.firstElementChild !== card) grid.insertBefore(card, previous ? previous.nextElementSibling : grid.firstChild); previous = card; } + // Natural footer measurements and absolute reservations share unzoomed CSS units. + const footerHeight = Math.max(metrics.footerHeight, ...[...rendered.values()].map(card => card.querySelector('.image-card-footer').offsetHeight)); + if (footerHeight > metrics.footerHeight) { + const anchor = indexes.find(index => geometry.positions[index].y + geometry.positions[index].height >= top && geometry.positions[index].y <= top + viewportHeight); + const anchorTop = anchor === undefined ? 0 : geometry.positions[anchor].y; + metrics = {...metrics, footerHeight, listHeight: Math.max(metrics.listHeight, footerHeight + 2)}; + updateGeometry(); + for (const index of indexes) positionCard(rendered.get(imageKey(state.items[index])), index); + // A later mounted footer must not move the current visible card. + const correction = anchor === undefined ? 0 : geometry.positions[anchor].y - anchorTop; + if (top > 0 && correction) window.scrollBy(0, correction * scale); + scheduleVisible(); + } } function positionCard(card, index) { const image = state.items[index], position = geometry.positions[index]; @@ -306,14 +322,25 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage } }); function scheduleVisible() { if (!frame) frame = requestAnimationFrame(() => { frame = 0; renderVisible(); }); } + function updateGeometry() { + geometry = layoutImages(state.items, galleryWidth, layout, 240, metrics); + grid.style.height = `${geometry.height}px`; + grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`); + } function updateLayout() { if (!state || scope.signal.aborted) return; - grid.dataset.layout = layout; cancelAnimationFrame(frame); frame = 0; - metrics = workspaceImageMetrics(window.innerWidth, density); + const width = Number.parseFloat(getComputedStyle(grid).width); + const retained = state.items.length && width === galleryWidth && grid.dataset.layout === layout && grid.dataset.density === density ? metrics : null; + galleryWidth = width; + metrics = workspaceImageMetrics(window.innerWidth, density, galleryWidth); + if (retained) { + metrics.footerHeight = retained.footerHeight; + metrics.listHeight = retained.listHeight; + } + grid.dataset.layout = layout; grid.dataset.density = density; - geometry = layoutImages(state.items, grid.clientWidth, layout, 240, metrics); - grid.style.height = `${geometry.height}px`; + updateGeometry(); renderVisible(); } let resizeFrame = 0, observedWidth; diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index 5d8ed64b..596cfecb 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -34,7 +34,9 @@ function mountWorkspace() { const theme = mountWorkspaceTheme({ root: document.body, scope }); const preference = createSelect([['follow', 'Follow ComfyUI'], ['light', 'Light'], ['dark', 'Dark']], ''); preference.value = theme.preference; - appearance.replaceChildren(createField('Appearance', preference, 'workspace-appearance')); + const appearanceField = createField('Appearance', preference, 'workspace-appearance'); + appearanceField.classList.add('workspace-appearance'); + appearance.replaceChildren(appearanceField); scope.listen(preference, 'change', () => theme.setPreference(preference.value)); const icons = { images: 'images', library: 'library', tags: 'tags', inspect: 'upload', overview: 'overview', system: 'settings' }; const groups = [['primary', 'WORKSPACE', ['images', 'library', 'tags', 'inspect']], ['tools', 'TOOLS', ['overview', 'system']]]; diff --git a/web/js/workspace/style.css b/web/js/workspace/style.css index 5fe407a6..3297591e 100644 --- a/web/js/workspace/style.css +++ b/web/js/workspace/style.css @@ -29,8 +29,9 @@ nav [aria-current="page"] { color: var(--ca-focus); background: var(--workspace- .connection-dot { inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--ca-muted); flex: 0 0 auto; } .workspace-body { min-inline-size: 0; display: flex; flex-direction: column; } .topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; block-size: 68px; padding-inline: 30px; border-block-end: 1px solid var(--ca-line); font-size: 12px; } -.workspace-appearance { display: flex; align-items: center; gap: 7px; font-size: 11px; } -.ca-tools .workspace-appearance select { font-size: 11px; } +#appearance-control { min-inline-size: 0; max-inline-size: 100%; } +.ca-tools .workspace-appearance { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; min-inline-size: 0; max-inline-size: 100%; font-size: 11px; } +.ca-tools .workspace-appearance select { min-inline-size: 0; max-inline-size: 100%; font-size: 11px; } .muted { color: var(--ca-muted); } main { flex: 1; padding: 30px; inline-size: 100%; max-inline-size: 1900px; min-inline-size: 0; margin-inline: auto; } h1 { font-size: 28px; line-height: 1.3; letter-spacing: -0.7px; margin: 0; font-weight: 620; overflow-wrap: anywhere; }