diff --git a/docs/screenshots/live-image-loader/classic.png b/docs/screenshots/live-image-loader/classic.png index 6f7ad11..d072dce 100644 Binary files a/docs/screenshots/live-image-loader/classic.png and b/docs/screenshots/live-image-loader/classic.png differ diff --git a/docs/screenshots/live-image-loader/nodes2.png b/docs/screenshots/live-image-loader/nodes2.png index d181bd9..4c1c4c9 100644 Binary files a/docs/screenshots/live-image-loader/nodes2.png and b/docs/screenshots/live-image-loader/nodes2.png differ diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index 48b5141..fd091d1 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -92,6 +92,32 @@ test('a slower directory response cannot overwrite the latest folder', async ({ await expect(page.getByAltText('cat & dog.png', { exact: true })).toHaveCount(0); }); +test('large galleries scroll instead of squeezing thumbnails and size is adjustable', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 52 } })).toBeOK(); + await openHost(page); + await createNode(page, 'KikoLocalImageLoader'); + await page.locator('.kiko-folder-name').filter({ hasText: /^bulk$/ }).click(); + await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50); + const loader = page.locator('.ca-image-browser'); + await loader.evaluate(element => { element.style.width = '800px'; element.style.height = '650px'; }); + const size = loader.getByRole('slider', { name: 'Thumbnail size' }); + await expect(size).toHaveValue('224'); + const media = loader.locator('.kiko-card-media-wrapper').nth(1); + const before = await media.boundingBox(); + expect(before.height).toBeGreaterThanOrEqual(200); + expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true); + await size.fill('320'); + await size.dispatchEvent('input'); + expect((await media.boundingBox()).height).toBeGreaterThan(before.height); + await expect(loader.locator('.ca-range output')).toHaveText('320 px'); + await size.focus(); + await page.keyboard.press('ArrowLeft'); + await expect(size).toHaveValue('304'); + await loader.evaluate(element => { element.style.width = '320px'; }); + expect(await loader.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); + await expect(media.locator('img')).toHaveCSS('object-fit', 'contain'); +}); + test('removing a loader cancels delayed autocomplete and detaches theme observers', async ({ page }) => { await openHost(page); const id = await createNode(page, 'KikoLocalImageLoader'); diff --git a/tests/comfyui/image-browser.spec.mjs b/tests/comfyui/image-browser.spec.mjs index 6d04dcc..2142478 100644 --- a/tests/comfyui/image-browser.spec.mjs +++ b/tests/comfyui/image-browser.spec.mjs @@ -47,7 +47,8 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => { }); }); -test('actual host preserves the widget through renderer changes, theme changes and save/reload', async ({ page }, testInfo) => { +test('actual host preserves the widget through renderer changes, theme changes and save/reload', async ({ page, request }, testInfo) => { + await expect(await request.post(`${fixture}/__test__/reset`, { data: { extra_images: 52 } })).toBeOK(); await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', false); @@ -75,7 +76,16 @@ test('actual host preserves the widget through renderer changes, theme changes a }); expect(node).toBeTruthy(); expect(node.widgets_values || []).not.toContainEqual(expect.objectContaining({ className: 'ca-image-browser' })); + await loader.getByRole('button', { name: 'Open folder bulk', exact: true }).click(); + await expect(loader.locator('.kiko-card-info-panel')).toHaveCount(50); + const media = await loader.locator('.kiko-card-media-wrapper').nth(1).boundingBox(); + expect(media.height / media.width).toBeCloseTo(1, 1); + expect(media.height).toBeGreaterThan(150); + expect(await loader.locator('.kiko-cardholder').evaluate(element => element.clientHeight)).toBeGreaterThanOrEqual(await loader.locator('.kiko-card-media-wrapper').nth(1).evaluate(element => element.offsetHeight)); + expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true); await page.screenshot({ path: testInfo.outputPath(renderer ? 'nodes2.png' : 'classic.png') }); + await loader.getByRole('button', { name: 'Open folder ..', exact: true }).click(); + await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible(); } for (const theme of ['light', 'dark']) { await page.evaluate(async theme => { diff --git a/web/kiko_local_image_loader.js b/web/kiko_local_image_loader.js index d2739a6..bf8f23b 100644 --- a/web/kiko_local_image_loader.js +++ b/web/kiko_local_image_loader.js @@ -56,7 +56,7 @@ app.registerExtension({ }); // Set initial size for the node - this.size = [800, 600]; + this.size = [800, 900]; this.setSize(this.size); // Initialize gallery functionality diff --git a/web/ui/README.md b/web/ui/README.md index 8a541bd..7f6fbcf 100644 --- a/web/ui/README.md +++ b/web/ui/README.md @@ -5,6 +5,13 @@ blue controls and host-following light/dark themes. `../views/image-browser.js` composes these primitives; `../kiko_local_image_loader.js` retains the controller, API calls and existing selection persistence. +Grid thumbnails default to 224 px with square preview areas and preserve the +entire image. The Thumbnail size slider adjusts the grid from 128–384 px. A full +page scrolls within the gallery instead of compressing the rows to fit the node. +Thumbnail size is a per-node view setting for the current session. +New nodes start at 800 × 900 to leave room for the larger previews. Existing +workflows retain their saved node dimensions and can be resized normally. + `createScope()` owns listeners, delayed work and a fetch cancellation signal. Dispose it on node removal. `mountTheme()` observes host palette changes and removes its observers/listeners with that scope. The module has no ComfyUI imports; diff --git a/web/ui/controls.css b/web/ui/controls.css index ff240bc..5fb7281 100644 --- a/web/ui/controls.css +++ b/web/ui/controls.css @@ -59,7 +59,7 @@ .ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; } .ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; } .ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; } -.ca-image-browser { inline-size: 100%; block-size: 100%; min-block-size: 350px; overflow: hidden; border-radius: 6px; container-type: inline-size; } +.ca-image-browser { inline-size: 100%; block-size: 100%; min-block-size: 350px; overflow: hidden; border-radius: 6px; container-type: inline-size; contain: size; } .ca-image-browser .kiko-container-wrapper { display: flex; flex-direction: column; gap: 12px; padding: 12px; block-size: 100%; min-block-size: 350px; overflow: auto; } .ca-image-browser .ca-browser-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 8px; } .ca-image-browser .ca-browser-header strong { font-size: 13px; font-weight: 550; } @@ -74,11 +74,11 @@ .ca-image-browser .ca-sort select { font-size: 12px; } .ca-image-browser .ca-gallery-tools { flex-wrap: wrap; } .ca-image-browser .ca-file-filter { flex: 1; inline-size: 100px; } -.ca-image-browser .kiko-cardholder { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 140px), 1fr)); gap: 8px; align-content: start; overflow: auto; min-block-size: 100px; flex: 1; padding: 3px; } +.ca-image-browser .kiko-cardholder { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--ca-thumbnail-size, 224px)), 1fr)); grid-auto-rows: max-content; gap: 8px; align-items: start; align-content: start; overflow: auto; min-block-size: 100px; flex: 1; padding: 3px; } .ca-image-browser .ca-empty { grid-column: 1 / -1; text-align: center; padding: 24px 8px; color: var(--ca-muted); font-size: 12px; } .ca-image-browser button.kiko-gallery-card { display: flex; flex-direction: column; justify-content: start; min-inline-size: 0; overflow: hidden; border: 1px solid var(--ca-border); padding: 4px; white-space: normal; background: var(--ca-field); } .ca-image-browser button.kiko-gallery-card.kiko-selected { border: 2px solid var(--ca-focus); padding: 3px; } -.ca-image-browser .kiko-card-media-wrapper { position: relative; inline-size: 100%; aspect-ratio: 1.5; overflow: hidden; border-radius: 3px; } +.ca-image-browser .kiko-card-media-wrapper { position: relative; inline-size: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; border-radius: 3px; } .ca-image-browser .kiko-card-media-wrapper img { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; } .ca-image-browser .kiko-card-info-panel { inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; padding: 6px 3px 3px; font-size: 11px; } .ca-image-browser :where(.kiko-folder-card, .kiko-audio-card) { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; block-size: 100%; inline-size: 100%; background: var(--ca-field); } @@ -94,6 +94,9 @@ .ca-image-browser .kiko-pagination button { font-size: 11px; min-inline-size: 30px; padding-inline: 5px; } .ca-image-browser .ca-selection { border-block-start: 1px solid var(--ca-line); padding-block-start: 8px; } .ca-image-browser .ca-selection strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } +.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; } +.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; } +.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; } .ca-image-browser .kiko-status-message { padding: 10px; color: var(--ca-muted); border: 1px solid var(--ca-line); border-radius: 5px; font-size: 12px; } .ca-image-browser .kiko-status-message[data-state="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); } .ca-image-browser .kiko-status-message code { overflow-wrap: anywhere; } diff --git a/web/ui/core.js b/web/ui/core.js index e0384df..437d127 100644 --- a/web/ui/core.js +++ b/web/ui/core.js @@ -66,6 +66,25 @@ export function createButton(text, className = '') { return element; } +export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) { + const field = document.createElement('div'); + field.className = 'ca-range'; + const caption = document.createElement('label'); + caption.htmlFor = id; + caption.textContent = label; + const input = document.createElement('input'); + Object.assign(input, { type: 'range', id, min, max, step, value }); + const output = document.createElement('output'); + output.setAttribute('for', id); + output.value = `${value} ${unit}`; + scope.listen(input, 'input', () => { + output.value = `${input.value} ${unit}`; + onInput(Number(input.value)); + }); + field.append(caption, input, output); + return field; +} + export function createField(label, input, id) { const field = document.createElement('label'); field.className = 'ca-field'; diff --git a/web/views/image-browser.js b/web/views/image-browser.js index a7f1eb1..2e404b1 100644 --- a/web/views/image-browser.js +++ b/web/views/image-browser.js @@ -1,4 +1,4 @@ -import { createButton, createField, createSelect, createSegments, createSwitch, mountTheme } from '../ui/core.js'; +import { createButton, createField, createSelect, createSegments, createSlider, createSwitch, mountTheme } from '../ui/core.js'; export function createImageBrowser(id, settings, scope) { const element = document.createElement('div'); @@ -37,6 +37,9 @@ export function createImageBrowser(id, settings, scope) { filter.className = 'ca-file-filter'; filter.setAttribute('aria-label', 'Filter this page'); element.querySelector('.ca-gallery-tools').append(filter, createSegments('Gallery layout', [['grid', 'Grid'], ['list', 'List']], `${id}-view`, scope, value => { element.querySelector('.kiko-cardholder').dataset.view = value; })); + element.querySelector('.ca-browser-footer').after(createSlider('Thumbnail size', { id: `${id}-thumbnail-size`, min: 128, max: 384, step: 16, value: 224, unit: 'px' }, scope, value => { + element.style.setProperty('--ca-thumbnail-size', `${value}px`); + })); mountTheme(element, settings, scope); return element; }