feat(ui): migrate saved-image viewer to shared window controls

This commit is contained in:
Vito Sansevero
2026-10-16 11:30:11 -07:00
parent ddb356fe17
commit 47dc177517
11 changed files with 627 additions and 926 deletions
+5 -1
View File
@@ -93,7 +93,11 @@ so user settings, saved workflows and selections are not written to the live
server. It does not execute a workflow or download models.
Checks cover Classic/Nodes 2.0/live switching, theme changes, workflow save/reload
and removal. Screenshots/traces appear in
and removal for the loader. Save Image additionally checks real image output,
window close/reopen through the node control across renderer switches, theme
changes, exclusion of the launcher from serialized values and owner removal.
Its `/view` requests also use disposable fixture output rather than live files.
Screenshots/traces appear in
`tools/testing/test-results/comfyui/`. This opt-in smoke suite is separate from
the required fixture CI job; fully provisioned, pinned ComfyUI CI and testing
alongside other node packs remain follow-up work in issue #81.
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

+142
View File
@@ -315,6 +315,148 @@ test('real image save output opens a preview, closes, and reuses the viewer on e
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
});
async function savedImageResult(page, request, count = 1) {
const id = await createNode(page, 'KikoSaveImage', { format: 'PNG', quality: 90 });
const response = await request.post('/__test__/execute/KikoSaveImage', { data: { filename_prefix: 'Window test' } });
await expect(response).toBeOK();
const message = (await response.json()).ui;
message.kiko_enhanced = Array.from({ length: count }, () => ({ ...message.kiko_enhanced[0] }));
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message });
return { id, message };
}
test('shared saved-image window follows themes and supports keyboard close and reopen', async ({ page, request }) => {
await openHost(page);
const { id } = await savedImageResult(page, request);
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
await expect(viewer).toBeVisible();
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#fafafa'));
await expect(viewer).toHaveAttribute('data-ca-theme', 'light');
await viewer.getByTitle('Minimize', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
await viewer.getByTitle('Restore', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeVisible();
await viewer.getByTitle('Close', { exact: true }).focus();
await page.keyboard.press('Escape');
await expect(viewer).toBeHidden();
await page.locator(`[data-node="KikoSaveImage"]`).getByRole('button', { name: 'Show images', exact: true }).click();
await expect(viewer).toBeVisible();
expect(await page.evaluate(id => kikoHost.nodes.get(String(id)).kikoViewer.isConnected, id)).toBe(true);
});
test('saved-image ownership transfers safely and deletion cleans up only the owner window', async ({ page, request }) => {
await openHost(page);
const first = await savedImageResult(page, request);
const second = await savedImageResult(page, request);
expect(await page.evaluate(id => Boolean(kikoHost.nodes.get(String(id)).kikoViewer), second.id)).toBe(true);
await page.evaluate(id => kikoHost.removeNode(id), first.id);
await expect(page.locator('kiko-image-viewer')).toBeVisible();
await page.evaluate(id => kikoHost.removeNode(id), second.id);
await expect(page.locator('kiko-image-viewer')).toHaveCount(0);
});
test('saved-image window moves and resizes by keyboard and remains inside a shrinking viewport', async ({ page, request }) => {
await page.setViewportSize({ width: 1200, height: 900 });
await openHost(page);
await savedImageResult(page, request);
const viewer = page.locator('kiko-image-viewer');
const move = viewer.getByRole('button', { name: 'Move image viewer with arrow keys', exact: true });
await move.focus();
const before = await viewer.boundingBox();
await page.keyboard.press('ArrowLeft');
expect((await viewer.boundingBox()).x).toBeLessThan(before.x);
const resize = viewer.getByRole('button', { name: 'Resize image viewer with arrow keys', exact: true });
await resize.focus();
await page.keyboard.press('ArrowRight');
expect((await viewer.boundingBox()).width).toBeGreaterThan(before.width);
const imageBefore = await viewer.locator('img').boundingBox();
await page.keyboard.press('ArrowDown');
expect((await viewer.locator('img').boundingBox()).height).toBeGreaterThan(imageBefore.height);
const info = await viewer.locator('.kiko-image-info').boundingBox();
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
await page.setViewportSize({ width: 360, height: 500 });
await expect.poll(async () => {
const rect = await viewer.boundingBox();
return rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= 361 && rect.y + rect.height <= 501;
}).toBe(true);
});
test('saved-image batch selection downloads and resets for new execution results', async ({ page, request }) => {
await openHost(page);
const { id, message } = await savedImageResult(page, request, 2);
const viewer = page.locator('kiko-image-viewer');
const choices = viewer.getByRole('checkbox');
await expect(choices).toHaveCount(2);
await choices.first().check();
await expect(viewer.locator('.selected-count')).toHaveText('1');
await viewer.getByRole('button', { name: 'Select all', exact: true }).click();
await expect(choices.nth(1)).toBeChecked();
const download = page.waitForEvent('download');
await viewer.getByRole('button', { name: /Download image 1:/ }).click();
expect((await download).suggestedFilename()).toBe(message.kiko_enhanced[0].filename);
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: { kiko_enhanced: [message.kiko_enhanced[0]] } });
expect(await viewer.evaluate(element => element.selectedImages.size)).toBe(0);
await expect(viewer.getByRole('checkbox')).toHaveCount(0);
});
test('saved-image window pointer handles drag resize and cancel without losing reachability', async ({ page, request }) => {
await page.setViewportSize({ width: 1200, height: 900 });
await openHost(page);
await savedImageResult(page, request);
const viewer = page.locator('kiko-image-viewer');
const before = await viewer.boundingBox();
const handle = await viewer.locator('[data-window-move]').boundingBox();
await page.mouse.move(handle.x + 20, handle.y + 15);
await page.mouse.down();
await page.mouse.move(handle.x - 100, handle.y + 35, { steps: 4 });
await page.mouse.up();
const moved = await viewer.boundingBox();
expect(moved.x).toBeLessThan(before.x);
await expect(viewer).not.toHaveClass(/ca-dragging/);
const resize = await viewer.locator('[data-window-resize]').boundingBox();
await page.mouse.move(resize.x + 12, resize.y + 12);
await page.mouse.down();
await page.mouse.move(resize.x + 60, resize.y + 15, { steps: 4 });
await page.mouse.up();
expect((await viewer.boundingBox()).width).toBeGreaterThan(moved.width);
const next = await viewer.locator('[data-window-move]').boundingBox();
await page.mouse.move(next.x + 20, next.y + 15);
await page.mouse.down();
await viewer.dispatchEvent('pointercancel');
await expect(viewer).not.toHaveClass(/ca-dragging/);
await page.mouse.up();
});
test('delayed selected-image opens stop on close replacement and owner removal', async ({ page, request }) => {
await openHost(page);
const { id, message } = await savedImageResult(page, request, 2);
await page.clock.install();
await page.evaluate(() => {
window.openedImages = [];
window.open = url => { window.openedImages.push(url); return {}; };
const viewer = document.querySelector('kiko-image-viewer');
viewer.handleBulkAction('select-all');
viewer.handleBulkAction('open-all');
});
await page.clock.runFor(200);
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
for (const action of ['close', 'replace', 'remove']) {
await page.evaluate(({ id, message, action }) => {
const node = kikoHost.nodes.get(String(id));
node.onExecuted(message);
const viewer = document.querySelector('kiko-image-viewer');
viewer.handleBulkAction('select-all');
viewer.handleBulkAction('open-all');
if (action === 'close') viewer.close();
if (action === 'replace') node.onExecuted(message);
if (action === 'remove') kikoHost.removeNode(id);
}, { id, message, action });
await page.clock.runFor(500);
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
}
});
test('Display Any renders real multiline output and restores serialized values without duplicate widgets', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'DisplayAny');
+49 -1
View File
@@ -17,7 +17,7 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace(/^\/api\//, '/');
if (path.startsWith('/kiko_local_image_loader/')) {
if (path.startsWith('/kiko_local_image_loader/') || path === '/view') {
const response = await route.fetch({ url: `${fixture}${path}${url.search}` });
return route.fulfill({ response });
}
@@ -47,6 +47,54 @@ test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
});
});
test('saved-image window survives real renderer switching and is removed with its owner', async ({ page, request }, testInfo) => {
const result = await request.post(`${fixture}/__test__/execute/KikoSaveImage`, { data: { filename_prefix: 'Real host viewer' } });
await expect(result).toBeOK();
const message = (await result.json()).ui;
const id = await page.evaluate(async message => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('KikoSaveImage');
app.graph.add(node);
node.onExecuted(message);
app.canvas.centerOnNode(node);
return node.id;
}, message);
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
await expect(viewer).toBeVisible();
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(viewer).toHaveCount(1);
await viewer.getByTitle('Close', { exact: true }).click();
await page.getByRole('button', { name: 'Show images', exact: true }).click();
await expect(viewer).toBeVisible();
expect(await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.serialize().nodes.find(node => String(node.id) === String(id)).widgets_values.includes('Show images');
}, id)).toBe(false);
}
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(viewer).toHaveAttribute('data-ca-theme', theme);
const info = await viewer.locator('.kiko-image-info').boundingBox();
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
await page.screenshot({ path: testInfo.outputPath(`viewer-${theme}.png`) });
}
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(viewer).toHaveCount(0);
});
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 () => {
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fitWindowRect } from '../../web/ui/floating-window.js';
test('window bounds keep its controls reachable after moving outside the viewport', () => {
const rect = fitWindowRect({ x: -100, y: 900, width: 420, height: 400 }, { width: 1000, height: 700 });
assert.equal(rect.x, 8);
assert.equal(rect.y + rect.height, 692);
});
test('window fitting respects minimum sizes and small viewports', () => {
const small = fitWindowRect({ x: 0, y: 0, width: 10, height: 10 }, { width: 1000, height: 700 });
assert.equal(small.width, 280);
assert.equal(small.height, 200);
const narrow = fitWindowRect({ x: 800, y: 600, width: 900, height: 800 }, { width: 260, height: 180 });
assert.equal(narrow.width, 244);
assert.equal(narrow.height, 164);
assert.equal(narrow.x, 8);
assert.equal(narrow.y, 8);
});
+11 -5
View File
@@ -48,10 +48,10 @@
"statements": 77
},
"kiko_save_image_ui.js": {
"lines": 66,
"branches": 49,
"functions": 57,
"statements": 66
"lines": 84,
"branches": 74,
"functions": 78,
"statements": 84
},
"kiko_workflow_timer_ui.js": {
"lines": 44,
@@ -73,10 +73,16 @@
},
"ui/core.js": {
"lines": 98,
"branches": 91,
"branches": 92,
"functions": 98,
"statements": 98
},
"ui/floating-window.js": {
"lines": 93,
"branches": 80,
"functions": 98,
"statements": 93
},
"views/image-browser.js": {
"lines": 98,
"branches": 98,
+252 -919
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -24,3 +24,20 @@ of `ui/`; add abstractions only as demonstrated by the next consumer.
Architecture and control organization were inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma). This implementation and
styling are original; none of his toolkit code or assets are vendored.
## Floating windows
`floating-window.js` owns pointer capture, keyboard movement/resizing, viewport
bounds and observer cleanup. `window.css` consumes the same control tokens as the
loader. Kiko Save Image is its first consumer; the controller has no node, API or
gallery dependencies.
Drag the title or bottom-right resize handle. Focus either handle and use arrow
keys (Shift for larger steps). Escape closes the focused window. The viewer stays
nonmodal so the canvas remains usable. Window position and size survive new
results and minimize/reopen within the session; they are not written into a
workflow or persisted across browser reloads.
The owning node controls removal. Another node may take over the shared viewer,
but deleting the previous owner must not remove it. Closing or deleting a viewer,
or replacing its results, cancels delayed bulk-open actions.
+80
View File
@@ -0,0 +1,80 @@
export function fitWindowRect(rect, viewport) {
const width = Math.min(Math.max(280, rect.width), Math.max(1, viewport.width - 16));
const height = Math.min(Math.max(200, rect.height), Math.max(1, viewport.height - 16));
return {
width, height,
x: Math.max(8, Math.min(rect.x, viewport.width - width - 8)),
y: Math.max(8, Math.min(rect.y, viewport.height - height - 8)),
};
}
export function mountFloatingWindow(element, scope, onClose) {
const stylesheet = new URL('./window.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href: stylesheet });
document.head.append(link);
}
let gesture;
const viewport = () => ({ width: innerWidth, height: innerHeight });
const move = (x, y) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x, y, width: rect.width, height: rect.height }, viewport());
element.style.left = `${next.x}px`;
element.style.top = `${next.y}px`;
element.style.right = 'auto';
};
const resize = (width, height) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x: rect.x, y: rect.y, width, height }, viewport());
element.style.setProperty('--ca-window-width', `${next.width}px`);
element.style.setProperty('--ca-window-height', `${next.height}px`);
move(next.x, next.y);
};
const end = () => {
if (gesture && element.hasPointerCapture(gesture.id)) element.releasePointerCapture(gesture.id);
gesture = null;
element.classList.remove('ca-dragging');
};
scope.listen(element, 'pointerdown', event => {
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || event.button !== 0) return;
const rect = element.getBoundingClientRect();
gesture = { id: event.pointerId, kind: handle.hasAttribute('data-window-resize') ? 'resize' : 'move', rect, x: event.clientX, y: event.clientY };
handle.focus();
element.setPointerCapture(event.pointerId);
element.classList.add('ca-dragging');
event.preventDefault();
});
scope.listen(element, 'pointermove', event => {
if (!gesture || event.pointerId !== gesture.id) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (gesture.kind === 'resize') resize(gesture.rect.width + dx, gesture.rect.height + dy);
else move(gesture.rect.x + dx, gesture.rect.y + dy);
});
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) scope.listen(element, event, end);
scope.listen(element, 'keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); return; }
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const delta = event.shiftKey ? 40 : 16;
const dx = event.key === 'ArrowLeft' ? -delta : event.key === 'ArrowRight' ? delta : 0;
const dy = event.key === 'ArrowUp' ? -delta : event.key === 'ArrowDown' ? delta : 0;
const rect = element.getBoundingClientRect();
if (handle.hasAttribute('data-window-resize')) resize(rect.width + dx, rect.height + dy);
else move(rect.x + dx, rect.y + dy);
});
const keepReachable = () => {
if (element.hidden) return;
const rect = element.getBoundingClientRect();
move(rect.x, rect.y);
};
scope.listen(window, 'resize', keepReachable);
const observer = new ResizeObserver(keepReachable);
observer.observe(element);
scope.cleanup(() => { observer.disconnect(); end(); });
return { fit: keepReachable };
}
+51
View File
@@ -0,0 +1,51 @@
.ca-floating-window {
box-sizing: border-box;
position: fixed;
inset-block-start: 100px;
right: 20px;
inline-size: min(var(--ca-window-width, 600px), calc(100dvw - 16px));
block-size: min(var(--ca-window-height, 560px), calc(100dvh - 16px));
min-inline-size: min(280px, calc(100dvw - 16px));
min-block-size: min(200px, calc(100dvh - 16px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ca-border);
border-radius: 8px;
box-shadow: 0 8px 28px #00000040;
z-index: 10000;
}
.ca-floating-window[hidden], .ca-floating-window [hidden] { display: none; }
.ca-floating-window.ca-minimized { block-size: auto; min-block-size: 0; }
.ca-window-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-block-end: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-window-header .ca-window-handle { min-inline-size: 0; flex: 1; justify-content: start; border: 0; background: transparent; padding-inline: 0; overflow: hidden; text-overflow: ellipsis; cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; }
.ca-floating-window.ca-dragging [data-window-move] { cursor: grabbing; }
.ca-window-header .kiko-viewer-controls { display: flex; align-items: center; gap: 6px; }
.ca-window-header button.kiko-control-btn { min-inline-size: 30px; padding-inline: 6px; }
.ca-window-header .ca-window-brand { color: var(--ca-muted); font-size: 10px; }
.ca-floating-window .ca-window-resize { position: absolute; inset-inline-end: 2px; inset-block-end: 2px; min-inline-size: 24px; min-block-size: 24px; border: 0; background: var(--ca-surface); color: var(--ca-muted); cursor: nwse-resize; touch-action: none; padding: 0; }
.ca-floating-window .kiko-viewer-container { padding: 12px 12px 28px; overflow: auto; min-block-size: 0; flex: 1; }
.ca-floating-window .kiko-image-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 224px), 1fr)); gap: 10px; align-items: start; }
.ca-floating-window .kiko-image-item { min-inline-size: 0; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-field); padding: 6px; }
.ca-floating-window .kiko-image-open { padding: 0; border: 0; background: var(--ca-field); inline-size: 100%; display: block; }
.ca-floating-window .kiko-image-open img { display: block; inline-size: 100%; block-size: 230px; object-fit: contain; }
.ca-floating-window.ca-single-image .kiko-viewer-container:not([hidden]) { display: flex; flex-direction: column; }
.ca-floating-window.ca-single-image .kiko-image-grid { display: flex; flex: 1; min-block-size: 0; align-items: stretch; }
.ca-floating-window.ca-single-image .kiko-image-item { display: flex; flex-direction: column; flex: 1; min-block-size: 0; }
.ca-floating-window.ca-single-image .kiko-image-open { position: relative; flex: 1; min-block-size: 100px; overflow: hidden; }
.ca-floating-window.ca-single-image .kiko-image-open img { position: absolute; inset: 0; block-size: 100%; }
.ca-floating-window .kiko-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-start: 8px; flex-wrap: wrap; }
.ca-floating-window .kiko-image-selector { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: 12px; min-block-size: 30px; }
.ca-floating-window .kiko-image-selector input { inline-size: 18px; block-size: 18px; min-block-size: 18px; }
.ca-floating-window .kiko-image-info { padding-block-start: 6px; font-size: 11px; }
.ca-floating-window .kiko-image-filename { overflow-wrap: anywhere; }
.ca-floating-window .kiko-image-stats { color: var(--ca-muted); margin-block-start: 3px; }
.ca-floating-window .kiko-format-badge { display: inline-block; border: 1px solid var(--ca-border); border-radius: 3px; padding: 1px 4px; margin-inline-end: 3px; }
.ca-floating-window .kiko-bulk-actions { border-block-start: 1px solid var(--ca-line); margin-block-start: 12px; padding-block-start: 10px; font-size: 12px; color: var(--ca-muted); }
.ca-floating-window .kiko-bulk-buttons { display: flex; flex-wrap: wrap; gap: 7px; margin-block-start: 7px; }
.ca-floating-window .kiko-bulk-buttons button { font-size: 12px; }
.ca-floating-window .ca-window-status { color: var(--ca-muted); font-size: 11px; padding: 6px 30px 6px 12px; border-block-start: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-viewer-launcher { padding: 3px 0; }
.ca-viewer-launcher button { inline-size: 100%; }
@media (pointer: coarse) { .ca-floating-window .ca-window-header button, .ca-floating-window .ca-window-resize { min-inline-size: 40px; min-block-size: 40px; } }
@media (forced-colors: active) { .ca-floating-window { box-shadow: none; border-color: CanvasText; } }