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 = ` -