diff --git a/TESTING.md b/TESTING.md index bd4fa5b..77d39c2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/docs/screenshots/save-image-viewer/dark.png b/docs/screenshots/save-image-viewer/dark.png new file mode 100644 index 0000000..59c7087 Binary files /dev/null and b/docs/screenshots/save-image-viewer/dark.png differ diff --git a/docs/screenshots/save-image-viewer/light.png b/docs/screenshots/save-image-viewer/light.png new file mode 100644 index 0000000..c398417 Binary files /dev/null and b/docs/screenshots/save-image-viewer/light.png differ diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index fd091d1..04e9839 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -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'); diff --git a/tests/comfyui/image-browser.spec.mjs b/tests/comfyui/image-browser.spec.mjs index 2142478..0d77518 100644 --- a/tests/comfyui/image-browser.spec.mjs +++ b/tests/comfyui/image-browser.spec.mjs @@ -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 () => { diff --git a/tests/js/floating-window.test.mjs b/tests/js/floating-window.test.mjs new file mode 100644 index 0000000..2dd3936 --- /dev/null +++ b/tests/js/floating-window.test.mjs @@ -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); +}); diff --git a/tools/testing/frontend-coverage-baseline.json b/tools/testing/frontend-coverage-baseline.json index 63ada88..e98db14 100644 --- a/tools/testing/frontend-coverage-baseline.json +++ b/tools/testing/frontend-coverage-baseline.json @@ -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, diff --git a/web/kiko_save_image_ui.js b/web/kiko_save_image_ui.js index 8bc45ac..b252fce 100644 --- a/web/kiko_save_image_ui.js +++ b/web/kiko_save_image_ui.js @@ -4,387 +4,15 @@ */ import { app } from "../../scripts/app.js"; +import { createButton, createScope, mountTheme } from "./ui/core.js"; +import { mountFloatingWindow } from "./ui/floating-window.js"; -// CSS styles for enhanced image previews and web component -const KIKO_SAVE_IMAGE_STYLES = ` -/* Custom Web Component Styles - Floating Draggable Window */ -kiko-image-viewer { - position: fixed; - top: 100px; - right: 20px; - width: 420px; - max-width: 90vw; - max-height: 80vh; - background: #2a2a2a; - border-radius: 12px; - overflow: hidden; - box-shadow: 0 8px 32px rgba(0,0,0,0.5); - border: 1px solid #444; - z-index: 10000; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - resize: both; - min-width: 300px; - min-height: 200px; - transition: background 0.3s ease, box-shadow 0.3s ease, border 0.3s ease; -} - -kiko-image-viewer.rolled-up { - background: transparent; - box-shadow: none; - border: none; - overflow: visible; - resize: none; - min-height: auto; -} - -kiko-image-viewer.dragging { - transition: none; - z-index: 10001; -} - -kiko-image-viewer.dragging .kiko-viewer-header { - background: linear-gradient(135deg, #388E3C, #2E7D32); -} - -.kiko-viewer-container { - padding: 12px; - max-height: calc(80vh - 80px); - overflow-y: auto; - overflow-x: hidden; - transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), - padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), - opacity 0.3s ease; -} - -kiko-image-viewer.rolled-up .kiko-viewer-container { - max-height: 0; - padding: 0 12px; - opacity: 0; - overflow: hidden; -} - -.kiko-viewer-header { - background: linear-gradient(135deg, #4CAF50, #45a049); - padding: 12px 16px; - color: #fff; - font-size: 13px; - font-weight: 600; - border-bottom: 1px solid #555; - cursor: move; - user-select: none; - display: flex; - justify-content: space-between; - align-items: center; - position: relative; -} - -.kiko-viewer-header::after { - content: ''; - position: absolute; - bottom: 2px; - left: 50%; - transform: translateX(-50%); - width: 30px; - height: 3px; - background: rgba(255, 255, 255, 0.3); - border-radius: 2px; - opacity: 0; - transition: opacity 0.3s ease; -} - -kiko-image-viewer.rolled-up .kiko-viewer-header::after { - opacity: 1; -} - -kiko-image-viewer.rolled-up .kiko-viewer-header { - border-bottom: none; - border-radius: 12px; - box-shadow: 0 4px 16px rgba(0,0,0,0.3); -} - -.kiko-viewer-title { - flex: 1; -} - -.kiko-viewer-controls { - display: flex; - gap: 8px; - align-items: center; -} - -.kiko-control-btn { - background: rgba(255,255,255,0.2); - border: none; - color: white; - width: 24px; - height: 24px; - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - transition: background 0.2s ease; -} - -.kiko-control-btn:hover { - background: rgba(255,255,255,0.3); -} - -.kiko-minimize-btn:hover { - background: rgba(255,193,7,0.8); -} - -.kiko-close-btn:hover { - background: rgba(244,67,54,0.8); -} - -.kiko-image-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 10px; - margin-top: 8px; -} - -.kiko-image-item { - position: relative; - background: #1a1a1a; - border-radius: 8px; - overflow: hidden; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - border: 2px solid transparent; - cursor: default; -} - -.kiko-image-item:hover { - border-color: #4CAF50; - transform: translateY(-3px); - box-shadow: 0 8px 25px rgba(76, 175, 80, 0.4); -} - -.kiko-image-item img { - width: 100%; - height: 120px; - display: block; - object-fit: cover; - transition: transform 0.3s ease; -} - -.kiko-image-item:hover img { - transform: scale(1.05); -} - -.kiko-image-actions { - position: absolute; - top: 8px; - right: 8px; - display: flex; - gap: 6px; - opacity: 0; - transform: translateY(-10px); - transition: all 0.3s ease; -} - -.kiko-image-item:hover .kiko-image-actions { - opacity: 1; - transform: translateY(0); -} - -.kiko-action-btn { - background: rgba(0,0,0,0.8); - border: none; - color: white; - width: 32px; - height: 32px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 14px; - transition: all 0.2s ease; - backdrop-filter: blur(10px); -} - -.kiko-action-btn:hover { - background: rgba(76, 175, 80, 0.9); - transform: scale(1.1); -} - -.kiko-action-btn.download:hover { - background: rgba(33, 150, 243, 0.9); -} - -.kiko-action-btn.copy:hover { - background: rgba(156, 39, 176, 0.9); -} - -.kiko-image-selector { - position: absolute; - top: 8px; - left: 8px; - width: 24px; - height: 24px; - background: rgba(0,0,0,0.8); - border: 2px solid white; - border-radius: 4px; - cursor: pointer; - opacity: 0; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; -} - -.kiko-image-item:hover .kiko-image-selector { - opacity: 1; -} - -.kiko-image-selector.selected { - background: #4CAF50; - border-color: #4CAF50; - opacity: 1; -} - -.kiko-image-selector.selected::after { - content: '✓'; - color: white; - font-size: 12px; - font-weight: bold; -} - -.kiko-image-info { - position: absolute; - bottom: 0; - left: 0; - right: 0; - background: linear-gradient(transparent, rgba(0,0,0,0.9)); - color: white; - padding: 8px 6px 4px; - font-size: 10px; - line-height: 1.2; -} - -.kiko-image-filename { - font-weight: bold; - margin-bottom: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.kiko-image-details { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 4px; -} - -.kiko-format-badge { - display: inline-block; - padding: 1px 4px; - border-radius: 3px; - font-size: 9px; - font-weight: bold; - text-transform: uppercase; -} - -.kiko-format-png { background-color: #2196F3; } -.kiko-format-jpeg { background-color: #FF9800; } -.kiko-format-webp { background-color: #4CAF50; } - -.kiko-image-stats { - font-size: 9px; - opacity: 0.8; -} - -.kiko-click-hint { - position: absolute; - top: 4px; - right: 4px; - background: rgba(0,0,0,0.7); - color: white; - padding: 2px 6px; - border-radius: 3px; - font-size: 9px; - opacity: 0; - transition: opacity 0.2s ease; -} - -.kiko-image-item:hover .kiko-click-hint { - opacity: 1; -} - -.kiko-bulk-btn { - background: #444; - border: 1px solid #666; - color: white; - padding: 4px 8px; - border-radius: 4px; - cursor: pointer; - font-size: 10px; - transition: all 0.2s ease; -} - -.kiko-bulk-btn:hover { - background: #4CAF50; - border-color: #4CAF50; - transform: translateY(-1px); -} - -/* Legacy styles for fallback */ -.kiko-save-image-preview { - position: relative; - display: inline-block; - margin: 4px; - border: 2px solid #333; - border-radius: 8px; - overflow: hidden; - cursor: pointer; - transition: all 0.2s ease; -} - -.kiko-save-image-preview:hover { - border-color: #555; - transform: scale(1.02); - box-shadow: 0 4px 12px rgba(0,0,0,0.3); -} - -.kiko-format-indicator { - display: inline-block; - padding: 2px 6px; - border-radius: 4px; - font-size: 10px; - font-weight: bold; - margin-left: 4px; -} - -.kiko-quality-preview { - font-size: 11px; - color: #888; - margin-top: 2px; -} -`; - -// Inject CSS styles function injectStyles() { - if (!document.getElementById('kiko-save-image-styles')) { - const style = document.createElement('style'); - style.id = 'kiko-save-image-styles'; - style.textContent = KIKO_SAVE_IMAGE_STYLES; - document.head.appendChild(style); - } -} - -// Escape a value for safe interpolation into HTML text or attribute contexts -function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + const url = new URL('./ui/window.css', import.meta.url).href; + if ([...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === url)) return; + const link = document.createElement('link'); + Object.assign(link, { rel: 'stylesheet', href: url }); + document.head.append(link); } // Build a ComfyUI /api/view URL with every parameter encoded @@ -407,19 +35,11 @@ function formatFileSize(bytes) { } // Open image in new tab -function openImageInTab(imagePath, subfolder = '', enablePopup = true) { +function openImageInTab(imagePath, subfolder = '', enablePopup = true, type = 'output') { // Note: enablePopup parameter is kept for compatibility but not used // since popup now controls viewer visibility, not individual image clicks - const basePath = window.location.origin; - - // Construct proper image URL handling subfolder - let fullPath; - if (subfolder && subfolder.trim()) { - fullPath = `${basePath}/api/view?filename=${encodeURIComponent(imagePath)}&subfolder=${encodeURIComponent(subfolder)}&type=output`; - } else { - fullPath = `${basePath}/api/view?filename=${encodeURIComponent(imagePath)}&type=output`; - } + const fullPath = buildViewUrl(imagePath, subfolder, type); // Extract clean filename for window name (remove timestamp and batch number) const cleanName = imagePath.split('_').slice(0, -2).join('_') || 'KikoSaveImage'; @@ -443,430 +63,248 @@ class KikoImageViewer extends HTMLElement { super(); this.imageData = []; this.selectedImages = new Set(); - this.isDragging = false; this.isMinimized = false; this.isRolledUp = false; - this.dragOffset = { x: 0, y: 0 }; - this.lastHeaderClick = 0; - - // Bind document-level drag handlers once so they can be removed later - this.boundHandleDrag = this.handleDrag.bind(this); - this.boundEndDrag = this.endDrag.bind(this); - - // Always start at default position + this.openTimers = new Set(); + this.viewerId = `kiko-viewer-${Math.random().toString(36).slice(2)}`; } - static get observedAttributes() { - return ['data']; - } + static get observedAttributes() { return ['data']; } connectedCallback() { - document.addEventListener('mousemove', this.boundHandleDrag); - document.addEventListener('mouseup', this.boundEndDrag); + this.classList.add('ca-tools', 'ca-floating-window'); + this.setAttribute('role', 'dialog'); + this.setAttribute('aria-label', 'Saved images'); + this.activate(); } disconnectedCallback() { - document.removeEventListener('mousemove', this.boundHandleDrag); - document.removeEventListener('mouseup', this.boundEndDrag); + this.scope?.dispose(); + this.cancelOpens(); + } + + activate() { + if (this.scope && !this.scope.signal.aborted) return; + this.scope = createScope(); + mountTheme(this, app.ui.settings, this.scope); + this.windowController = mountFloatingWindow(this, this.scope, () => this.close()); + this.scope.listen(this, 'click', event => { + const button = event.target.closest('button[data-action]'); + if (!button) return; + event.stopPropagation(); + const action = button.dataset.action; + if (action === 'close') this.close(); + else if (action === 'minimize') this.toggleMinimize(); + else if (button.hasAttribute('data-index')) this.handleActionButton(action, Number(button.dataset.index)); + else this.handleBulkAction(action); + }); + this.scope.listen(this, 'change', event => { + if (event.target.matches('input[data-index]')) this.toggleImageSelection(Number(event.target.dataset.index)); + }); + this.scope.listen(this, 'dblclick', event => { + if (event.target.closest('[data-window-move]')) this.toggleRollUp(); + }); + this.scope.cleanup(() => this.cancelOpens()); } attributeChangedCallback(name, oldValue, newValue) { - if (name === 'data' && newValue) { - try { - this.imageData = JSON.parse(newValue); - this.autoUnrollOnNewImages(); - this.render(); - } catch (e) { - console.error('KikoImageViewer: Failed to parse image data:', e); - } - } + if (name !== 'data' || !newValue || oldValue === newValue) return; + try { this.setImageData(JSON.parse(newValue)); } + catch (error) { console.error('KikoImageViewer: Invalid image data', error); } } setImageData(data) { + if (!Array.isArray(data)) return; + this.cancelOpens(); this.imageData = data; - this.autoUnrollOnNewImages(); + this.classList.toggle('ca-single-image', data.length === 1); + this.selectedImages.clear(); + this.isMinimized = false; + this.isRolledUp = false; + this.classList.remove('ca-minimized', 'rolled-up'); this.render(); - this.setupEventListeners(); } - setupEventListeners() { - // Window dragging and double-click functionality - const header = this.querySelector('.kiko-viewer-header'); - if (header) { - header.addEventListener('mousedown', this.startDrag.bind(this)); - header.addEventListener('dblclick', this.handleHeaderDoubleClick.bind(this)); - } - - // Control buttons - const minimizeBtn = this.querySelector('.kiko-minimize-btn'); - const closeBtn = this.querySelector('.kiko-close-btn'); - - if (minimizeBtn) { - minimizeBtn.addEventListener('click', this.toggleMinimize.bind(this)); - } - - if (closeBtn) { - closeBtn.addEventListener('click', this.close.bind(this)); - } - - // Image selection checkboxes - const selectors = this.querySelectorAll('.kiko-image-selector'); - selectors.forEach((selector, index) => { - selector.addEventListener('click', (e) => { - e.stopPropagation(); - this.toggleImageSelection(index); - }); - }); - - // Action buttons - const actionButtons = this.querySelectorAll('.kiko-action-btn'); - actionButtons.forEach((btn) => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const action = btn.dataset.action; - const index = parseInt(btn.dataset.index); - this.handleActionButton(action, index); - }); - }); - - // Bulk action buttons - const bulkButtons = this.querySelectorAll('.kiko-bulk-btn'); - bulkButtons.forEach((btn) => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const action = btn.dataset.action; - this.handleBulkAction(action); - }); - }); - - // Document-level drag listeners are managed in connectedCallback/disconnectedCallback - } - - startDrag(e) { - // Don't start dragging if clicking on control buttons - if (e.target.closest('.kiko-viewer-controls')) { - return; - } - - this.isDragging = true; - const rect = this.getBoundingClientRect(); - this.dragOffset = { - x: e.clientX - rect.left, - y: e.clientY - rect.top - }; - - // Prevent text selection while dragging - e.preventDefault(); - - // Add dragging class for visual feedback - this.classList.add('dragging'); - } - - handleDrag(e) { - if (!this.isDragging) return; - - e.preventDefault(); - - const x = e.clientX - this.dragOffset.x; - const y = e.clientY - this.dragOffset.y; - - // Keep window within viewport bounds - const maxX = window.innerWidth - this.offsetWidth; - const maxY = window.innerHeight - this.offsetHeight; - - const boundedX = Math.max(0, Math.min(x, maxX)); - const boundedY = Math.max(0, Math.min(y, maxY)); - - this.style.left = boundedX + 'px'; - this.style.top = boundedY + 'px'; - this.style.right = 'auto'; // Override CSS right positioning - } - - endDrag(e) { - if (!this.isDragging) return; - - this.isDragging = false; - this.classList.remove('dragging'); - } - - handleHeaderDoubleClick(e) { - // Don't toggle if clicking on control buttons - if (e.target.closest('.kiko-viewer-controls')) { - return; - } - - this.toggleRollUp(); - } - - autoUnrollOnNewImages() { - // Auto-unroll if currently rolled up and we have new image data - if (this.isRolledUp && this.imageData && this.imageData.length > 0) { - this.isRolledUp = false; - this.classList.remove('rolled-up'); - } - } - - toggleRollUp() { - this.isRolledUp = !this.isRolledUp; - - if (this.isRolledUp) { - this.classList.add('rolled-up'); - } else { - this.classList.remove('rolled-up'); - } + cancelOpens() { + this.openTimers.forEach(timer => clearTimeout(timer)); + this.openTimers.clear(); } toggleMinimize() { this.isMinimized = !this.isMinimized; - const container = this.querySelector('.kiko-viewer-container'); - const minimizeBtn = this.querySelector('.kiko-minimize-btn'); - - if (container && minimizeBtn) { - if (this.isMinimized) { - container.style.display = 'none'; - minimizeBtn.textContent = '+'; - minimizeBtn.title = 'Restore'; - } else { - container.style.display = 'block'; - minimizeBtn.textContent = '−'; - minimizeBtn.title = 'Minimize'; - } - } + this.isRolledUp = this.isMinimized; + this.classList.toggle('ca-minimized', this.isMinimized); + this.classList.toggle('rolled-up', this.isMinimized); + this.querySelector('.kiko-viewer-container').hidden = this.isMinimized; + this.querySelector('[data-window-resize]').hidden = this.isMinimized; + this.querySelector('.ca-window-status').hidden = this.isMinimized; + const button = this.querySelector('.kiko-minimize-btn'); + button.textContent = this.isMinimized ? '+' : '−'; + button.title = this.isMinimized ? 'Restore' : 'Minimize'; + button.setAttribute('aria-label', `${button.title} image viewer`); + button.setAttribute('aria-expanded', String(!this.isMinimized)); + this.windowController?.fit(); } + toggleRollUp() { this.toggleMinimize(); } + close() { - this.style.display = 'none'; + const restoreFocus = this.contains(document.activeElement); + this.hidden = true; + this.scope?.dispose(); + this.ownerNode?.updateViewerButton(); + if (restoreFocus && this.returnFocus?.isConnected) this.returnFocus.focus(); } - show() { - this.style.display = 'block'; + show(focus = false) { + if (focus && !this.contains(document.activeElement)) this.returnFocus = document.activeElement; + this.hidden = false; + if (this.isConnected) this.activate(); + this.windowController?.fit(); + this.ownerNode?.updateViewerButton(); + if (focus) this.querySelector('[data-window-move]')?.focus(); } toggleImageSelection(index) { - const selector = this.querySelector(`[data-index="${index}"].kiko-image-selector`); - if (!selector) return; - - if (this.selectedImages.has(index)) { - this.selectedImages.delete(index); - selector.classList.remove('selected'); - } else { - this.selectedImages.add(index); - selector.classList.add('selected'); - } - - this.updateBulkActionsVisibility(); - this.updateSelectedCount(); + const checkbox = this.querySelector(`input[data-index="${index}"]`); + if (!checkbox) return; + if (checkbox.checked) this.selectedImages.add(index); + else this.selectedImages.delete(index); + this.updateSelection(); } - updateBulkActionsVisibility() { - const bulkActions = this.querySelector('.kiko-bulk-actions'); - if (bulkActions) { - bulkActions.style.display = this.selectedImages.size > 0 ? 'block' : 'none'; - } - } - - updateSelectedCount() { - const countSpan = this.querySelector('.selected-count'); - if (countSpan) { - countSpan.textContent = this.selectedImages.size; - } + updateSelection() { + this.querySelectorAll('input[data-index]').forEach(input => { input.checked = this.selectedImages.has(Number(input.dataset.index)); }); + const count = this.querySelector('.selected-count'); + if (count) count.textContent = this.selectedImages.size; + this.querySelectorAll('[data-action="open-all"], [data-action="download-all"], [data-action="clear-selection"]').forEach(button => { button.disabled = this.selectedImages.size === 0; }); } handleActionButton(action, index) { - const imageData = this.imageData[index]; - if (!imageData) return; - - switch (action) { - case 'download': - this.downloadImage(imageData); - break; - } + const image = this.imageData[index]; + if (!image) return; + if (action === 'download') this.downloadImage(image); + else if (action === 'open') openImageInTab(image.filename, image.subfolder || '', true, image.type); } handleBulkAction(action) { - switch (action) { - case 'open-all': - this.openAllImagesWithDelay(); - break; - case 'download-all': - this.selectedImages.forEach(index => { - const imageData = this.imageData[index]; - if (imageData) { - this.downloadImage(imageData); - } - }); - break; - case 'select-all': - this.imageData.forEach((_, index) => { - this.selectedImages.add(index); - const selector = this.querySelector(`[data-index="${index}"].kiko-image-selector`); - if (selector) { - selector.classList.add('selected'); - } - }); - this.updateBulkActionsVisibility(); - this.updateSelectedCount(); - break; - case 'clear-selection': - this.selectedImages.clear(); - this.querySelectorAll('.kiko-image-selector.selected').forEach(selector => { - selector.classList.remove('selected'); - }); - this.updateBulkActionsVisibility(); - this.updateSelectedCount(); - break; - } + if (action === 'select-all') this.imageData.forEach((_, index) => this.selectedImages.add(index)); + else if (action === 'clear-selection') this.selectedImages.clear(); + else if (action === 'open-all') this.openAllImagesWithDelay(); + else if (action === 'download-all') this.selectedImages.forEach(index => this.downloadImage(this.imageData[index])); + this.updateSelection(); } openAllImagesWithDelay() { - const selectedIndices = Array.from(this.selectedImages); - if (selectedIndices.length === 0) { - return; - } - - // Open images with 150ms delay between each to prevent popup blocking - selectedIndices.forEach((index, i) => { - setTimeout(() => { - const imageData = this.imageData[index]; - if (imageData) { - openImageInTab(imageData.filename, imageData.subfolder || ''); - } - }, i * 150); + this.cancelOpens(); + [...this.selectedImages].forEach((index, offset) => { + const image = this.imageData[index]; + const timer = setTimeout(() => { + this.openTimers.delete(timer); + openImageInTab(image.filename, image.subfolder || '', true, image.type); + }, offset * 150); + this.openTimers.add(timer); }); } - downloadImage(imageData) { - // Construct proper image URL handling subfolder - const imageUrl = buildViewUrl(imageData.filename, imageData.subfolder, imageData.type); - - // Create temporary link and trigger download + downloadImage(image) { const link = document.createElement('a'); - link.href = imageUrl; - link.download = imageData.filename; - link.style.display = 'none'; - document.body.appendChild(link); + link.href = buildViewUrl(image.filename, image.subfolder, image.type); + link.download = image.filename; + document.body.append(link); link.click(); - document.body.removeChild(link); + link.remove(); } - render() { - if (!this.imageData || this.imageData.length === 0) { - this.innerHTML = ` -
-
No images to display
-
- -
-
- `; - return; - } - - const header = this.imageData.length === 1 - ? `Saved Image (${escapeHtml(this.imageData[0].format)})` - : `Saved Images (${this.imageData.length} files)`; - - const containerStyle = this.isMinimized ? 'style="display: none;"' : ''; - - this.innerHTML = ` -
-
${header}
-
- - -
-
-
-
- ${this.imageData.map((data, index) => this.createImageItem(data, index)).join('')} -
- ${this.imageData.length > 1 ? this.createBulkActions() : ''} -
- `; - - // Add all event handlers - this.addClickHandlers(); + this.innerHTML = '
'; + const header = this.querySelector('header'); + const move = createButton(this.imageData.length === 1 ? 'Saved image' : `Saved images · ${this.imageData.length}`, 'ca-window-handle'); + move.setAttribute('data-window-move', ''); + move.setAttribute('aria-label', 'Move image viewer with arrow keys'); + move.title = 'Drag to move · Double-click to minimize'; + header.prepend(move); + const brand = document.createElement('span'); + brand.className = 'ca-window-brand'; + brand.textContent = 'ComfyAssets'; + move.after(brand); + const controls = this.querySelector('.kiko-viewer-controls'); + const minimize = createButton('−', 'kiko-control-btn kiko-minimize-btn'); + minimize.dataset.action = 'minimize'; + minimize.title = 'Minimize'; + minimize.setAttribute('aria-label', 'Minimize image viewer'); + minimize.setAttribute('aria-expanded', 'true'); + const close = createButton('×', 'kiko-control-btn kiko-close-btn ca-danger'); + close.dataset.action = 'close'; + close.title = 'Close'; + close.setAttribute('aria-label', 'Close image viewer'); + controls.append(minimize, close); + this.querySelector('.kiko-image-grid').append(...this.imageData.map((image, index) => this.createImageItem(image, index))); + if (this.imageData.length > 1) this.createBulkActions(); + const resize = createButton('◢', 'ca-window-resize'); + resize.setAttribute('data-window-resize', ''); + resize.setAttribute('aria-label', 'Resize image viewer with arrow keys'); + this.append(resize); + this.querySelector('footer').textContent = this.imageData.length ? `${this.imageData.length} saved ${this.imageData.length === 1 ? 'image' : 'images'} · Click a preview to open` : 'No images to display'; + this.updateSelection(); } createBulkActions() { - return ` - - `; - } - - createImageItem(data, index) { - // Construct proper image URL handling subfolder - // ComfyUI expects subfolder to be passed separately - const imageUrl = buildViewUrl(data.filename, data.subfolder, data.type); - - const formatClass = `kiko-format-${String(data.format ?? '').toLowerCase().replace(/[^a-z0-9_-]/g, '')}`; - - // Format file size - const fileSize = data.file_size ? formatFileSize(data.file_size) : 'Unknown'; - - // Build quality info - let qualityInfo = ''; - if (data.format === 'PNG' && data.compress_level !== undefined) { - qualityInfo = `C${data.compress_level}`; - } else if ((data.format === 'JPEG' || data.format === 'WEBP') && data.quality !== undefined) { - if (data.format === 'WEBP' && data.lossless) { - qualityInfo = 'Lossless'; - } else { - qualityInfo = `Q${data.quality}`; - } + const toolbar = document.createElement('div'); + toolbar.className = 'kiko-bulk-actions'; + toolbar.innerHTML = '0 selected
'; + for (const [action, text] of [['select-all', 'Select all'], ['clear-selection', 'Clear selection'], ['open-all', 'Open selected'], ['download-all', 'Download selected']]) { + const button = createButton(text, `kiko-bulk-btn${action === 'download-all' ? ' ca-primary' : ''}`); + button.dataset.action = action; + toolbar.querySelector('div').append(button); } - - return ` -
- ${escapeHtml(data.filename)} - - ${this.imageData.length > 1 ? ` -
- ` : ''} - -
- -
- -
-
- ${escapeHtml(data.filename)} -
-
-
- ${escapeHtml(data.format)} - ${escapeHtml(data.dimensions || 'Unknown')} • ${escapeHtml(fileSize)} - ${qualityInfo ? ` • ${escapeHtml(qualityInfo)}` : ''} -
-
-
-
- `; + this.querySelector('.kiko-viewer-container').append(toolbar); } - addClickHandlers() { - const items = this.querySelectorAll('.kiko-image-item'); - items.forEach((item, index) => { - const data = this.imageData[index]; - if (data) { - item.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - openImageInTab(data.filename, data.subfolder || ''); - }); - } - }); + createImageItem(image, index) { + const item = document.createElement('div'); + item.className = 'kiko-image-item'; + const open = createButton('', 'kiko-image-open'); + open.dataset.action = 'open'; + open.dataset.index = index; + open.setAttribute('aria-label', `Open image ${index + 1}: ${image.filename}`); + const img = document.createElement('img'); + Object.assign(img, { src: buildViewUrl(image.filename, image.subfolder, image.type), alt: image.filename, loading: 'lazy' }); + open.append(img); + const actions = document.createElement('div'); + actions.className = 'kiko-image-actions'; + if (this.imageData.length > 1) { + const label = document.createElement('label'); + label.className = 'kiko-image-selector'; + const checkbox = document.createElement('input'); + Object.assign(checkbox, { type: 'checkbox', id: `${this.viewerId}-select-${index}` }); + checkbox.dataset.index = index; + checkbox.setAttribute('aria-label', `Select image ${index + 1}: ${image.filename}`); + label.htmlFor = checkbox.id; + label.append(checkbox, document.createTextNode('Select')); + actions.append(label); + } + const download = createButton('↓ Download', 'kiko-action-btn ca-primary'); + download.dataset.action = 'download'; + download.dataset.index = index; + download.title = 'Download'; + download.setAttribute('aria-label', `Download image ${index + 1}: ${image.filename}`); + actions.append(download); + const info = document.createElement('div'); + info.className = 'kiko-image-info'; + const filename = document.createElement('div'); + filename.className = 'kiko-image-filename'; + filename.textContent = image.filename; + const stats = document.createElement('div'); + stats.className = 'kiko-image-stats'; + let quality = ''; + if (image.format === 'PNG' && image.compress_level !== undefined) quality = ` · C${image.compress_level}`; + else if (image.format === 'WEBP' && image.lossless) quality = ' · Lossless'; + else if (image.quality !== undefined) quality = ` · Q${image.quality}`; + stats.textContent = `${image.format || 'PNG'} · ${image.dimensions || 'Unknown'} · ${formatFileSize(image.file_size || 0)}${quality}`; + info.append(filename, stats); + item.append(open, actions, info); + return item; } } @@ -875,64 +313,6 @@ if (!customElements.get('kiko-image-viewer')) { customElements.define('kiko-image-viewer', KikoImageViewer); } -// Create enhanced image preview element -function createImagePreview(imageData) { - const container = document.createElement('div'); - container.className = 'kiko-save-image-preview'; - - // Create image element - const img = document.createElement('img'); - img.src = buildViewUrl(imageData.filename, imageData.subfolder, imageData.type); - img.alt = imageData.filename; - - // Create info overlay - const info = document.createElement('div'); - info.className = 'kiko-save-image-info'; - - const formatSpan = document.createElement('span'); - formatSpan.className = 'kiko-save-image-format'; - formatSpan.textContent = imageData.format || 'PNG'; - - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'kiko-save-image-size'; - sizeSpan.textContent = ` • ${imageData.dimensions || 'Unknown'}`; - - const fileSizeSpan = document.createElement('span'); - fileSizeSpan.className = 'kiko-save-image-size'; - fileSizeSpan.textContent = ` • ${formatFileSize(imageData.file_size || 0)}`; - - info.appendChild(formatSpan); - info.appendChild(sizeSpan); - info.appendChild(fileSizeSpan); - - // Add quality info for JPEG/WebP - if (imageData.quality && (imageData.format === 'JPEG' || imageData.format === 'WEBP')) { - const qualitySpan = document.createElement('span'); - qualitySpan.className = 'kiko-save-image-quality'; - let qualityText = ` • Q${imageData.quality}`; - if (imageData.format === 'WEBP' && imageData.lossless) { - qualityText = ' • Lossless'; - } - qualitySpan.textContent = qualityText; - info.appendChild(qualitySpan); - } else if (imageData.compress_level !== undefined && imageData.format === 'PNG') { - const compressSpan = document.createElement('span'); - compressSpan.className = 'kiko-save-image-quality'; - compressSpan.textContent = ` • C${imageData.compress_level}`; - info.appendChild(compressSpan); - } - - container.appendChild(img); - container.appendChild(info); - - // Add click handler to open in new tab - container.addEventListener('click', () => { - openImageInTab(imageData.filename, imageData.subfolder || ''); - }); - - return container; -} - // Add format indicator to widget function addFormatIndicator(node, widget) { const indicator = document.createElement('span'); @@ -968,10 +348,10 @@ app.registerExtension({ const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { - // Call original onNodeCreated - if (onNodeCreated) { - onNodeCreated.apply(this, arguments); - } + const result = onNodeCreated?.apply(this, arguments); + + this.kikoViewerScope = createScope(); + this.createToggleButton(); // Add format indicator to format widget const formatWidget = this.widgets?.find(w => w.name === "format"); @@ -980,6 +360,7 @@ app.registerExtension({ // Store update function for later use this.updateFormatIndicator = updateIndicator; + this.kikoViewerScope.cleanup(() => indicator.remove()); } // Add quality preview for quality widget @@ -996,7 +377,9 @@ app.registerExtension({ // Store preview element for updates this.qualityPreview = preview; + this.kikoViewerScope.cleanup(() => preview.remove()); } + return result; }; // Override onWidgetChange to update indicators @@ -1035,106 +418,61 @@ app.registerExtension({ } }; - // Add method to create custom image viewer - nodeType.prototype.createCustomImageViewer = function(imageData) { - // Check if popup is enabled for any image (use first image's popup setting) - const popupEnabled = imageData.length > 0 ? imageData[0].popup : true; - - if (!popupEnabled) { - return; - } - - // Check if viewer already exists - let existingViewer = document.querySelector('kiko-image-viewer'); - if (existingViewer) { - // Update existing viewer with new data - existingViewer.setImageData(imageData); - existingViewer.show(); // Make sure it's visible - return; - } - - // Create toggle button in node if it doesn't exist - if (!this.kikoToggleButton) { - this.createToggleButton(); - } - - // Create new viewer - always visible on workflow execution - const viewer = document.createElement('kiko-image-viewer'); - viewer.setImageData(imageData); - - // Store reference for toggle button - this.kikoViewer = viewer; - - // Update toggle button text - if (this.kikoToggleButton) { - this.kikoToggleButton.textContent = '👁️ Hide Images'; - } - - // Append to body (floating window) - always visible - document.body.appendChild(viewer); + const originalOnRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function () { + this.kikoViewerScope?.dispose(); + if (this.kikoViewer?.ownerNode === this) this.kikoViewer.remove(); + this.kikoViewer = null; + return originalOnRemoved?.apply(this, arguments); }; - // Add method to create toggle button - nodeType.prototype.createToggleButton = function() { - // Find the node element to add button to - let nodeElement = null; + nodeType.prototype.updateViewerButton = function () { + if (!this.kikoToggleButton) return; + const visible = this.kikoViewer?.isConnected && this.kikoViewer.ownerNode === this && !this.kikoViewer.hidden; + this.kikoToggleButton.disabled = !this.kikoLastImages?.length; + this.kikoToggleButton.textContent = visible ? 'Hide images' : 'Show images'; + this.kikoToggleButton.setAttribute('aria-expanded', String(Boolean(visible))); + }; - if (this.domElement) { - nodeElement = this.domElement; - } else if (this.widgets && this.widgets[0] && this.widgets[0].element) { - nodeElement = this.widgets[0].element.closest('.litegraph-node'); - } else { - // Find by node title - const allNodes = document.querySelectorAll('.litegraph-node'); - for (const node of allNodes) { - if (node.textContent && node.textContent.includes('Kiko Save Image')) { - nodeElement = node; - break; - } - } - } + nodeType.prototype.createCustomImageViewer = function (imageData) { + if (!Array.isArray(imageData) || !imageData.length) return; + this.kikoLastImages = imageData; + this.updateViewerButton(); + if (!imageData[0].popup) return; + let viewer = document.querySelector('kiko-image-viewer'); + const previousOwner = viewer?.ownerNode; + if (!viewer) viewer = document.createElement('kiko-image-viewer'); + viewer.ownerNode = this; + this.kikoViewer = viewer; + viewer.setImageData(imageData); + if (!viewer.isConnected) document.body.append(viewer); + viewer.show(); + previousOwner?.updateViewerButton(); + this.updateViewerButton(); + }; - if (!nodeElement) return; - - // Create toggle button - const toggleButton = document.createElement('button'); - toggleButton.textContent = '👁️ Show Images'; - toggleButton.className = 'kiko-toggle-viewer-btn'; - toggleButton.style.cssText = ` - margin: 4px; - padding: 4px 8px; - background: #4CAF50; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-size: 11px; - font-family: inherit; - `; - - toggleButton.addEventListener('click', () => { - if (this.kikoViewer) { - const isHidden = this.kikoViewer.style.display === 'none'; - if (isHidden) { - this.kikoViewer.show(); - toggleButton.textContent = '👁️ Hide Images'; - } else { - this.kikoViewer.close(); - toggleButton.textContent = '👁️ Show Images'; - } + nodeType.prototype.createToggleButton = function () { + const container = document.createElement('div'); + container.className = 'ca-tools ca-viewer-launcher'; + const button = createButton('Show images', 'ca-primary kiko-toggle-viewer-btn'); + button.disabled = true; + button.setAttribute('aria-expanded', 'false'); + container.append(button); + mountTheme(container, app.ui.settings, this.kikoViewerScope); + this.addDOMWidget('kiko_saved_image_controls', 'div', container, { + serialize: false, hideOnZoom: false, + getMinHeight: () => 38, getMaxHeight: () => 38, + }); + this.kikoToggleButton = button; + this.kikoViewerScope.cleanup(() => container.remove()); + this.kikoViewerScope.listen(button, 'click', () => { + if (!this.kikoLastImages?.length) return; + if (this.kikoViewer?.ownerNode === this && this.kikoViewer.isConnected && !this.kikoViewer.hidden) this.kikoViewer.close(); + else { + this.createCustomImageViewer(this.kikoLastImages.map(image => ({ ...image, popup: true }))); + this.kikoViewer.show(true); } }); - - // Add hover effects - toggleButton.addEventListener('mouseenter', () => { - toggleButton.style.background = '#45a049'; - }); - toggleButton.addEventListener('mouseleave', () => { - toggleButton.style.background = '#4CAF50'; - }); - - nodeElement.appendChild(toggleButton); - this.kikoToggleButton = toggleButton; }; // Legacy method kept for compatibility (not used with web component) @@ -1157,7 +495,7 @@ window.kikoResetViewer = function() { const toggleButtons = document.querySelectorAll('.kiko-toggle-viewer-btn'); toggleButtons.forEach(btn => { btn.textContent = '👁️ Show Images'; - btn.style.background = '#4CAF50'; + }); console.log('✨ Reset complete! Run your workflow to create a new viewer!'); @@ -1178,8 +516,3 @@ window.kikoForceViewer = function() { console.log('⚠️ No existing viewer found. Run your KikoSaveImage workflow to create a real viewer with actual images!'); alert('⚠️ No viewer found! Run your KikoSaveImage workflow to create a viewer with real images.'); }; - -console.log('🎉 KikoSaveImage: Extension loaded!'); -console.log('💡 Helper functions available:'); -console.log(' 📞 kikoResetViewer() - Remove existing viewer windows'); -console.log(' 🚀 kikoForceViewer() - Show demo viewer'); diff --git a/web/ui/README.md b/web/ui/README.md index 7f6fbcf..b1ab6bb 100644 --- a/web/ui/README.md +++ b/web/ui/README.md @@ -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. diff --git a/web/ui/floating-window.js b/web/ui/floating-window.js new file mode 100644 index 0000000..e2deb84 --- /dev/null +++ b/web/ui/floating-window.js @@ -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 }; +} diff --git a/web/ui/window.css b/web/ui/window.css new file mode 100644 index 0000000..bb35ba7 --- /dev/null +++ b/web/ui/window.css @@ -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; } }