fix(workspace): contain zoomed controls and image metadata (#215)

This commit is contained in:
Vito Sansevero
2026-10-18 02:30:01 -07:00
parent 98b7365cb2
commit 4a37ac4fde
7 changed files with 320 additions and 17 deletions
+250
View File
@@ -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']) {
+16
View File
@@ -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);
}
});
+4 -2
View File
@@ -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 = {}) {
+10 -5
View File
@@ -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; }
+34 -7
View File
@@ -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;
+3 -1
View File
@@ -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']]];
+3 -2
View File
@@ -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; }