diff --git a/web/accumulator_preview.js b/web/accumulator_preview.js index 438a0e3..e957813 100644 --- a/web/accumulator_preview.js +++ b/web/accumulator_preview.js @@ -3,9 +3,17 @@ import { api } from "../../scripts/api.js"; const EXTENSION = "ethanfel.prompt_builder.accumulator_preview"; const NODE_NAME = "SxCPAccumulatorPreview"; -const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview"; -const ENTRY_ACTIONS = ["move up", "move down", "move top", "move bottom", "delete selected"]; -const entryCache = new Map(); +const STYLE_ID = "sxcp-accumulator-preview-styles"; + +const MIN_CELL_W = 180; +const GAP = 6; +const PAD = 4; +const TOOLBAR_H = 28; +const CAPTION_H = 20; +const EMPTY_GRID_H = 84; +const MAX_GRID_H = 640; +const MIN_W = 560; +const MARGIN = 10; function widget(node, name) { return node.widgets?.find((w) => w.name === name); @@ -13,22 +21,10 @@ function widget(node, name) { function hideWidget(w) { if (!w) return; - if (w.origType === undefined) w.origType = w.type; - w.type = "hidden"; w.hidden = true; w.computeSize = () => [0, -4]; } -function resizeNode(node) { - const size = node.computeSize?.(); - if (size) node.setSize?.(size); - app.graph?.setDirtyCanvas(true, true); -} - -function nodeKey(nodeOrId) { - return String(typeof nodeOrId === "object" ? nodeOrId?.id : nodeOrId); -} - function isAccumulatorPreviewNode(node) { return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME; } @@ -68,47 +64,8 @@ function outputImages(output) { return asArray(images).filter(Boolean); } -function entryLabel(entry) { - const index = entry?.index ?? "?"; - const id = entry?.id ? ` ${entry.id}` : ""; - const image = entry?.has_image ? " image" : " value"; - const shape = Array.isArray(entry?.shape) && entry.shape.length >= 2 ? ` ${entry.shape[1]}x${entry.shape[0]}` : ""; - const metadata = entry?.has_metadata ? " metadata" : ""; - return `#${index}${id}${image}${shape}${metadata}`.trim(); -} - -function setStatus(node, status) { - if (!node._sxcpAccumulatorStatusWidget) return; - node._sxcpAccumulatorStatusWidget.value = status || "no accumulator data"; - node.setDirtyCanvas?.(true, true); -} - -function setEntries(node, entries, status = "") { - entries = asArray(entries); - entryCache.set(nodeKey(node), entries); - node._sxcpAccumulatorEntries = entries; - if (node._sxcpEntrySelectWidget) { - const labels = entries.map(entryLabel); - node._sxcpEntrySelectWidget.options.values = labels.length ? labels : ["no entries"]; - if (!labels.includes(node._sxcpEntrySelectWidget.value)) { - node._sxcpEntrySelectWidget.value = labels[0] || "no entries"; - } - } - setStatus(node, status || `${entries.length} entries`); - renderEntryStrip(node); - resizeNode(node); -} - -function selectedEntry(node) { - const entries = entryCache.get(nodeKey(node)) || node._sxcpAccumulatorEntries || []; - const selected = widget(node, "selected_entry")?.value || node._sxcpEntrySelectWidget?.value || ""; - const labels = entries.map(entryLabel); - const index = labels.indexOf(selected); - return index >= 0 ? entries[index] : entries[0]; -} - function storeKey(node) { - return String(widget(node, "store_key")?.value || node._sxcpResolvedStoreKey || "").trim(); + return String(widget(node, "store_key")?.value || node._sxapResolvedStoreKey || "").trim(); } function previewLimit(node) { @@ -120,11 +77,9 @@ function actionPayload(node, values = {}) { return {preview_limit: previewLimit(node), ...values}; } -function removeAnimationPreviewWidget(node) { - const widgetIndex = node.widgets?.findIndex((w) => w.name === ANIM_PREVIEW_WIDGET) ?? -1; - if (widgetIndex < 0) return; - node.widgets[widgetIndex].onRemove?.(); - node.widgets.splice(widgetIndex, 1); +function imageEntries(node) { + const entries = node._sxapEntries || []; + return entries.filter((entry) => entry?.has_image).slice(0, previewLimit(node)); } function imageUrl(params) { @@ -134,194 +89,17 @@ function imageUrl(params) { return api.apiURL(`/view?${query}${preview}${rand}`); } -function loadImage(src) { - return new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => resolve(null); - img.src = src; - }); +function entryLabel(entry) { + const parts = [`#${entry?.index ?? "?"}`]; + if (entry?.id) parts.push(entry.id); + if (Array.isArray(entry?.shape) && entry.shape.length >= 2) parts.push(`${entry.shape[1]}x${entry.shape[0]}`); + if (entry?.has_metadata) parts.push("metadata"); + return parts.join(" "); } -function selectEntry(node, entry) { - if (!node._sxcpEntrySelectWidget || !entry) return; - const label = entryLabel(entry); - node._sxcpEntrySelectWidget.value = label; - node.setDirtyCanvas?.(true, true); -} - -function entryStripRows(node) { - const entries = entryCache.get(nodeKey(node)) || node._sxcpAccumulatorEntries || []; - return entries.filter((entry) => entry?.has_image).slice(0, previewLimit(node)); -} - -function clearDragHighlights(root) { - root?.querySelectorAll?.("[data-sxcp-drop]").forEach((row) => { - row.style.borderColor = "rgba(255,255,255,0.18)"; - row.style.background = "rgba(255,255,255,0.04)"; - delete row.dataset.sxcpDrop; - }); -} - -function dropTargetIndex(sourceEntry, targetEntry, event, targetElement) { - const sourceIndex = Number(sourceEntry?.index || 0); - const targetIndex = Number(targetEntry?.index || 0); - if (!sourceIndex || !targetIndex) return 0; - const rect = targetElement.getBoundingClientRect(); - const insertAfter = event.clientY > rect.top + rect.height / 2; - let finalIndex = targetIndex + (insertAfter ? 1 : 0); - if (sourceIndex < finalIndex) finalIndex -= 1; - return Math.max(1, finalIndex); -} - -function makeStripRow(node, entry, imageParams, rowNumber) { - const row = document.createElement("div"); - row.draggable = true; - row.dataset.index = String(entry.index || ""); - row.style.cssText = [ - "display:flex", - "align-items:center", - "gap:8px", - "min-height:52px", - "padding:4px", - "border:1px solid rgba(255,255,255,0.18)", - "border-radius:4px", - "background:rgba(255,255,255,0.04)", - "cursor:grab", - "box-sizing:border-box", - "user-select:none", - ].join(";"); - row.title = "Drag to reorder accumulator images"; - - const thumb = document.createElement("img"); - thumb.style.cssText = [ - "width:44px", - "height:44px", - "object-fit:cover", - "border-radius:3px", - "background:#111", - "flex:0 0 auto", - ].join(";"); - if (imageParams) thumb.src = imageUrl(imageParams); - - const label = document.createElement("div"); - label.textContent = `${rowNumber}. ${entryLabel(entry)}`; - label.style.cssText = [ - "overflow:hidden", - "text-overflow:ellipsis", - "white-space:nowrap", - "font:12px sans-serif", - "color:#ddd", - "flex:1 1 auto", - ].join(";"); - - row.append(thumb, label); - row.addEventListener("click", () => selectEntry(node, entry)); - row.addEventListener("dragstart", (event) => { - node._sxcpDraggedEntry = entry; - row.style.opacity = "0.55"; - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = "move"; - event.dataTransfer.setData("text/plain", String(entry.id || entry.index || "")); - } - }); - row.addEventListener("dragend", () => { - row.style.opacity = "1"; - clearDragHighlights(node._sxcpEntryStripRoot); - node._sxcpDraggedEntry = null; - }); - row.addEventListener("dragover", (event) => { - event.preventDefault(); - if (!node._sxcpDraggedEntry || node._sxcpDraggedEntry === entry) return; - clearDragHighlights(node._sxcpEntryStripRoot); - row.dataset.sxcpDrop = "1"; - row.style.borderColor = "#7aa2ff"; - row.style.background = "rgba(122,162,255,0.18)"; - }); - row.addEventListener("drop", async (event) => { - event.preventDefault(); - const sourceEntry = node._sxcpDraggedEntry; - clearDragHighlights(node._sxcpEntryStripRoot); - if (!sourceEntry || sourceEntry === entry) return; - const targetIndex = dropTargetIndex(sourceEntry, entry, event, row); - if (!targetIndex || targetIndex === Number(sourceEntry.index || 0)) return; - await moveEntryToIndex(node, sourceEntry, targetIndex); - }); - return row; -} - -function renderEntryStrip(node) { - const root = node._sxcpEntryStripRoot; - if (!root) return; - const entries = entryStripRows(node); - const images = node._sxcpPreviewImageParams || []; - root.replaceChildren(); - root.style.cssText = [ - "display:flex", - "flex-direction:column", - "gap:4px", - "max-height:240px", - "overflow:auto", - "padding:4px", - "border:1px solid rgba(255,255,255,0.12)", - "border-radius:4px", - "background:rgba(0,0,0,0.18)", - "box-sizing:border-box", - ].join(";"); - - if (!entries.length) { - const empty = document.createElement("div"); - empty.textContent = "no image entries"; - empty.style.cssText = "font:12px sans-serif;color:#aaa;padding:6px;"; - root.append(empty); - node._sxcpEntryStripHeight = 36; - return; - } - - for (const [index, entry] of entries.entries()) { - root.append(makeStripRow(node, entry, images[index], index + 1)); - } - node._sxcpEntryStripHeight = Math.min(250, 12 + entries.length * 58); -} - -function applyPreviewImages(node, images) { - const normalized = asArray(images).filter(Boolean); - node._sxcpPreviewImageParams = normalized; - const key = nodeKey(node); - const output = app.nodeOutputs?.[key] || {}; - if (app.nodeOutputs) { - app.nodeOutputs[key] = {...output, images: normalized}; - } - - removeAnimationPreviewWidget(node); - node.images = null; - node.imgs = null; - node.imageIndex = null; - node.overIndex = null; - node.pointerDown = null; - - if (!normalized.length) { - renderEntryStrip(node); - resizeNode(node); - return; - } - - Promise.all(normalized.map((params) => loadImage(imageUrl(params)))).then((loadedImages) => { - if (app.nodeOutputs?.[key]?.images !== normalized) return; - node.images = normalized; - node.imgs = loadedImages.filter(Boolean); - node.setSizeForImage?.(); - resizeNode(node); - }); - renderEntryStrip(node); - resizeNode(node); -} - -function applyActionResult(node, data, status) { - if (Object.prototype.hasOwnProperty.call(data || {}, "images")) { - applyPreviewImages(node, data.images || []); - } - setEntries(node, data.entries || [], status || data.status || ""); +function entryTitle(entry) { + const value = entry?.value ? `\n${entry.value}` : ""; + return `${entryLabel(entry)}${value}`; } async function postJson(path, payload) { @@ -335,34 +113,284 @@ async function postJson(path, payload) { return data; } +function injectStyles() { + if (document.getElementById(STYLE_ID)) return; + const css = ` + .sxap-wrap { display:flex; flex-direction:column; gap:4px; box-sizing:border-box; height:100%; min-height:0; } + .sxap-grid { display:flex; flex-wrap:wrap; gap:${GAP}px; align-content:flex-start; overflow-y:auto; + padding:${PAD}px; background:rgba(0,0,0,0.15); border-radius:4px; + flex:1 1 auto; min-height:0; box-sizing:border-box; } + .sxap-grid.sxap-dragover { outline:2px dashed #6cf; outline-offset:-2px; } + .sxap-empty { width:100%; padding:12px; text-align:center; font-size:12px; opacity:0.65; box-sizing:border-box; } + .sxap-cell { position:relative; width:var(--sxap-cell-w, 240px); border:2px solid transparent; + border-radius:4px; overflow:hidden; background:#222; box-sizing:border-box; + transition:border-color .1s, background .1s; } + .sxap-cell:hover { border-color:#555; } + .sxap-cell.sxap-selected { border-color:#6cf; } + .sxap-cell.sxap-drop { border-color:#fc6; border-style:dashed; } + .sxap-thumb { width:100%; object-fit:contain; display:block; cursor:grab; background:#111; } + .sxap-thumb:active { cursor:grabbing; } + .sxap-badge { position:absolute; top:2px; left:2px; font-size:10px; background:rgba(0,0,0,0.65); + color:#fff; padding:0 4px; border-radius:3px; pointer-events:none; } + .sxap-meta { position:absolute; top:3px; left:50%; transform:translateX(-50%); font-size:9px; + background:rgba(40,160,100,0.9); color:#fff; padding:0 3px; border-radius:3px; + pointer-events:none; } + .sxap-del { position:absolute; top:2px; right:2px; width:18px; height:18px; border:none; border-radius:3px; + background:rgba(180,30,30,0.88); color:#fff; font-size:11px; line-height:1; + cursor:pointer; padding:0; } + .sxap-caption { width:100%; height:${CAPTION_H}px; box-sizing:border-box; + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding:2px 4px; + background:rgba(0,0,0,0.62); color:#fff; font-size:10px; line-height:16px; } + .sxap-toolbar { display:flex; align-items:center; gap:6px; flex:0 0 auto; min-height:${TOOLBAR_H - 2}px; } + .sxap-toolbar button { font-size:11px; padding:2px 8px; cursor:pointer; } + .sxap-status { min-width:0; margin-left:auto; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; + font-size:11px; opacity:0.75; } + `; + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = css; + document.head.appendChild(style); +} + +function entryAspect(entry) { + const shape = Array.isArray(entry?.shape) ? entry.shape : []; + const height = Number(shape[0] || 0); + const width = Number(shape[1] || 0); + if (height > 0 && width > 0) return height / width; + return 1; +} + +function layoutMetrics(node) { + const width = Math.max(node.size?.[0] || MIN_W, MIN_W); + const inner = Math.max(MIN_CELL_W, width - 2 * MARGIN - 2 * PAD); + const entries = imageEntries(node); + const count = entries.length; + const perRow = count <= 1 ? 1 : Math.max(1, Math.floor((inner + GAP) / (MIN_CELL_W + GAP))); + const cellW = Math.max(MIN_CELL_W, Math.floor((inner - GAP * (perRow - 1)) / perRow)); + let gridH = EMPTY_GRID_H; + if (count > 0) { + gridH = 2 * PAD; + for (let start = 0; start < count; start += perRow) { + const row = entries.slice(start, start + perRow); + const rowH = Math.max(...row.map((entry) => Math.round(cellW * entryAspect(entry)) + CAPTION_H)); + gridH += rowH; + if (start + perRow < count) gridH += GAP; + } + } + return {cellW, gridH}; +} + +function recomputeSize(node) { + const {cellW, gridH} = layoutMetrics(node); + node._sxapCellW = cellW; + node._sxapWidgetH = 2 * MARGIN + TOOLBAR_H + 6 + Math.min(gridH, MAX_GRID_H); +} + +function syncWidgetWidth(node) { + if (node._sxapWidget) node._sxapWidget.width = node.size?.[0] || MIN_W; +} + +function resizeToContent(node) { + recomputeSize(node); + const targetH = node.computeSize?.()[1] || node._sxapWidgetH || 140; + const width = node.size?.[0] || MIN_W; + const currentH = node.size?.[1] || 0; + const previousAutoH = node._sxapAutoHeight || 0; + const userSizedTaller = previousAutoH > 0 && currentH > previousAutoH + 6 && currentH > targetH; + const nextH = userSizedTaller ? currentH : targetH; + node._sxapAutoHeight = nextH; + node.setSize?.([width, nextH]); + syncWidgetWidth(node); + node.setDirtyCanvas?.(true, true); +} + +function suppressBuiltinPreview(node) { + try { + Object.defineProperty(node, "imgs", { + configurable: true, + get() { return undefined; }, + set() {}, + }); + } catch (_err) { + // Best effort: if another extension made it non-configurable, the grid still works. + } +} + +function setWidgetValue(node, name, value) { + const w = widget(node, name); + if (!w) return; + w.value = value; + w.callback?.(value, app.canvas, node); +} + +function setStatus(node, status) { + node._sxapStatus = status || ""; + if (node._sxapStatusEl) node._sxapStatusEl.textContent = status || ""; + node.setDirtyCanvas?.(true, true); +} + +function clearDropState(node) { + node._sxapGridEl?.querySelectorAll?.(".sxap-drop").forEach((cell) => cell.classList.remove("sxap-drop")); + node._sxapGridEl?.classList.remove("sxap-dragover"); +} + +function markSelected(node, selectedIndex) { + node._sxapSelectedIndex = selectedIndex; + node._sxapGridEl?.querySelectorAll?.(".sxap-cell").forEach((cell) => { + cell.classList.toggle("sxap-selected", Number(cell.dataset.index || 0) === selectedIndex); + }); +} + +function renderCell(node, entry, imageParams, displayIndex) { + const cell = document.createElement("div"); + cell.className = "sxap-cell" + (entry.index === node._sxapSelectedIndex ? " sxap-selected" : ""); + cell.dataset.index = String(entry.index || ""); + cell.title = entryTitle(entry); + const cellW = node._sxapCellW || MIN_CELL_W; + cell.style.setProperty("--sxap-cell-w", `${cellW}px`); + + cell.ondragover = (event) => { + if (!node._sxapDragEntry) return; + event.preventDefault(); + event.stopPropagation(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; + cell.classList.add("sxap-drop"); + }; + cell.ondragleave = () => cell.classList.remove("sxap-drop"); + cell.ondrop = async (event) => { + if (!node._sxapDragEntry) return; + event.preventDefault(); + event.stopPropagation(); + cell.classList.remove("sxap-drop"); + const source = node._sxapDragEntry; + node._sxapDragEntry = null; + if (source.index === entry.index) return; + await moveEntryToIndex(node, source, entry.index); + }; + + const thumb = document.createElement("img"); + thumb.className = "sxap-thumb"; + thumb.style.height = `${Math.max(48, Math.round(cellW * entryAspect(entry)))}px`; + if (imageParams) thumb.src = imageUrl(imageParams); + thumb.draggable = true; + thumb.onclick = () => markSelected(node, entry.index); + thumb.ondragstart = (event) => { + node._sxapDragEntry = entry; + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(entry.id || entry.index || "")); + } + }; + thumb.ondragend = () => { + node._sxapDragEntry = null; + clearDropState(node); + }; + cell.appendChild(thumb); + + const badge = document.createElement("div"); + badge.className = "sxap-badge"; + badge.textContent = String(entry.index ?? displayIndex + 1); + cell.appendChild(badge); + + if (entry.has_metadata) { + const meta = document.createElement("div"); + meta.className = "sxap-meta"; + meta.textContent = "M"; + meta.title = "has metadata"; + cell.appendChild(meta); + } + + const del = document.createElement("button"); + del.className = "sxap-del"; + del.textContent = "x"; + del.title = "Delete"; + del.onclick = async (event) => { + event.stopPropagation(); + await deleteEntry(node, entry); + }; + cell.appendChild(del); + + const caption = document.createElement("div"); + caption.className = "sxap-caption"; + caption.textContent = entry.id || entry.value || entryLabel(entry); + cell.appendChild(caption); + + return cell; +} + +function renderGrid(node) { + const grid = node._sxapGridEl; + if (!grid) return; + const entries = imageEntries(node); + const images = node._sxapImages || []; + grid.replaceChildren(); + + if (!entries.length) { + const empty = document.createElement("div"); + empty.className = "sxap-empty"; + empty.textContent = storeKey(node) ? "No accumulator images." : "Run once or set an explicit store_key."; + grid.appendChild(empty); + } else { + entries.forEach((entry, index) => { + grid.appendChild(renderCell(node, entry, images[index], index)); + }); + } + + const total = (node._sxapEntries || []).filter((entry) => entry?.has_image).length; + const count = entries.length; + if (node._sxapStatusEl) { + const prefix = total > count ? `${count}/${total} shown` : `${total} image${total === 1 ? "" : "s"}`; + node._sxapStatusEl.textContent = node._sxapStatus ? `${prefix}; ${node._sxapStatus}` : prefix; + node._sxapStatusEl.title = node._sxapStatusEl.textContent; + } + + if (count !== node._sxapLastCount) { + node._sxapLastCount = count; + requestAnimationFrame(() => resizeToContent(node)); + } else { + recomputeSize(node); + syncWidgetWidth(node); + node.setDirtyCanvas?.(true, true); + } +} + +function applyData(node, data, status = "") { + if (Object.prototype.hasOwnProperty.call(data || {}, "store_key")) { + node._sxapResolvedStoreKey = data.store_key || ""; + } + if (Object.prototype.hasOwnProperty.call(data || {}, "entries")) { + node._sxapEntries = asArray(data.entries); + } + if (Object.prototype.hasOwnProperty.call(data || {}, "images")) { + node._sxapImages = asArray(data.images).filter(Boolean); + } + setStatus(node, status || data?.status || ""); + renderGrid(node); +} + async function refreshEntries(node) { const key = storeKey(node); if (!key) { - alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); + setStatus(node, "no store_key yet"); + renderGrid(node); return; } try { const data = await postJson("/sxcp/accumulator/list", actionPayload(node, {store_key: key})); - applyActionResult(node, data, data.status || ""); + applyData(node, data, data.status || ""); } catch (err) { console.error(`[${EXTENSION}] refresh failed`, err); alert(`Refresh failed: ${err}`); } } -async function deleteSelected(node) { +async function deleteEntry(node, entry) { const key = storeKey(node); if (!key) { alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); return; } - const entry = selectedEntry(node); - if (!entry) { - alert("No accumulator entry selected."); - return; - } - const label = entryLabel(entry); - if (!confirm(`Delete accumulator entry ${label}?`)) return; + if (!entry) return; try { const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, { store_key: key, @@ -370,38 +398,13 @@ async function deleteSelected(node) { index: entry.id ? 0 : entry.index, clear: false, })); - applyActionResult(node, data, `${data.status || ""}; deleted=${data.removed || 0}`); + applyData(node, data, `${data.status || ""}; deleted=${data.removed || 0}`); } catch (err) { console.error(`[${EXTENSION}] delete failed`, err); alert(`Delete failed: ${err}`); } } -async function moveSelected(node, direction) { - const key = storeKey(node); - if (!key) { - alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); - return; - } - const entry = selectedEntry(node); - if (!entry) { - alert("No accumulator entry selected."); - return; - } - try { - const data = await postJson("/sxcp/accumulator/move", actionPayload(node, { - store_key: key, - entry_id: entry.id || "", - index: entry.id ? 0 : entry.index, - direction, - })); - applyActionResult(node, data, `${data.status || ""}; moved=${data.moved ? "yes" : "no"}`); - } catch (err) { - console.error(`[${EXTENSION}] move failed`, err); - alert(`Move failed: ${err}`); - } -} - async function moveEntryToIndex(node, entry, targetIndex) { const key = storeKey(node); if (!key) { @@ -416,23 +419,13 @@ async function moveEntryToIndex(node, entry, targetIndex) { index: entry.id ? 0 : entry.index, target_index: targetIndex, })); - applyActionResult(node, data, `${data.status || ""}; moved=${data.moved ? "yes" : "no"}`); + applyData(node, data, `${data.status || ""}; moved=${data.moved ? "yes" : "no"}`); } catch (err) { console.error(`[${EXTENSION}] drag move failed`, err); alert(`Move failed: ${err}`); } } -async function applyEntryAction(node) { - const action = widget(node, "entry_action")?.value || node._sxcpEntryActionWidget?.value || "move up"; - if (action === "delete selected") { - await deleteSelected(node); - return; - } - const direction = action.replace(/^move\s+/, ""); - await moveSelected(node, direction); -} - async function clearStore(node) { const key = storeKey(node); if (!key) { @@ -442,58 +435,129 @@ async function clearStore(node) { if (!confirm(`Clear all entries from accumulator "${key}"?`)) return; try { const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {store_key: key, clear: true})); - applyActionResult(node, data, `${data.status || ""}; cleared=${data.removed || 0}`); + applyData(node, data, `${data.status || ""}; cleared=${data.removed || 0}`); } catch (err) { console.error(`[${EXTENSION}] clear failed`, err); alert(`Clear failed: ${err}`); } } -function setupNode(node) { - hideWidget(widget(node, "delete_action")); - hideWidget(widget(node, "delete_entry_id")); - hideWidget(widget(node, "delete_index")); +async function saveBatch(node) { + const saveWidget = widget(node, "save_batch"); + if (!saveWidget) { + alert("Missing save_batch widget."); + return; + } + setWidgetValue(node, "finished", true); + setWidgetValue(node, "save_batch", true); + node.setDirtyCanvas?.(true, true); + try { + try { + await app.queuePrompt(0, 1); + } catch (_err) { + await app.queuePrompt(0); + } + } catch (err) { + console.error(`[${EXTENSION}] save queue failed`, err); + alert(`Save failed: ${err}`); + } finally { + setWidgetValue(node, "save_batch", false); + node.setDirtyCanvas?.(true, true); + } +} + +function hideInternalWidgets(node) { + for (const name of [ + "delete_action", + "delete_entry_id", + "delete_index", + "save_batch", + "finished", + "selected_entry", + "entry_action", + "accumulator_status", + ]) { + hideWidget(widget(node, name)); + } for (const legacyButton of [ "Move Selected Top", "Move Selected Up", "Move Selected Down", "Move Selected Bottom", "Delete Selected Entry", + "Apply Entry Action", + "Clear Accumulator", + "Refresh Entry List", ]) { hideWidget(widget(node, legacyButton)); } +} - if (!node._sxcpEntrySelectWidget) { - node._sxcpEntrySelectWidget = node.addWidget("combo", "selected_entry", "no entries", () => {}, {values: ["no entries"]}); - node._sxcpEntrySelectWidget.serialize = false; - } - if (!node._sxcpEntryStripWidget && typeof node.addDOMWidget === "function") { - node._sxcpEntryStripRoot = document.createElement("div"); - node._sxcpEntryStripWidget = node.addDOMWidget("entry_strip", "div", node._sxcpEntryStripRoot, { +function setupNode(node) { + injectStyles(); + suppressBuiltinPreview(node); + hideInternalWidgets(node); + + if (!node._sxapGridEl && typeof node.addDOMWidget === "function") { + const wrap = document.createElement("div"); + wrap.className = "sxap-wrap"; + + const grid = document.createElement("div"); + grid.className = "sxap-grid"; + wrap.appendChild(grid); + + const toolbar = document.createElement("div"); + toolbar.className = "sxap-toolbar"; + + const save = document.createElement("button"); + save.textContent = "Save"; + save.title = "Save current accumulator batch once"; + save.onclick = () => saveBatch(node); + + const refresh = document.createElement("button"); + refresh.textContent = "Refresh"; + refresh.onclick = () => refreshEntries(node); + + const clear = document.createElement("button"); + clear.textContent = "Clear"; + clear.onclick = () => clearStore(node); + + const status = document.createElement("span"); + status.className = "sxap-status"; + + toolbar.append(save, refresh, clear, status); + wrap.appendChild(toolbar); + + node._sxapGridEl = grid; + node._sxapStatusEl = status; + node._sxapEntries = node._sxapEntries || []; + node._sxapImages = node._sxapImages || []; + node._sxapLastCount = -1; + recomputeSize(node); + node._sxapWidget = node.addDOMWidget("accumulator_grid", "div", wrap, { serialize: false, - hideOnZoom: false, - getMinHeight: () => node._sxcpEntryStripHeight || 36, + getMinHeight: () => node._sxapWidgetH || 140, }); - renderEntryStrip(node); + renderGrid(node); } - if (!node._sxcpEntryActionWidget) { - node._sxcpEntryActionWidget = node.addWidget("combo", "entry_action", ENTRY_ACTIONS[0], () => {}, {values: ENTRY_ACTIONS}); - node._sxcpEntryActionWidget.serialize = false; + + const onResize = node.onResize; + if (!node._sxapResizeWrapped) { + node.onResize = function () { + const result = onResize?.apply(this, arguments); + recomputeSize(node); + syncWidgetWidth(node); + return result; + }; + node._sxapResizeWrapped = true; } - if (!node._sxcpAccumulatorStatusWidget) { - node._sxcpAccumulatorStatusWidget = node.addWidget("text", "accumulator_status", "no accumulator data", () => {}); - node._sxcpAccumulatorStatusWidget.serialize = false; + + syncWidgetWidth(node); + if (!node._sxapInitialSizeSet) { + node._sxapInitialSizeSet = true; + node.setSize?.([Math.max(node.size?.[0] || 0, MIN_W), node.computeSize?.()[1] || node.size?.[1] || 180]); } - if (!node._sxcpApplyEntryActionButton) { - node._sxcpApplyEntryActionButton = node.addWidget("button", "Apply Entry Action", null, () => applyEntryAction(node)); - } - if (!node._sxcpClearButton) { - node._sxcpClearButton = node.addWidget("button", "Clear Accumulator", null, () => clearStore(node)); - } - if (!node._sxcpRefreshButton) { - node._sxcpRefreshButton = node.addWidget("button", "Refresh Entry List", null, () => refreshEntries(node)); - } - resizeNode(node); + renderGrid(node); } app.registerExtension({ @@ -504,11 +568,14 @@ app.registerExtension({ const node = getNodeById(detail?.display_node ?? detail?.node); if (!isAccumulatorPreviewNode(node)) return; const output = detail?.output || {}; - node._sxcpResolvedStoreKey = outputStoreKey(output); - if (Object.prototype.hasOwnProperty.call(output, "images")) { - applyPreviewImages(node, outputImages(output)); - } - setEntries(node, outputEntries(output), outputStatus(output)); + const key = outputStoreKey(output); + if (key) node._sxapResolvedStoreKey = key; + applyData(node, { + store_key: key, + entries: outputEntries(output), + images: outputImages(output), + status: outputStatus(output), + }, outputStatus(output)); }); }, @@ -525,7 +592,10 @@ app.registerExtension({ const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = onConfigure?.apply(this, arguments); - queueMicrotask(() => setupNode(this)); + queueMicrotask(() => { + setupNode(this); + refreshEntries(this); + }); return result; }; },