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" }