From e9acfe1e20dfd6e14a053e4049a39e2a6594d98b Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:18:41 +0800 Subject: [PATCH 1/5] feat: interactive full-screen view mode (zoom, pan, copy, load-workflow, help hint) --- tests/gallery.test.js | 384 ++++++++++++++++++++++++++++++++++++++++++ tests/helpers.test.js | 45 ++++- web/lib/constants.js | 6 + web/lib/gallery.js | 165 +++++++++++++++++- web/lib/helpers.js | 34 +++- web/locales/en.json | 4 + web/locales/zh.json | 4 + web/queue-sidebar.js | 2 +- 8 files changed, 629 insertions(+), 15 deletions(-) create mode 100644 tests/gallery.test.js diff --git a/tests/gallery.test.js b/tests/gallery.test.js new file mode 100644 index 0000000..9dc8df1 --- /dev/null +++ b/tests/gallery.test.js @@ -0,0 +1,384 @@ +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' +import { attachZoomPan, openGallery } from '../web/lib/gallery.js' + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +function makeImage() { + return document.createElement('img') +} + +// Parse the numeric parts of the applied CSS transform. Regex-based so the +// assertions are tolerant of spacing/float formatting differences. +function scaleOf(elm) { + const m = elm.style.transform.match(/scale\(([-\d.]+)\)/) + return m ? parseFloat(m[1]) : null +} + +function translateOf(elm) { + const m = elm.style.transform.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/) + return m ? { x: parseFloat(m[1]), y: parseFloat(m[2]) } : null +} + +function wheel(elm, deltaY) { + return elm.dispatchEvent(new WheelEvent('wheel', { deltaY, cancelable: true })) +} + +function pointer(elm, type, x, y) { + elm.dispatchEvent(new PointerEvent(type, { clientX: x, clientY: y, pointerId: 1 })) +} + +// ─── attachZoomPan ──────────────────────────────────────────────────────────── + +describe('attachZoomPan', () => { + it('sets an initial fit transform (scale 1, no offset)', () => { + const img = makeImage() + attachZoomPan(img) + expect(scaleOf(img)).toBe(1) + expect(translateOf(img)).toEqual({ x: 0, y: 0 }) + }) + + it('wheel up zooms in by one step', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) + expect(scaleOf(img)).toBeCloseTo(1.12, 5) + }) + + it('wheel zoom is clamped to a maximum of 4', () => { + const img = makeImage() + attachZoomPan(img) + for (let i = 0; i < 40; i++) wheel(img, -100) + expect(scaleOf(img)).toBe(4) + }) + + it('wheel down at fit stays clamped at 1', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, 100) + expect(scaleOf(img)).toBe(1) + }) + + it('ignores horizontal wheel (deltaY 0) instead of zooming', () => { + const img = makeImage() + attachZoomPan(img) + expect(wheel(img, 0)).toBe(true) // not prevented — lets horizontal scroll through + expect(scaleOf(img)).toBe(1) // no zoom on a horizontal swipe + }) + + it('wheel prevents the default page scroll', () => { + const img = makeImage() + attachZoomPan(img) + expect(wheel(img, -100)).toBe(false) // dispatchEvent → false when preventDefault called + }) + + it('drag pans by the pointer delta while zoomed in', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) // zoom in so scale > 1 + pointer(img, 'pointerdown', 100, 100) + pointer(img, 'pointermove', 130, 150) + expect(translateOf(img)).toEqual({ x: 30, y: 50 }) + }) + + it('does not pan at fit (scale 1)', () => { + const img = makeImage() + attachZoomPan(img) + pointer(img, 'pointerdown', 100, 100) + pointer(img, 'pointermove', 130, 150) + expect(translateOf(img)).toEqual({ x: 0, y: 0 }) + }) + + it('resets the pan offset when zooming back out to fit', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) + wheel(img, -100) // scale ~1.24 + pointer(img, 'pointerdown', 100, 100) + pointer(img, 'pointermove', 130, 150) // offset (30, 50) + wheel(img, 100) + wheel(img, 100) + wheel(img, 100) // back down to scale 1 + expect(scaleOf(img)).toBe(1) + expect(translateOf(img)).toEqual({ x: 0, y: 0 }) + }) + + it('stops panning after the pointer is released', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) + pointer(img, 'pointerdown', 100, 100) + pointer(img, 'pointermove', 130, 150) // offset (30, 50) + pointer(img, 'pointerup', 130, 150) + pointer(img, 'pointermove', 300, 300) // ignored — drag ended + expect(translateOf(img)).toEqual({ x: 30, y: 50 }) + }) + + it('suppresses native image dragging so hold-drag pans instead', () => { + const img = makeImage() + attachZoomPan(img) + const ev = new Event('dragstart', { cancelable: true }) + img.dispatchEvent(ev) + expect(ev.defaultPrevented).toBe(true) + }) + + it('shows no special cursor at fit (panning unavailable)', () => { + const img = makeImage() + attachZoomPan(img) + expect(img.style.cursor).toBe('') + }) + + it('shows a grab cursor once zoomed in', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) + expect(img.style.cursor).toBe('grab') + }) + + it('shows a grabbing cursor while dragging', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) + pointer(img, 'pointerdown', 100, 100) + expect(img.style.cursor).toBe('grabbing') + }) + + it('returns to a grab cursor after the drag ends', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) + pointer(img, 'pointerdown', 100, 100) + pointer(img, 'pointerup', 100, 100) + expect(img.style.cursor).toBe('grab') + }) + + it('clears the cursor when zooming back out to fit', () => { + const img = makeImage() + attachZoomPan(img) + wheel(img, -100) + wheel(img, 100) // back to scale 1 + expect(img.style.cursor).toBe('') + }) +}) + +// ─── openGallery — zoom/pan scope (images only) ───────────────────────────────── + +describe('openGallery zoom/pan wiring', () => { + afterEach(() => { + // Close any open overlay and detach its global keydown listener. + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + document.body.innerHTML = '' + }) + + it('makes the shown image zoomable', () => { + openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0) + const media = document.querySelector('.gallery-media') + expect(media.tagName).toBe('IMG') + wheel(media, -100) + expect(scaleOf(media)).toBeCloseTo(1.12, 5) + }) + + it('leaves video untouched (no zoom transform)', () => { + openGallery([{ type: 'video', url: 'http://x/a.mp4', task: {} }], 0) + const media = document.querySelector('.gallery-media') + expect(media.tagName).toBe('VIDEO') + wheel(media, -100) + expect(media.style.transform).toBe('') + }) + + it('resets zoom and pan to fit when switching images', () => { + const items = [ + { type: 'image', url: 'a', task: {} }, + { type: 'image', url: 'b', task: {} }, + ] + openGallery(items, 0) + const first = document.querySelector('.gallery-media') + wheel(first, -100) // zoom image 0 + pointer(first, 'pointerdown', 100, 100) + pointer(first, 'pointermove', 140, 160) // pan image 0 + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + const second = document.querySelector('.gallery-media') + expect(second).not.toBe(first) // a fresh element is rendered + expect(scaleOf(second)).toBe(1) + expect(translateOf(second)).toEqual({ x: 0, y: 0 }) + }) +}) + +// ─── openGallery — copy image with metadata (Ctrl/⌘+C, images only) ───────────── + +function ctrlC({ meta = false } = {}) { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'c', ctrlKey: !meta, metaKey: meta })) +} + +describe('openGallery copy image', () => { + let clipboardWrite + + beforeEach(() => { + clipboardWrite = vi.fn().mockResolvedValue(undefined) + // jsdom implements none of these — supply mocks for the copy path. + global.ClipboardItem = vi.fn(function ClipboardItem(data) { this.data = data }) + Object.defineProperty(navigator, 'clipboard', { + value: { write: clipboardWrite }, configurable: true, + }) + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' })), + }) + }) + + afterEach(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + document.body.innerHTML = '' + vi.restoreAllMocks() + }) + + it('writes the original image blob to the clipboard as a ClipboardItem', async () => { + openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0, { t: (k) => k }) + ctrlC() + await vi.waitFor(() => expect(clipboardWrite).toHaveBeenCalledTimes(1)) + expect(global.fetch).toHaveBeenCalledWith('http://x/a.png') + expect(global.ClipboardItem).toHaveBeenCalledWith({ 'image/png': expect.any(Blob) }) + }) + + it('also copies on ⌘+C (metaKey)', async () => { + openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0, { t: (k) => k }) + ctrlC({ meta: true }) + await vi.waitFor(() => expect(clipboardWrite).toHaveBeenCalledTimes(1)) + }) + + it('shows a confirmation toast after a successful copy', async () => { + openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0, { t: (k) => k }) + ctrlC() + await vi.waitFor(() => expect(document.body.textContent).toContain('copied')) + }) + + it('shows an error toast when the copy fails (no URL fallback)', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('network down')) + openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0, { t: (k) => k }) + ctrlC() + await vi.waitFor(() => expect(document.body.textContent).toContain('copyFailed')) + expect(clipboardWrite).not.toHaveBeenCalled() + }) + + it('shows an error toast when the server responds not-ok', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, status: 404, + blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' })), + }) + openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0, { t: (k) => k }) + ctrlC() + await vi.waitFor(() => expect(document.body.textContent).toContain('copyFailed')) + expect(clipboardWrite).not.toHaveBeenCalled() + }) + + it('is a no-op for video items', async () => { + openGallery([{ type: 'video', url: 'http://x/a.mp4', task: {} }], 0, { t: (k) => k }) + ctrlC() + await new Promise((r) => setTimeout(r, 10)) // give any stray async a chance to fire + expect(global.fetch).not.toHaveBeenCalled() + expect(clipboardWrite).not.toHaveBeenCalled() + }) + + it('does not let a slow earlier copy overwrite a newer copy result', async () => { + // press1 fetch ok, but clipboard.write stays pending until we resolve it. + let releaseWrite + clipboardWrite.mockImplementationOnce(() => new Promise((r) => { releaseWrite = r })) + // press2 fetch rejects fast → shows the error toast first. + global.fetch = vi.fn() + .mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(new Blob(['x'], { type: 'image/png' })) }) + .mockRejectedValueOnce(new Error('net down')) + + openGallery([{ type: 'image', url: 'http://x/a.png', task: {} }], 0, { t: (k) => k }) + + ctrlC() // press1 → success path, parks on the pending clipboard.write + await Promise.resolve(); await Promise.resolve() // reach the pending write + ctrlC() // press2 → fails fast, shows copyFailed + await vi.waitFor(() => expect(document.body.textContent).toContain('copyFailed')) + + releaseWrite() // press1's write resolves late — must NOT reshow the success toast + await Promise.resolve(); await Promise.resolve() + expect(document.body.textContent).toContain('copyFailed') + expect(document.body.textContent).not.toContain('copied') + }) +}) + +// ─── openGallery — load-workflow button ───────────────────────────────────────── + +function galleryDeps(loadGraphData = () => {}) { + return { app: { loadGraphData }, t: (k) => k } +} + +describe('openGallery load-workflow button', () => { + afterEach(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + document.body.innerHTML = '' + }) + + it('is hidden when the current item has no workflow', () => { + openGallery([{ type: 'image', url: 'u', task: {} }], 0, galleryDeps()) + expect(document.querySelector('.gallery-load-workflow').style.display).toBe('none') + }) + + it('is visible when the current item has a workflow', () => { + openGallery([{ type: 'image', url: 'u', task: { workflow: { nodes: [] } } }], 0, galleryDeps()) + expect(document.querySelector('.gallery-load-workflow').style.display).not.toBe('none') + }) + + it('loads the workflow and closes the overlay on click', () => { + const loadGraphData = vi.fn() + const wf = { nodes: [1] } + openGallery([{ type: 'image', url: 'u', task: { workflow: wf } }], 0, galleryDeps(loadGraphData)) + document.querySelector('.gallery-load-workflow').click() + expect(loadGraphData).toHaveBeenCalledWith(wf) + expect(document.querySelector('.gallery-media')).toBeNull() // overlay removed + }) + + it('updates button visibility when navigating between items', () => { + const items = [ + { type: 'image', url: 'a', task: { workflow: { nodes: [] } } }, + { type: 'image', url: 'b', task: {} }, + ] + openGallery(items, 0, galleryDeps()) + const btn = document.querySelector('.gallery-load-workflow') + expect(btn.style.display).not.toBe('none') // item 0 has a workflow + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + expect(btn.style.display).toBe('none') // item 1 has none + }) +}) + +// ─── openGallery — corner buttons (close / load-workflow) ─────────────────────── + +describe('openGallery corner buttons', () => { + afterEach(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + document.body.innerHTML = '' + }) + + it('gives the close button a tooltip', () => { + openGallery([{ type: 'image', url: 'u', task: {} }], 0, { t: (k) => k }) + expect(document.querySelector('.gallery-close').title).toBe('close') + }) + + it('places the load-workflow button on the same row as close (beside ✕)', () => { + openGallery([{ type: 'image', url: 'u', task: { workflow: {} } }], 0, { t: (k) => k }) + const closeBtn = document.querySelector('.gallery-close') + const loadBtn = document.querySelector('.gallery-load-workflow') + expect(loadBtn.style.top).toBe('16px') + expect(loadBtn.style.top).toBe(closeBtn.style.top) // side by side, not stacked below + }) +}) + +// ─── openGallery — help hint bar ──────────────────────────────────────────────── + +describe('openGallery hint bar', () => { + afterEach(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + document.body.innerHTML = '' + }) + + it('shows a top-left help hint bar with the localized help text', () => { + openGallery([{ type: 'image', url: 'u', task: {} }], 0, { t: (k) => k }) + const hint = document.querySelector('.gallery-hint') + expect(hint).not.toBeNull() + expect(hint.textContent).toBe('galleryHelp') + }) +}) diff --git a/tests/helpers.test.js b/tests/helpers.test.js index c0737bc..d984b8b 100644 --- a/tests/helpers.test.js +++ b/tests/helpers.test.js @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { mediaType } from '../web/lib/helpers.js' +import { describe, it, expect, afterEach, vi } from 'vitest' +import { mediaType, showToast } from '../web/lib/helpers.js' describe('mediaType', () => { it.each([ @@ -52,3 +52,44 @@ describe('mediaType', () => { expect(mediaType('output.v2.mp4')).toBe('video') }) }) + +describe('showToast', () => { + afterEach(() => { + vi.useRealTimers() + document.body.innerHTML = '' + }) + + it('renders a dark-surface toast with a coloured text and border', () => { + showToast('done', 1500, 'green') + const toast = document.body.querySelector('div') + expect(toast.textContent).toBe('done') + expect(toast.style.color).toBe('green') + expect(toast.style.border).toContain('green') + expect(toast.style.backgroundColor).toContain('rgb(26, 26, 26)') // #1a1a1a + }) + + it('shows only one toast at a time (removes any existing toast first)', () => { + showToast('first') + showToast('second') + const toasts = document.body.querySelectorAll('.queue-sidebar-toast') + expect(toasts.length).toBe(1) + expect(toasts[0].textContent).toBe('second') + }) + + it('starts below and transparent so it can slide up and fade in', () => { + showToast('hi') + // Synchronously after append (before the rAF that animates it into place). + const toast = document.body.querySelector('.queue-sidebar-toast') + expect(toast.style.opacity).toBe('0') + expect(toast.style.transform).toContain('16px') // offset below resting position + }) + + it('removes the toast after the (halved) default duration', () => { + vi.useFakeTimers() + showToast('bye') + expect(document.body.querySelector('div')).not.toBeNull() + vi.advanceTimersByTime(1500) // default duration → begins fade-out + vi.advanceTimersByTime(300) // fade transition → removal + expect(document.body.querySelector('div')).toBeNull() + }) +}) diff --git a/web/lib/constants.js b/web/lib/constants.js index 25c6754..e682797 100644 --- a/web/lib/constants.js +++ b/web/lib/constants.js @@ -28,6 +28,12 @@ export const GALLERY_NAV_BTN = 'position:fixed;top:50%;transform:translateY(-50%);' + 'background:rgba(255,255,255,.1);border:none;color:rgba(255,255,255,.8);' + 'font-size:2rem;padding:12px 18px;cursor:pointer;border-radius:6px' +// Shared shape for the top-corner buttons (close, load-workflow); each caller +// prepends its own top/right offset plus background/color (they differ), and +// `display:` where needed. +export const GALLERY_CORNER_BTN = + 'position:fixed;border:none;font-size:1.1rem;' + + 'padding:8px 12px;cursor:pointer;border-radius:6px' export const TOOLBAR_BTN = 'background:none;border:none;cursor:pointer;padding:6px;border-radius:4px;' + 'color:var(--input-text,#eee);font-size:14px' diff --git a/web/lib/gallery.js b/web/lib/gallery.js index c79955e..75f9bf3 100644 --- a/web/lib/gallery.js +++ b/web/lib/gallery.js @@ -1,5 +1,79 @@ -import { GALLERY_NAV_BTN } from './constants.js' -import { el, elHtml } from './helpers.js' +import { GALLERY_NAV_BTN, GALLERY_CORNER_BTN } from './constants.js' +import { el, elHtml, showToast } from './helpers.js' + +// ─── Zoom / pan (images only) ─────────────────────────────────────────────────── + +const ZOOM_STEP = 0.12 +const ZOOM_MIN = 1 +const ZOOM_MAX = 4 + +/** + * Wire wheel-zoom and drag-pan onto an image element. Scale is clamped to + * [ZOOM_MIN, ZOOM_MAX]; panning is only active while zoomed in (scale > 1). + * State lives in this closure and is baked into the element's CSS transform, + * so re-rendering the image (on open / navigation) starts fresh at fit. + * + * @param {HTMLElement} media - the gallery element + */ +export function attachZoomPan(media) { + let scale = ZOOM_MIN + let offsetX = 0 + let offsetY = 0 + let dragging = false + let startX = 0 + let startY = 0 + let startOffsetX = 0 + let startOffsetY = 0 + + const apply = () => { + media.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})` + // Cursor affordance: grab when panning is available, grabbing mid-drag. + media.style.cursor = scale > ZOOM_MIN ? (dragging ? 'grabbing' : 'grab') : '' + } + + // is natively draggable; suppress it so press-and-hold pans the image + // instead of starting a browser drag-and-drop ghost. + media.addEventListener('dragstart', (e) => e.preventDefault()) + + media.addEventListener('wheel', (e) => { + if (!e.deltaY) return // ignore horizontal swipes (deltaY 0) — don't zoom or block scroll + e.preventDefault() + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP + scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, scale + delta)) + if (scale <= ZOOM_MIN) { offsetX = 0; offsetY = 0 } // recenter when back at fit + apply() + }) + + media.addEventListener('pointerdown', (e) => { + if (scale <= ZOOM_MIN) return // panning only applies while zoomed in + dragging = true + startX = e.clientX + startY = e.clientY + startOffsetX = offsetX + startOffsetY = offsetY + media.setPointerCapture?.(e.pointerId) // keep tracking if the pointer leaves the element + apply() // switch to the grabbing cursor + }) + + media.addEventListener('pointermove', (e) => { + if (!dragging) return + offsetX = startOffsetX + (e.clientX - startX) + offsetY = startOffsetY + (e.clientY - startY) + apply() + }) + + const endDrag = (e) => { + if (!dragging) return + dragging = false + media.releasePointerCapture?.(e.pointerId) + apply() // revert the grabbing cursor back to grab + } + media.addEventListener('pointerup', endDrag) + media.addEventListener('pointercancel', endDrag) + media.addEventListener('pointerleave', endDrag) + + apply() +} // ─── Gallery Overlay ────────────────────────────────────────────────────────── @@ -13,6 +87,7 @@ function createGalleryMedia(item) { 'border-radius:4px;box-shadow:0 8px 32px rgba(0,0,0,.6)', ) media.src = url + attachZoomPan(media) } else { media = el( 'video', @@ -53,8 +128,45 @@ function createGalleryNav(overlay, items, getIdx, setIdx, showFn) { overlay.appendChild(next) } -export function openGallery(items, startIdx) { +// ─── Copy image to clipboard ──────────────────────────────────────────────────── + +// Monotonic token identifying the latest copy request. A press bumps this and +// captures its value; only the still-latest request may show its toast, so a +// slow earlier copy resolving late can't overwrite a newer press's result. +let copyRequestId = 0 + +/** + * Copy the current image to the clipboard as a ClipboardItem. + * On any failure it surfaces an error toast — deliberately no URL fallback. + * + * NOTE(debt): Chromium's async-clipboard write decodes and re-encodes the PNG + * (stripping ComfyUI's embedded workflow metadata) and rejects non-PNG types + * outright. Actually preserving metadata would require web custom formats + * (`web image/png`) plus reader opt-in — pending a design decision. + * + * @param {object} item - gallery item ({ type, url, ... }) + * @param {Function} t - i18n lookup + */ +async function copyImage(item, t) { + const requestId = ++copyRequestId + try { + const res = await fetch(item.url) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const blob = await res.blob() + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]) + if (requestId !== copyRequestId) return // superseded by a newer copy + showToast(t('copied'), undefined, 'var(--p-green-500,#22c55e)') // success confirmation + } catch (err) { + console.error('[QueueSidebar] Copy image failed:', err) + if (requestId !== copyRequestId) return // superseded by a newer copy + showToast(t('copyFailed')) + } +} + +export function openGallery(items, startIdx, deps = {}) { let idx = startIdx + const t = deps.t ?? ((k) => k) + const app = deps.app const overlay = el( 'div', 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.92);' + @@ -77,6 +189,7 @@ export function openGallery(items, startIdx) { counter.className = 'gallery-counter' overlay.appendChild(counter) } + loadBtn.style.display = items[idx].task?.workflow ? 'block' : 'none' } if (items.length > 1) { @@ -85,16 +198,56 @@ export function openGallery(items, startIdx) { const closeBtn = el( 'button', - 'position:fixed;top:16px;right:16px;background:rgba(255,255,255,.1);' + - 'border:none;color:rgba(255,255,255,.8);font-size:1.1rem;padding:8px 12px;' + - 'cursor:pointer;border-radius:6px', '✕', + `top:16px;right:16px;background-color:rgba(255,255,255,.1);color:#ef4444;${GALLERY_CORNER_BTN}`, + '✕', ) + closeBtn.className = 'gallery-close' + closeBtn.title = t('close') + addHoverEffect(closeBtn) // translucent-grey button, red ✕ icon; hover brightens closeBtn.addEventListener('click', (e) => { e.stopPropagation(); close() }) overlay.appendChild(closeBtn) + + // Load-workflow button left of ✕ — same subtle grey button as ✕, but a white + // icon. Visibility toggled per current item in show(). + const loadBtn = elHtml( + 'button', + `top:16px;right:60px;background-color:rgba(255,255,255,.1);color:rgba(255,255,255,.8);${GALLERY_CORNER_BTN};display:none`, + '', + ) + loadBtn.className = 'gallery-load-workflow' + loadBtn.title = t('loadWorkflow') + addHoverEffect(loadBtn) + loadBtn.addEventListener('click', (e) => { + e.stopPropagation() + const wf = items[idx].task?.workflow + if (!wf) return + app?.loadGraphData(wf) + close() + }) + overlay.appendChild(loadBtn) + + // Top-left help hint bar (SparkToComfy-style) — a non-interactive affordance reminder. + const hint = el( + 'div', + 'position:fixed;top:16px;left:16px;max-width:calc(100vw - 120px);' + + 'padding:7px 10px;border:1px solid rgba(255,255,255,.1);border-radius:6px;' + + 'color:rgba(255,255,255,.72);background-color:rgba(20,20,20,.62);' + + 'backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);' + + 'font-size:11px;line-height:1.25;pointer-events:none;white-space:nowrap;' + + 'overflow:hidden;text-overflow:ellipsis', + t('galleryHelp'), + ) + hint.className = 'gallery-hint' + overlay.appendChild(hint) + overlay.addEventListener('click', close) const onKey = (e) => { if (e.key === 'Escape') return close() + if ((e.ctrlKey || e.metaKey) && (e.key === 'c' || e.key === 'C')) { + if (items[idx].type === 'image') copyImage(items[idx], t) + return + } if (items.length <= 1) return if (e.key === 'ArrowLeft') { idx = (idx - 1 + items.length) % items.length; show() } if (e.key === 'ArrowRight') { idx = (idx + 1) % items.length; show() } diff --git a/web/lib/helpers.js b/web/lib/helpers.js index e5f7c14..24fb7f0 100644 --- a/web/lib/helpers.js +++ b/web/lib/helpers.js @@ -30,18 +30,40 @@ export function mediaType(filename) { // ─── Toast Notification ─────────────────────────────────────────────────────── -export function showToast(message, duration = 3000) { +/** + * Show a transient toast at the bottom of the screen. Uses an opaque dark-grey + * background that matches how the gallery corner buttons look over the dark + * overlay (a translucent-white fill would read differently against a bright + * image), with a coloured text + border to signal tone; defaults to the error + * red. Any toast already on screen is cleared first so only the latest shows. + * @param {string} message + * @param {number} [duration=1500] - visible time in ms before fade-out + * @param {string} [color] - CSS colour for text and border; defaults to the error red + */ +export function showToast(message, duration = 1500, color = 'var(--p-red-500,#ef4444)') { + for (const prev of document.querySelectorAll('.queue-sidebar-toast')) prev.remove() const toast = el( 'div', - 'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:99999;' + - 'padding:10px 20px;border-radius:8px;font-size:13px;color:#fff;' + - 'background:var(--p-red-500,#ef4444);box-shadow:0 4px 12px rgba(0,0,0,.3);' + - 'transition:opacity .3s ease', + 'position:fixed;bottom:64px;left:50%;z-index:99999;' + + 'padding:10px 20px;border-radius:8px;font-size:13px;' + + `color:${color};border:1px solid ${color};` + + 'background-color:#1a1a1a;box-shadow:0 4px 12px rgba(0,0,0,.3);' + + 'transition:opacity .3s ease,transform .3s ease', message, ) + // Start below and transparent (set on style directly, not via cssText, so the + // animated properties are addressable and survive per-property updates). + toast.style.opacity = '0' + toast.style.transform = 'translate(-50%,16px)' + toast.className = 'queue-sidebar-toast' document.body.appendChild(toast) + // Next frame → slide up into place and fade in (a same-frame set wouldn't transition). + requestAnimationFrame(() => { + toast.style.opacity = '1' + toast.style.transform = 'translate(-50%,0)' + }) setTimeout(() => { - toast.style.opacity = '0' + toast.style.opacity = '0' // fade out (position holds) setTimeout(() => toast.remove(), 300) }, duration) } diff --git a/web/locales/en.json b/web/locales/en.json index ea159a4..155962c 100644 --- a/web/locales/en.json +++ b/web/locales/en.json @@ -14,5 +14,9 @@ "deleteTask": "Delete", "interruptTask": "Interrupt", "loadWorkflow": "Load workflow", + "close": "Close", + "copyFailed": "Failed to copy image", + "copied": "Image copied", + "galleryHelp": "←/→ switch · scroll to zoom · drag to pan · Ctrl+C copy · Esc close", "queue": "Queue" } diff --git a/web/locales/zh.json b/web/locales/zh.json index d7885d8..1cf8f11 100644 --- a/web/locales/zh.json +++ b/web/locales/zh.json @@ -14,5 +14,9 @@ "deleteTask": "刪除", "interruptTask": "中斷", "loadWorkflow": "載入工作流程", + "close": "關閉", + "copyFailed": "複製圖片失敗", + "copied": "已複製圖片", + "galleryHelp": "←/→ 切換 · 滾輪縮放 · 長按拖曳 · Ctrl+C 複製 · Esc 離開", "queue": "佇列" } diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index cfb9b6f..127c079 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -177,7 +177,7 @@ function makeCard(task) { if (type !== 'image' && type !== 'video') return const items = galleryItems() const idx = items.findIndex((it) => it.task.promptId === task.promptId) - if (idx !== -1) openGallery(items, idx) + if (idx !== -1) openGallery(items, idx, { app, t }) }) card.addEventListener('contextmenu', (e) => From 97981c4fcc5834f5cc03d706a07fb1a517f675e4 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:19:54 +0800 Subject: [PATCH 2/5] refactor: refresh queue from status events instead of patching ComfyUI internals --- tests/comfyAdapter.test.js | 70 -------------------------------------- web/lib/comfyAdapter.js | 60 +++----------------------------- web/queue-sidebar.js | 6 +--- 3 files changed, 5 insertions(+), 131 deletions(-) diff --git a/tests/comfyAdapter.test.js b/tests/comfyAdapter.test.js index 178f6c0..216375f 100644 --- a/tests/comfyAdapter.test.js +++ b/tests/comfyAdapter.test.js @@ -1,8 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { getComfyLocale, - hookQueuePrompt, - reorderQueueTab, updateTabBadge, normalizeQueue, normalizeHistoryItem, @@ -207,74 +205,6 @@ describe('getComfyLocale', () => { }) }) -// ─── hookQueuePrompt ────────────────────────────────────────────────────────── - -describe('hookQueuePrompt', () => { - it('wraps queuePrompt and calls onQueued after', async () => { - const onQueued = vi.fn() - const origResult = { id: '123' } - const app = { queuePrompt: vi.fn().mockResolvedValue(origResult) } - - hookQueuePrompt(app, onQueued) - - const result = await app.queuePrompt(1, 0) - expect(result).toEqual(origResult) - expect(onQueued).toHaveBeenCalledOnce() - }) - - it('warns and skips when queuePrompt is missing', () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => { }) - const app = {} - hookQueuePrompt(app, vi.fn()) - expect(spy).toHaveBeenCalled() - spy.mockRestore() - }) - - it('warns when app is totally broken', () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => { }) - hookQueuePrompt(null, vi.fn()) - expect(spy).toHaveBeenCalled() - spy.mockRestore() - }) -}) - -// ─── reorderQueueTab ────────────────────────────────────────────────────────── - -describe('reorderQueueTab', () => { - it('moves queue tab after assets tab', () => { - const tabs = [ - { id: 'search' }, - { id: 'assets' }, - { id: 'nodes' }, - { id: 'queue' }, - ] - const app = { extensionManager: { sidebarTab: { sidebarTabs: tabs } } } - reorderQueueTab(app) - expect(tabs.map((t) => t.id)).toEqual(['search', 'assets', 'queue', 'nodes']) - }) - - it('does nothing when queue tab is already at position 0', () => { - const tabs = [{ id: 'queue' }, { id: 'assets' }] - const app = { extensionManager: { sidebarTab: { sidebarTabs: tabs } } } - reorderQueueTab(app) - expect(tabs.map((t) => t.id)).toEqual(['queue', 'assets']) - }) - - it('places queue at index 1 when no assets tab', () => { - const tabs = [{ id: 'search' }, { id: 'nodes' }, { id: 'queue' }] - const app = { extensionManager: { sidebarTab: { sidebarTabs: tabs } } } - reorderQueueTab(app) - expect(tabs.map((t) => t.id)).toEqual(['search', 'queue', 'nodes']) - }) - - it('warns when sidebarTabs is not available', () => { - const spy = vi.spyOn(console, 'warn').mockImplementation(() => { }) - reorderQueueTab({}) - expect(spy).toHaveBeenCalled() - spy.mockRestore() - }) -}) - // ─── updateTabBadge ─────────────────────────────────────────────────────────── describe('updateTabBadge', () => { diff --git a/web/lib/comfyAdapter.js b/web/lib/comfyAdapter.js index 60f0d6f..52bd817 100644 --- a/web/lib/comfyAdapter.js +++ b/web/lib/comfyAdapter.js @@ -6,12 +6,10 @@ * APIs change, only this file needs updating. * * Integration points (all guarded with feature detection): - * 1. queuePrompt hook — monkey-patches app.queuePrompt (wrap, not replace) - * 2. sidebar tab order — mutates sidebarTabs array to reposition the tab - * 3. badge update — reads sidebarTabs to set iconBadge - * 4. queue schema — normalizes /queue response tuples - * 5. history schema — normalizes /history response shape - * 6. locale detection — reads Comfy.Locale setting + * 1. badge update — reads sidebarTabs to set iconBadge + * 2. queue schema — normalizes /queue response tuples + * 3. history schema — normalizes /history response shape + * 4. locale detection — reads Comfy.Locale setting */ // ─── Locale ─────────────────────────────────────────────────────────────────── @@ -30,58 +28,8 @@ export function getComfyLocale(app) { } } -// ─── Queue prompt hook ──────────────────────────────────────────────────────── - -/** - * Wrap app.queuePrompt to call `onQueued` after each prompt submission. - * This is a monkey-patch — logged explicitly so breakage is auditable. - * - * @param {object} app - ComfyUI app instance - * @param {Function} onQueued - callback to invoke after prompt is queued - */ -export function hookQueuePrompt(app, onQueued) { - try { - const orig = app.queuePrompt?.bind(app) - if (typeof orig !== 'function') { - console.warn('[QueueSidebar] app.queuePrompt is not a function — hook skipped (degraded mode)') - return - } - app.queuePrompt = async (...args) => { - const result = await orig(...args) - onQueued() - return result - } - } catch (err) { - console.warn('[QueueSidebar] Could not hook queuePrompt (degraded mode):', err) - } -} - // ─── Sidebar tab management ────────────────────────────────────────────────── -/** - * Attempt to reorder the sidebar tabs so 'queue' appears after 'assets'. - * Accesses internal sidebarTabs array — guarded with feature detection. - * - * @param {object} app - ComfyUI app instance - */ -export function reorderQueueTab(app) { - try { - const tabs = app.extensionManager?.sidebarTab?.sidebarTabs - if (!Array.isArray(tabs)) { - console.warn('[QueueSidebar] sidebarTabs not found — tab reorder skipped (degraded mode)') - return - } - const queueIdx = tabs.findIndex((tab) => tab.id === 'queue') - if (queueIdx > 0) { - const [queueTab] = tabs.splice(queueIdx, 1) - const assetsIdx = tabs.findIndex((tab) => tab.id === 'assets') - tabs.splice(assetsIdx >= 0 ? assetsIdx + 1 : 1, 0, queueTab) - } - } catch (err) { - console.warn('[QueueSidebar] Could not reorder sidebar tabs (degraded mode):', err) - } -} - /** * Update the icon badge on the 'queue' sidebar tab. * diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index 127c079..62945d2 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -8,8 +8,7 @@ import { showContextMenu } from './lib/contextMenu.js' import { makePreview, updateRunningPreview } from './lib/preview.js' import { buildToolbar } from './lib/toolbar.js' import { - getComfyLocale, hookQueuePrompt, reorderQueueTab, - updateTabBadge, normalizeQueue, normalizeHistoryItem, + getComfyLocale, updateTabBadge, normalizeQueue, normalizeHistoryItem, } from './lib/comfyAdapter.js' import { firstOutput, saveOutputCache } from './lib/outputCache.js' // saveOutputCache wired in onExecuted (see below) @@ -359,7 +358,6 @@ app.registerExtension({ await loadI18n() injectBadgeStyle() - hookQueuePrompt(app, refresh) api.addEventListener('status', onStatus) api.addEventListener('execution_start', onExecutionStart) @@ -384,7 +382,5 @@ app.registerExtension({ scrollEl = null }, }) - - reorderQueueTab(app) }, }) From 3360d4094ff49b9d675e12692987ad059fa6ce06 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:20:32 +0800 Subject: [PATCH 3/5] fix: revalidate locale JSON on load so translations aren't served stale --- web/queue-sidebar.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/queue-sidebar.js b/web/queue-sidebar.js index 62945d2..6c15077 100644 --- a/web/queue-sidebar.js +++ b/web/queue-sidebar.js @@ -13,7 +13,9 @@ import { import { firstOutput, saveOutputCache } from './lib/outputCache.js' // saveOutputCache wired in onExecuted (see below) // ─── i18n ────────────────────────────────────────────────────────────────────── -// Translations are loaded from web/locales/.json at startup. +// Translations are loaded from web/locales/.json at startup, fetched with +// `cache: 'no-cache'` so JSON edits take effect on reload instead of serving a stale +// copy (the module graph updates but a plain fetch would keep the cached JSON). let _translations = {} let _fallback = {} @@ -26,11 +28,11 @@ async function loadI18n() { const base = new URL('.', import.meta.url).href + 'locales' const locale = getLocale() try { - _fallback = await fetch(`${base}/en.json`).then((r) => r.json()) + _fallback = await fetch(`${base}/en.json`, { cache: 'no-cache' }).then((r) => r.json()) } catch (err) { console.warn('[QueueSidebar] Failed to load fallback (en) translations:', err) } if (locale !== 'en') { try { - _translations = await fetch(`${base}/${locale}.json`).then((r) => r.json()) + _translations = await fetch(`${base}/${locale}.json`, { cache: 'no-cache' }).then((r) => r.json()) } catch (err) { console.warn(`[QueueSidebar] Failed to load ${locale} translations, using fallback:`, err); _translations = _fallback } } else { _translations = _fallback From 7fb98186220f36937a2ad793bcb52975dd95783e Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:49:33 +0800 Subject: [PATCH 4/5] docs: add project spec and architecture (EN + zh-TW) --- docs/ARCHITECTURE.md | 196 ++++++++++++++++++++++++++++++++++ docs/README.md | 17 +++ docs/SPEC.md | 209 +++++++++++++++++++++++++++++++++++++ docs/zh-TW/ARCHITECTURE.md | 195 ++++++++++++++++++++++++++++++++++ docs/zh-TW/README.md | 18 ++++ docs/zh-TW/SPEC.md | 199 +++++++++++++++++++++++++++++++++++ 6 files changed, 834 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/README.md create mode 100644 docs/SPEC.md create mode 100644 docs/zh-TW/ARCHITECTURE.md create mode 100644 docs/zh-TW/README.md create mode 100644 docs/zh-TW/SPEC.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a9fa34b --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,196 @@ +# ComfyUI Queue Sidebar — Architecture + +- **Status:** Living document +- **Related:** [SPEC.md](./SPEC.md) + +> Diagrams use Mermaid so they render natively on GitHub and version alongside the code. +> The C4 layers below are expressed as Mermaid flowcharts for reliable rendering. + +--- + +## 1. Overview + +The extension is a set of **Vanilla JS ES modules** loaded by ComfyUI from `web/` (declared +by `WEB_DIRECTORY` in `__init__.py`). The browser executes them as native modules that +`import` ComfyUI's own `app` and `api` globals. There is no build step and no runtime +dependency. All coupling to ComfyUI internals is isolated in a single module, +`comfyAdapter.js`. + +--- + +## 2. C4 — System Context + +```mermaid +flowchart TB + user([ComfyUI User]) + + subgraph browser[Browser] + comfy[ComfyUI Frontend
scripts/app.js, scripts/api.js] + ext[Queue Sidebar Extension
web/*.js] + end + + server[(ComfyUI Server
REST + WebSocket)] + + user -->|clicks, keyboard Q, wheel, Ctrl+C| ext + ext -->|import app, api| comfy + ext -->|REST: /queue /history /view /interrupt| server + server -->|WS: status, execution_start, b_preview, executed| ext + comfy <-->|prompt execution| server +``` + +--- + +## 3. C4 — Containers / Components + +Module responsibilities and their layering. Arrows show `import` direction. + +```mermaid +flowchart TB + subgraph entry[Entry / Orchestrator] + main[queue-sidebar.js
state · WS handlers · render · registration] + end + + subgraph coupling[ComfyUI Coupling Point] + adapter[comfyAdapter.js
badge · schema · locale] + end + + subgraph uimod[UI Modules] + preview[preview.js
card previews] + gallery[gallery.js
view mode overlay] + ctx[contextMenu.js
right-click actions] + toolbar[toolbar.js
fit · clear] + end + + subgraph core[Core / Utilities] + cache[outputCache.js
localStorage + firstOutput] + helpers[helpers.js
el · mediaType · safeApi · toast] + constants[constants.js
styles · enums] + end + + main --> adapter + main --> preview + main --> gallery + main --> ctx + main --> toolbar + main --> cache + preview --> cache + preview --> helpers + gallery --> helpers + gallery --> constants + ctx --> helpers + ctx --> constants + toolbar --> helpers + toolbar --> constants + preview --> constants + adapter -.reads.-> comfyglobals[app / api globals] + helpers --> constants +``` + +--- + +## 4. Runtime Sequence — Execution lifecycle + +How a queued prompt flows from submission to a completed card. In 1.3.0 the refresh after +submission is driven purely by the `status` event (the previous `queuePrompt` monkey-patch +is removed). + +```mermaid +sequenceDiagram + actor User + participant Comfy as ComfyUI app/api + participant Ext as Queue Sidebar + participant Server as ComfyUI Server + + User->>Comfy: Queue Prompt + Comfy->>Server: POST /prompt + Server-->>Ext: WS status (queue changed) + Ext->>Server: GET /queue, /history + Ext->>Ext: render() pending + running cards + + Server-->>Ext: WS execution_start {prompt_id} + Ext->>Ext: move pending→running, render() + + loop sampling + Server-->>Ext: WS b_preview {blob} + Ext->>Ext: updateRunningPreview (latent) + end + + Server-->>Ext: WS executed {prompt_id, output} + Ext->>Ext: progressUrl = /view, saveOutputCache(), render() + + Server-->>Ext: WS status (queue empty) + Ext->>Server: GET /history + Ext->>Ext: render() completed card (firstOutput cache-hit) +``` + +--- + +## 5. State Machine — Task lifecycle + +```mermaid +stateDiagram-v2 + [*] --> pending: queued + pending --> running: execution_start + pending --> cancelled: deleted from queue + running --> completed: executed + success + running --> failed: status_str = error + running --> cancelled: status_str = cancelled / interrupt + completed --> [*] + failed --> [*] + cancelled --> [*] +``` + +--- + +## 6. Data Flow + +How output data moves between the server, extension state, the cache, and the DOM — +including the two new view-mode sinks (clipboard, workflow loader). + +```mermaid +flowchart LR + server[(ComfyUI Server)] + state[Extension state
running · pending · history] + render[render + preview] + dom[ComfyUI DOM
sidebar cards] + cache[(localStorage
lastOutput)] + gallery[view mode overlay] + clip[(Clipboard)] + graph[app.loadGraphData] + + server -->|REST + WS| state + state --> render --> dom + server -->|executed output| cache + cache -->|firstOutput| render + dom -->|click card| gallery + gallery -->|Ctrl+C: fetch original blob| clip + gallery -->|Load workflow| graph +``` + +--- + +## 7. Key design decisions + +| Decision | Where | Rationale | +|---|---|---| +| Vanilla JS, no framework / build | whole `web/` | native ES modules; no framework or build step | +| Single ComfyUI coupling point | `comfyAdapter.js` | one file to update when upstream changes | +| Decouple from ComfyUI internals | remove tab-reorder + queuePrompt patch | registry compliance + upgrade safety | +| Cache-first output resolution | `outputCache.js` | correct image for multi-stage workflows | +| Feature-detected degradation | `comfyAdapter.js` | never break ComfyUI if internals move | + +--- + +## 8. Module reference + +| Module | Responsibility | Tests | +|---|---|---| +| `queue-sidebar.js` | Entry: state, WS handlers, render loop, extension registration | (e2e) | +| `comfyAdapter.js` | ComfyUI coupling: badge, schema normalization, locale | `comfyAdapter.test.js` | +| `preview.js` | Card preview rendering (image/video/running/pending) | `preview.test.js` | +| `gallery.js` | View mode overlay: nav, zoom, pan, copy, load workflow | `gallery.test.js` *(new)* | +| `contextMenu.js` | Right-click actions per status | `contextMenu.test.js` | +| `toolbar.js` | Fit toggle, clear-all with confirm | (via render/helpers) | +| `outputCache.js` | `localStorage` cache + `firstOutput` resolver | `outputCache.test.js` | +| `helpers.js` | `el`, `mediaType`, `safeApi`, `showToast` | `helpers.test.js` | +| `constants.js` | Shared styles, extension enums | — | diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..90d260f --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# Documentation + +Project documentation for **ComfyUI Queue Sidebar**. Diagrams are written in Mermaid so they +render natively on GitHub and version alongside the code. + +## Specification & Architecture + +| Doc | What it covers | +|---|---| +| [SPEC.md](./SPEC.md) | What the extension does — goals, use cases, functional & non-functional requirements, data contracts, glossary. | +| [ARCHITECTURE.md](./ARCHITECTURE.md) | How it's built — C4 context/containers, module dependencies, execution sequence, task state machine, data flow. | + +## Conventions + +- **Diagrams:** Mermaid, embedded in the relevant doc. +- **Language:** English (matches `README.md`, code, and `locales/`). +- **Versioning:** `X.Y.Z`, no `v` prefix. diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..493aff8 --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,209 @@ +# ComfyUI Queue Sidebar — Software Specification + +- **Status:** Living document +- **Targets:** 1.3.0 +- **Audience:** maintainers, contributors, ComfyUI extension developers +- **Related:** [ARCHITECTURE.md](./ARCHITECTURE.md) + +--- + +## 1. Overview + +ComfyUI Queue Sidebar is an **in-process ComfyUI frontend extension** that restores a +queue/history panel with live image previews inside ComfyUI's sidebar. It shows pending, +running, and completed tasks as a card grid; updates the running card in real time from +WebSocket events; and lets the user open any output in a full-screen viewer ("view mode"). + +It ships as static `web/*.js` loaded by ComfyUI via `WEB_DIRECTORY` — **no build step, no +runtime dependencies**. + +### Goals + +- Bring back an always-visible queue/history panel with thumbnails. +- Reflect execution state (queue → running → done) with minimal latency. +- Show the *correct* output for multi-stage workflows (e.g. upscaling) — not just the + first node's image. +- Provide a fast full-screen viewer for inspecting, copying, and re-loading outputs. + +### Non-Goals + +- Not a standalone web app; it lives inside ComfyUI's page and DOM. +- No server-side / Python logic (`__init__.py` only declares `WEB_DIRECTORY`). +- No database; the only persistence is a bounded `localStorage` cache. +- No UI framework or build pipeline. + +--- + +## 2. Actors & Use Cases + +The only actor is the **ComfyUI User** operating the ComfyUI web interface. + +```mermaid +flowchart LR + user([ComfyUI User]) + + subgraph QS[Queue Sidebar] + uc1([View queue and history]) + uc2([Inspect live preview]) + uc3([Open output in view mode]) + uc4([Zoom / pan image]) + uc5([Copy image to clipboard]) + uc6([Load workflow from output]) + uc7([Interrupt / delete task]) + uc8([Clear all tasks and history]) + uc9([Toggle image fit]) + end + + user --- uc1 + user --- uc2 + user --- uc3 + user --- uc7 + user --- uc8 + user --- uc9 + uc3 -.-> uc4 + uc3 -.-> uc5 + uc3 -.-> uc6 +``` + +--- + +## 3. Functional Requirements + +### FR-1 — Queue & history display +- Render a card grid combining `pending`, `running`, and `history` tasks (`queue-sidebar.js#render`). +- Order: pending → running → history; cards keyed by `promptId` for stable reconciliation. +- Empty state shows a "no tasks" placeholder. +- Each card shows a status tag (running/pending/completed/failed/cancelled) and, when known, + execution time. + +### FR-2 — Real-time preview +- During sampling, update the running card from `b_preview` WS events (latent blob previews). +- On each `executed` WS event, switch the running card to the decoded `/view` output and + persist the selected output to the cache (`outputCache.saveOutputCache`). +- Completed/historical cards resolve their thumbnail via `firstOutput(outputs, promptId)`, + which prefers the cached output and falls back to dict iteration (backward compatible). + +### FR-3 — Task status lifecycle +- Status transitions follow the state machine in [ARCHITECTURE.md](./ARCHITECTURE.md#5-state-machine--task-lifecycle). +- Failed and cancelled tasks are derived from the `/history` `status_str`. + +### FR-4 — View mode (full-screen viewer) +Opening a completed image/video card opens a full-screen overlay (`gallery.js`). + +- **FR-4.1 Baseline sizing** — the image fits the viewport at `max-width:90vw; max-height:90vh; + object-fit:contain` (this is the "minimum size" baseline; zoom scales up from here). +- **FR-4.2 Navigation** — prev/next arrows, `←`/`→` keys, counter; close via `✕`, `Esc`, or + backdrop click. +- **FR-4.3 Wheel zoom** *(new in 1.3.0)* — scroll to zoom, step `±0.12`, clamped to + `[1, 4]`, centered on the image. At scale `1` the offset resets. +- **FR-4.4 Drag-pan** *(new)* — when zoomed (`scale > 1`), press-and-hold to drag-pan + (pointer capture); at scale `1` panning is disabled. +- **FR-4.5 Copy image** *(new)* — `Ctrl/⌘+C` copies the current image to the clipboard as a + `ClipboardItem`. On success it shows a confirmation toast; on failure an error toast; it does + **not** fall back to copying a URL. Applies to **images only**. + + > **Metadata is _not_ preserved — a browser-platform limit, not a bug.** The async Clipboard + > API re-encodes images on write (Chromium/WebKit decode to a bitmap and emit a fresh PNG to + > defend against decompression-bomb attacks), which drops ComfyUI's embedded workflow + > (`tEXt`/`iTXt` chunks); the standard write path also accepts only `image/png`, so non-PNG + > outputs are rejected outright. The one byte-exact escape hatch (web custom formats, + > `web image/png`) needs the *receiving* app to opt in, so it would not restore the workflow + > on paste. `Ctrl+C` is therefore a pixel-level "copy image", not a metadata transport — to + > recover the workflow use the **Load-workflow button (FR-4.6)** or download the original file + > from `/view` (raw bytes, chunks intact; saving bypasses clipboard sanitization). +- **FR-4.6 Load workflow button** *(new)* — a translucent-grey button (white icon) to the left of the red `✕` that, + when the output carries a workflow, exits view mode and calls `app.loadGraphData(workflow)` — + equivalent to the right-click "Load workflow". Hidden when no workflow is available. +- **FR-4.7 Scope** — zoom/pan/copy apply to **images only**; video keeps current behaviour. +- **FR-4.8 Help hint** *(new)* — a non-interactive top-left bar listing the viewer controls + (`←/→`, wheel-zoom, drag-pan, `Ctrl+C`, `Esc`). + +### FR-5 — Context menu +Right-click a card (`contextMenu.js`): +- Running → **Interrupt** (`POST /interrupt`). +- Pending → **Delete** (`POST /queue {delete}`). +- Completed/failed/cancelled → **Delete** (`POST /history {delete}`), and **Load workflow** + when `task.workflow` is present (`app.loadGraphData`). + +### FR-6 — Toolbar +- **Image-fit toggle** (contain ↔ cover) re-renders previews (`toolbar.createFitButton`). +- **Clear all** with a confirm popup clears queue + history (`toolbar.createClearButton`). + +### FR-7 — Internationalization +- Labels load from `web/locales/.json`, locale read from ComfyUI's `Comfy.Locale` + setting; English is the fallback (`comfyAdapter.getComfyLocale`, `queue-sidebar.js#t`). + +### FR-8 — ComfyUI integration +- Register a sidebar tab + toggle command/keybinding (`Q`). +- Maintain an icon badge showing `running + pending` count. +- **Tab ordering:** uses ComfyUI's default registration order. *(Changed in 1.3.0 — the + previous `sidebarTabs` array mutation is removed for registry compliance and upgrade safety.)* +- **Queue refresh:** driven by the `status` WS event. *(Changed in 1.3.0 — the previous + `queuePrompt` monkey-patch is removed.)* + +--- + +## 4. Non-Functional Requirements + +- **NFR-1 Zero build / zero deps** — ships as native ES modules. +- **NFR-2 Graceful degradation** — every ComfyUI-internal touch point is feature-detected and + fails into a logged "degraded mode" rather than throwing (`comfyAdapter.js`). +- **NFR-3 Registry compliance** — minimize patterns that trigger Comfy Registry security + review; avoid mutating ComfyUI internals where an official API or event exists. +- **NFR-4 Testability** — DOM-producing logic is unit-tested with Vitest + jsdom; each `lib/` + module has a matching `tests/*.test.js`. +- **NFR-5 Performance** — rendering uses keyed reconciliation; running-card previews update in + place without full rebuilds. +- **NFR-6 Resilience** — API failures show a toast and return `null` (`helpers.safeApi`); + cache writes fail silently on quota errors. + +--- + +## 5. Data Contracts + +### 5.1 ComfyUI REST (via `api.fetchApi`) +| Endpoint | Method | Use | +|---|---|---| +| `/queue` | GET | running + pending tuples → `normalizeQueue` | +| `/queue` | POST | `{delete:[id]}` / `{clear:true}` | +| `/history?max_items=N` | GET | history map → `normalizeHistoryItem` | +| `/history` | POST | `{delete:[id]}` / `{clear:true}` | +| `/view?filename&subfolder&type` | GET | output image/video URL | +| `/interrupt` | POST | stop running task | + +### 5.2 WebSocket events (`api.addEventListener`) +| Event | Payload | Effect | +|---|---|---| +| `status` | queue counts | trigger `refresh()` | +| `execution_start` | `{prompt_id}` | move pending→running immediately | +| `b_preview` | image blob | update running card latent preview | +| `executed` | `{prompt_id, output}` | set `/view` preview + `saveOutputCache` | + +### 5.3 localStorage +- Key `queueSidebar.lastOutput`: `{ [promptId]: { filename, subfolder, type } }`, bounded to + `OUTPUT_CACHE_MAX = 200` (oldest evicted). + +### 5.4 Workflow extraction +- `workflow = item.prompt[3].extra_pnginfo.workflow` (deeply nested; guarded — may be `null`). + +--- + +## 6. Verification + +Automated (Vitest): each `lib/` module has unit tests; new view-mode interactions add +`tests/gallery.test.js`. End-to-end behaviour (Playwright) covers load, card ordering, +real-time preview, output cache, view mode, context menu, toolbar, and i18n; acceptance +requires a running ComfyUI instance. + +--- + +## 7. Glossary + +| Term | Meaning | +|---|---| +| **Task / card** | One queued prompt, shown as a grid card, identified by `promptId`. | +| **View mode** | The full-screen image/video overlay (`gallery.js`). | +| **Output cache** | `localStorage` map of the last image-producing output per prompt. | +| **Coupling point** | `comfyAdapter.js` — the single module that touches ComfyUI internals. | +| **Degraded mode** | State after a feature-detected ComfyUI internal is missing; logged, non-fatal. | +| **`firstOutput`** | Resolver that picks the output to display, cache-first. | diff --git a/docs/zh-TW/ARCHITECTURE.md b/docs/zh-TW/ARCHITECTURE.md new file mode 100644 index 0000000..a85d44f --- /dev/null +++ b/docs/zh-TW/ARCHITECTURE.md @@ -0,0 +1,195 @@ +# ComfyUI Queue Sidebar — 架構 + +- **狀態:** 持續維護文件 +- **相關:** [SPEC.md](./SPEC.md) +- **English:** [../ARCHITECTURE.md](../ARCHITECTURE.md) + +> 圖表使用 Mermaid,可在 GitHub 原生渲染並隨程式碼一起版控。以下 C4 分層以 Mermaid flowchart +> 表達以確保可靠渲染。 + +--- + +## 1. 概述 + +本擴充是一組由 ComfyUI 從 `web/` 載入的 **Vanilla JS ES modules**(由 `__init__.py` 的 +`WEB_DIRECTORY` 宣告)。瀏覽器將其作為原生模組執行,`import` ComfyUI 自己的 `app` 與 `api` +全域物件。無建置步驟、無執行期相依。所有對 ComfyUI 內部的耦合皆隔離於單一模組 +`comfyAdapter.js`。 + +--- + +## 2. C4 — 系統情境(System Context) + +```mermaid +flowchart TB + user([ComfyUI User]) + + subgraph browser[Browser] + comfy[ComfyUI Frontend
scripts/app.js, scripts/api.js] + ext[Queue Sidebar Extension
web/*.js] + end + + server[(ComfyUI Server
REST + WebSocket)] + + user -->|clicks, keyboard Q, wheel, Ctrl+C| ext + ext -->|import app, api| comfy + ext -->|REST: /queue /history /view /interrupt| server + server -->|WS: status, execution_start, b_preview, executed| ext + comfy <-->|prompt execution| server +``` + +--- + +## 3. C4 — 容器/元件(Containers / Components) + +模組職責與其分層。箭頭表示 `import` 方向。 + +```mermaid +flowchart TB + subgraph entry[Entry / Orchestrator] + main[queue-sidebar.js
state · WS handlers · render · registration] + end + + subgraph coupling[ComfyUI Coupling Point] + adapter[comfyAdapter.js
badge · schema · locale] + end + + subgraph uimod[UI Modules] + preview[preview.js
card previews] + gallery[gallery.js
view mode overlay] + ctx[contextMenu.js
right-click actions] + toolbar[toolbar.js
fit · clear] + end + + subgraph core[Core / Utilities] + cache[outputCache.js
localStorage + firstOutput] + helpers[helpers.js
el · mediaType · safeApi · toast] + constants[constants.js
styles · enums] + end + + main --> adapter + main --> preview + main --> gallery + main --> ctx + main --> toolbar + main --> cache + preview --> cache + preview --> helpers + gallery --> helpers + gallery --> constants + ctx --> helpers + ctx --> constants + toolbar --> helpers + toolbar --> constants + preview --> constants + adapter -.reads.-> comfyglobals[app / api globals] + helpers --> constants +``` + +--- + +## 4. 執行時序 — 執行生命週期 + +一個排入佇列的 prompt 如何從送出流動到完成的卡片。在 1.3.0 中,送出後的刷新純由 `status` +事件驅動(先前的 `queuePrompt` monkey-patch 已移除)。 + +```mermaid +sequenceDiagram + actor User + participant Comfy as ComfyUI app/api + participant Ext as Queue Sidebar + participant Server as ComfyUI Server + + User->>Comfy: Queue Prompt + Comfy->>Server: POST /prompt + Server-->>Ext: WS status (queue changed) + Ext->>Server: GET /queue, /history + Ext->>Ext: render() pending + running cards + + Server-->>Ext: WS execution_start {prompt_id} + Ext->>Ext: move pending→running, render() + + loop sampling + Server-->>Ext: WS b_preview {blob} + Ext->>Ext: updateRunningPreview (latent) + end + + Server-->>Ext: WS executed {prompt_id, output} + Ext->>Ext: progressUrl = /view, saveOutputCache(), render() + + Server-->>Ext: WS status (queue empty) + Ext->>Server: GET /history + Ext->>Ext: render() completed card (firstOutput cache-hit) +``` + +--- + +## 5. 狀態機 — 任務生命週期 + +```mermaid +stateDiagram-v2 + [*] --> pending: queued + pending --> running: execution_start + pending --> cancelled: deleted from queue + running --> completed: executed + success + running --> failed: status_str = error + running --> cancelled: status_str = cancelled / interrupt + completed --> [*] + failed --> [*] + cancelled --> [*] +``` + +--- + +## 6. 資料流 + +輸出資料如何在伺服器、擴充狀態、快取與 DOM 之間流動——包含兩個新的 view-mode 出口 +(剪貼簿、工作流程載入器)。 + +```mermaid +flowchart LR + server[(ComfyUI Server)] + state[Extension state
running · pending · history] + render[render + preview] + dom[ComfyUI DOM
sidebar cards] + cache[(localStorage
lastOutput)] + gallery[view mode overlay] + clip[(Clipboard)] + graph[app.loadGraphData] + + server -->|REST + WS| state + state --> render --> dom + server -->|executed output| cache + cache -->|firstOutput| render + dom -->|click card| gallery + gallery -->|Ctrl+C: fetch original blob| clip + gallery -->|Load workflow| graph +``` + +--- + +## 7. 關鍵設計決策 + +| 決策 | 位置 | 理由 | +|---|---|---| +| Vanilla JS,無框架/建置 | 整個 `web/` | 原生 ES modules;無框架或建置步驟 | +| 單一 ComfyUI 耦合點 | `comfyAdapter.js` | 上游變更時只需更新一個檔案 | +| 與 ComfyUI 內部解耦 | 移除 tab 重排 + queuePrompt 補丁 | registry 合規 + 升級安全 | +| 快取優先的輸出解析 | `outputCache.js` | 對多階段工作流程顯示正確圖片 | +| 功能偵測降級 | `comfyAdapter.js` | ComfyUI 內部移動時絕不弄壞它 | + +--- + +## 8. 模組參照 + +| 模組 | 職責 | 測試 | +|---|---|---| +| `queue-sidebar.js` | 進入點:狀態、WS handlers、render 迴圈、擴充註冊 | (e2e) | +| `comfyAdapter.js` | ComfyUI 耦合:badge、schema 正規化、locale | `comfyAdapter.test.js` | +| `preview.js` | 卡片預覽渲染(image/video/running/pending) | `preview.test.js` | +| `gallery.js` | View mode 覆蓋層:導覽、縮放、平移、複製、載入工作流程 | `gallery.test.js`(新) | +| `contextMenu.js` | 依狀態的右鍵動作 | `contextMenu.test.js` | +| `toolbar.js` | 適配切換、附確認的清除全部 | (透過 render/helpers) | +| `outputCache.js` | `localStorage` 快取 + `firstOutput` 解析器 | `outputCache.test.js` | +| `helpers.js` | `el`、`mediaType`、`safeApi`、`showToast` | `helpers.test.js` | +| `constants.js` | 共用樣式、擴充列舉 | — | diff --git a/docs/zh-TW/README.md b/docs/zh-TW/README.md new file mode 100644 index 0000000..e2c5a5d --- /dev/null +++ b/docs/zh-TW/README.md @@ -0,0 +1,18 @@ +# 文件(繁體中文) + +**ComfyUI Queue Sidebar** 的專案文件。圖表以 Mermaid 撰寫,可在 GitHub 原生渲染並隨程式碼一起版控。 + +> English version: [../README.md](../README.md) + +## 規格與架構 + +| 文件 | 內容 | +|---|---| +| [SPEC.md](./SPEC.md) | 擴充功能做什麼——目標、使用情境、功能與非功能需求、資料契約、術語表。 | +| [ARCHITECTURE.md](./ARCHITECTURE.md) | 如何建構——C4 情境/容器、模組依賴、執行時序、任務狀態機、資料流。 | + +## 慣例 + +- **圖表:** Mermaid,內嵌於對應文件。 +- **語言:** 英文為主(對應 `README.md`、程式碼與 `locales/`),本目錄為繁體中文對照版。 +- **版本號:** `X.Y.Z`,不加 `v` 前綴。 diff --git a/docs/zh-TW/SPEC.md b/docs/zh-TW/SPEC.md new file mode 100644 index 0000000..74e7b5b --- /dev/null +++ b/docs/zh-TW/SPEC.md @@ -0,0 +1,199 @@ +# ComfyUI Queue Sidebar — 軟體規格 + +- **狀態:** 持續維護文件 +- **目標版本:** 1.3.0 +- **對象:** 維護者、貢獻者、ComfyUI 擴充開發者 +- **相關:** [ARCHITECTURE.md](./ARCHITECTURE.md) +- **English:** [../SPEC.md](../SPEC.md) + +--- + +## 1. 概述 + +ComfyUI Queue Sidebar 是一個**行程內(in-process)的 ComfyUI 前端擴充**,在 ComfyUI 側邊欄 +中還原了帶即時圖片預覽的佇列/歷史面板。它以卡片網格顯示等待中、執行中與已完成的任務; +從 WebSocket 事件即時更新執行中的卡片;並讓使用者在全螢幕檢視器(「view mode」)中開啟任何輸出。 + +它以靜態 `web/*.js` 形式由 ComfyUI 透過 `WEB_DIRECTORY` 載入——**無建置步驟、無執行期相依**。 + +### 目標 + +- 還原一個常駐可見、帶縮圖的佇列/歷史面板。 +- 以最小延遲反映執行狀態(佇列 → 執行中 → 完成)。 +- 對多階段工作流程(如放大)顯示**正確**的輸出——而非只顯示第一個節點的圖片。 +- 提供快速的全螢幕檢視器,用於檢視、複製與重新載入輸出。 + +### 非目標 + +- 不是獨立的 web 應用;它存在於 ComfyUI 的頁面與 DOM 之中。 +- 無伺服器端/Python 邏輯(`__init__.py` 僅宣告 `WEB_DIRECTORY`)。 +- 無資料庫;唯一的持久化是一個有界的 `localStorage` 快取。 +- 無 UI 框架或建置流程。 + +--- + +## 2. 角色與使用情境 + +唯一的角色是操作 ComfyUI 網頁介面的**ComfyUI 使用者**。 + +```mermaid +flowchart LR + user([ComfyUI User]) + + subgraph QS[Queue Sidebar] + uc1([View queue and history]) + uc2([Inspect live preview]) + uc3([Open output in view mode]) + uc4([Zoom / pan image]) + uc5([Copy image to clipboard]) + uc6([Load workflow from output]) + uc7([Interrupt / delete task]) + uc8([Clear all tasks and history]) + uc9([Toggle image fit]) + end + + user --- uc1 + user --- uc2 + user --- uc3 + user --- uc7 + user --- uc8 + user --- uc9 + uc3 -.-> uc4 + uc3 -.-> uc5 + uc3 -.-> uc6 +``` + +--- + +## 3. 功能需求 + +### FR-1 — 佇列與歷史顯示 +- 以卡片網格合併顯示 `pending`、`running` 與 `history` 任務(`queue-sidebar.js#render`)。 +- 排序:pending → running → history;卡片以 `promptId` 為 key 以利穩定的協調(reconciliation)。 +- 空狀態顯示「無任務」佔位。 +- 每張卡片顯示狀態標籤(running/pending/completed/failed/cancelled),已知時並顯示執行時間。 + +### FR-2 — 即時預覽 +- 取樣期間,從 `b_preview` WS 事件更新執行中的卡片(latent blob 預覽)。 +- 每個 `executed` WS 事件時,將執行中卡片切換為解碼後的 `/view` 輸出,並將所選輸出持久化到快取 + (`outputCache.saveOutputCache`)。 +- 已完成/歷史卡片透過 `firstOutput(outputs, promptId)` 解析縮圖——優先取用快取,未命中則 + 退回字典逐一走訪(向後相容)。 + +### FR-3 — 任務狀態生命週期 +- 狀態轉換遵循 [ARCHITECTURE.md](./ARCHITECTURE.md#5-state-machine--task-lifecycle) 的狀態機。 +- 失敗與取消狀態由 `/history` 的 `status_str` 導出。 + +### FR-4 — View mode(全螢幕檢視器) +點擊已完成的圖片/影片卡片會開啟全螢幕覆蓋層(`gallery.js`)。 + +- **FR-4.1 基準尺寸** —— 圖片以 `max-width:90vw; max-height:90vh; object-fit:contain` 適配視窗 + (此即「最小尺寸」基準;縮放由此往上放大)。 +- **FR-4.2 導覽** —— 上一張/下一張箭頭、`←`/`→` 鍵、計數器;以 `✕`、`Esc` 或點背景關閉。 +- **FR-4.3 滾輪縮放**(1.3.0 新增)—— 滾動縮放,步進 `±0.12`,範圍限制 `[1, 4]`,以圖片中心為錨點。 + 縮放至 1 時位移歸零。 +- **FR-4.4 拖曳平移**(新增)—— 放大時(`scale > 1`),按住即可拖曳平移(pointer capture); + 於 scale 1 時停用平移。 +- **FR-4.5 複製圖片**(新增)—— `Ctrl/⌘+C` 將當前圖片以 `ClipboardItem` 複製到剪貼簿。成功時顯示 + 確認 toast,失敗時顯示錯誤 toast;**不**退回複製網址。**僅限圖片**。 + + > **不會保留 metadata——這是瀏覽器平台限制,而非 bug。** 非同步 Clipboard API 在寫入時會重新編碼 + > 圖片(Chromium/WebKit 先解碼成點陣圖、再輸出全新的 PNG,以防禦解壓縮炸彈攻擊),因而丟棄 ComfyUI + > 內嵌的 workflow(`tEXt`/`iTXt` chunk);標準寫入路徑又只接受 `image/png`,故非 PNG 輸出會被直接 + > 拒絕。唯一能保留位元組的手段(web custom formats,`web image/png`)需要**接收端** app 主動 opt-in, + > 因此貼上時仍無法還原 workflow。所以 `Ctrl+C` 只是像素層級的「複製圖片」,並非 metadata 傳輸——要 + > 取回 workflow 請用 **Load-workflow 按鈕(FR-4.6)**,或從 `/view` 下載原始檔(原始位元組、chunk + > 完整;存檔不經過剪貼簿 sanitize)。 +- **FR-4.6 載入工作流程按鈕**(新增)—— 紅色 `✕` 左側的半透明灰底按鈕(白色 icon),當輸出帶有 workflow 時,退出 view + mode 並呼叫 `app.loadGraphData(workflow)`——等同於右鍵的「Load workflow」。無 workflow 時隱藏。 +- **FR-4.7 範圍** —— 縮放/平移/複製**僅適用圖片**;影片維持現有行為。 +- **FR-4.8 操作提示**(新增)—— 左上角不可互動的長條,列出檢視器控制方式(`←/→`、滾輪縮放、拖曳平移、 + `Ctrl+C`、`Esc`)。 + +### FR-5 — 右鍵選單 +右鍵點擊卡片(`contextMenu.js`): +- 執行中 → **Interrupt**(`POST /interrupt`)。 +- 等待中 → **Delete**(`POST /queue {delete}`)。 +- 已完成/失敗/取消 → **Delete**(`POST /history {delete}`),且當 `task.workflow` 存在時顯示 + **Load workflow**(`app.loadGraphData`)。 + +### FR-6 — 工具列 +- **圖片適配切換**(contain ↔ cover)重新渲染預覽(`toolbar.createFitButton`)。 +- **全部清除** 附確認彈窗,清空佇列 + 歷史(`toolbar.createClearButton`)。 + +### FR-7 — 國際化 +- 標籤自 `web/locales/.json` 載入,locale 讀取自 ComfyUI 的 `Comfy.Locale` 設定; + 英文為退回預設(`comfyAdapter.getComfyLocale`、`queue-sidebar.js#t`)。 + +### FR-8 — ComfyUI 整合 +- 註冊側邊欄 tab + 切換命令/快捷鍵(`Q`)。 +- 維護顯示 `running + pending` 數量的圖示 badge。 +- **Tab 排序:** 使用 ComfyUI 的預設註冊順序。*(1.3.0 變更——先前的 `sidebarTabs` 陣列 mutation + 已移除,以符合 registry 合規與升級安全。)* +- **佇列刷新:** 由 `status` WS 事件驅動。*(1.3.0 變更——先前的 `queuePrompt` monkey-patch + 已移除。)* + +--- + +## 4. 非功能需求 + +- **NFR-1 零建置/零相依** —— 以原生 ES modules 發布。 +- **NFR-2 優雅降級** —— 每個 ComfyUI 內部接觸點皆有功能偵測,失敗時記錄為「降級模式」而非拋錯 + (`comfyAdapter.js`)。 +- **NFR-3 Registry 合規** —— 盡量減少觸發 Comfy Registry 安全審查的模式;在有官方 API 或事件可用時 + 避免 mutate ComfyUI 內部。 +- **NFR-4 可測試性** —— 產生 DOM 的邏輯以 Vitest + jsdom 單元測試;每個 `lib/` 模組都有對應的 + `tests/*.test.js`。 +- **NFR-5 效能** —— 渲染採用 keyed reconciliation;執行中卡片的預覽就地更新,不整體重建。 +- **NFR-6 韌性** —— API 失敗顯示 toast 並回傳 `null`(`helpers.safeApi`);快取寫入在配額錯誤時 + 靜默失敗。 + +--- + +## 5. 資料契約 + +### 5.1 ComfyUI REST(透過 `api.fetchApi`) +| 端點 | 方法 | 用途 | +|---|---|---| +| `/queue` | GET | running + pending tuples → `normalizeQueue` | +| `/queue` | POST | `{delete:[id]}` / `{clear:true}` | +| `/history?max_items=N` | GET | history map → `normalizeHistoryItem` | +| `/history` | POST | `{delete:[id]}` / `{clear:true}` | +| `/view?filename&subfolder&type` | GET | 輸出圖片/影片網址 | +| `/interrupt` | POST | 停止執行中的任務 | + +### 5.2 WebSocket 事件(`api.addEventListener`) +| 事件 | 內容 | 效果 | +|---|---|---| +| `status` | 佇列計數 | 觸發 `refresh()` | +| `execution_start` | `{prompt_id}` | 立即將 pending→running | +| `b_preview` | 圖片 blob | 更新執行中卡片的 latent 預覽 | +| `executed` | `{prompt_id, output}` | 設定 `/view` 預覽 + `saveOutputCache` | + +### 5.3 localStorage +- Key `queueSidebar.lastOutput`:`{ [promptId]: { filename, subfolder, type } }`,上限 + `OUTPUT_CACHE_MAX = 200`(最舊者淘汰)。 + +### 5.4 工作流程擷取 +- `workflow = item.prompt[3].extra_pnginfo.workflow`(深層巢狀;有防護——可能為 `null`)。 + +--- + +## 6. 驗證 + +自動化(Vitest):每個 `lib/` 模組都有單元測試;新的 view-mode 互動新增 `tests/gallery.test.js`。 +端對端行為(Playwright)涵蓋載入、卡片排序、即時預覽、輸出快取、view mode、右鍵選單、工具列與 +國際化;驗收需要一個運行中的 ComfyUI 實例。 + +--- + +## 7. 術語表 + +| 術語 | 意義 | +|---|---| +| **Task / card(任務/卡片)** | 一個排入佇列的 prompt,以網格卡片顯示,由 `promptId` 識別。 | +| **View mode** | 全螢幕圖片/影片覆蓋層(`gallery.js`)。 | +| **Output cache(輸出快取)** | 每個 prompt 最後一個產生圖片之輸出的 `localStorage` 對應表。 | +| **Coupling point(耦合點)** | `comfyAdapter.js`——唯一接觸 ComfyUI 內部的模組。 | +| **Degraded mode(降級模式)** | 功能偵測發現 ComfyUI 內部缺失後的狀態;記錄且非致命。 | +| **`firstOutput`** | 決定顯示哪個輸出的解析器,快取優先。 | From 2fb32b96cb53c721f49ac60f8a833f59ba265421 Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:51:13 +0800 Subject: [PATCH 5/5] chore: bump version to 1.3.0 --- package.json | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 8ba24aa..47407d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "comfyui_queue_sidebar", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "ComfyUI sidebar extension — brings back the queue panel with image previews", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index 9da15bf..db832c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "comfyui-queue-sidebar" -version = "1.2.0" +version = "1.3.0" description = "ComfyUI sidebar extension — brings back the queue panel with image previews" license = { file = "LICENSE" }