import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; const EXTENSION = "ethanfel.prompt_builder.accumulator_preview"; const NODE_NAME = "SxCPAccumulatorPreview"; const STYLE_ID = "sxcp-accumulator-preview-styles"; const DEBUG_STORAGE_KEY = "sxcpAccumulatorPreviewDebug"; const BASE_CELL_W = 170; const MIN_CELL_W = 90; const MAX_CELL_W = 520; const BASE_GRID_H = 360; const MIN_GRID_H = 240; const MAX_GRID_H = 900; const GAP = 6; const PAD = 4; const TOOLBAR_H = 28; const CAPTION_H = 20; const EMPTY_GRID_H = 84; const MIN_W = 560; const MARGIN = 10; function widget(node, name) { return node.widgets?.find((w) => w.name === name); } function hideWidget(w) { if (!w) return; w.hidden = true; w.computeSize = () => [0, -4]; } function isAccumulatorPreviewNode(node) { return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME; } function getNodeById(id) { return app.graph?.getNodeById?.(Number(id)) || app.graph?._nodes_by_id?.[id] || app.graph?._nodes_by_id?.[Number(id)]; } function debugEnabled() { try { return window.SXCP_ACCUMULATOR_PREVIEW_DEBUG === true || localStorage.getItem(DEBUG_STORAGE_KEY) === "1"; } catch (_err) { return window.SXCP_ACCUMULATOR_PREVIEW_DEBUG === true; } } function debugLog(...args) { if (debugEnabled()) console.log(`[${EXTENSION}]`, ...args); } function debugWarn(...args) { console.warn(`[${EXTENSION}]`, ...args); } function nodeSummaries() { return (app.graph?._nodes || []) .filter(isAccumulatorPreviewNode) .map((node) => ({ id: node.id, store_key: storeKey(node), widget_store_key: widgetStoreKey(node), resolved_store_key: node._sxapResolvedStoreKey || "", entries: (node._sxapEntries || []).map((entry, index) => ({ index: entry.index, id: entry.id, preview_key: entry.preview_key, has_image: entry.has_image, has_preview_image: !!entry.preview_image, image_ref: imageParamsForEntry(node, entry, index), })), images: node._sxapImages || [], status: node._sxapStatus || "", })); } function installDebugHelpers() { if (window.sxcpAccumulatorPreviewDebug) return; window.sxcpAccumulatorPreviewDebug = { enable() { localStorage.setItem(DEBUG_STORAGE_KEY, "1"); window.SXCP_ACCUMULATOR_PREVIEW_DEBUG = true; console.log(`[${EXTENSION}] debug enabled`); }, disable() { localStorage.removeItem(DEBUG_STORAGE_KEY); window.SXCP_ACCUMULATOR_PREVIEW_DEBUG = false; console.log(`[${EXTENSION}] debug disabled`); }, dump() { const data = nodeSummaries(); console.log(`[${EXTENSION}] dump`, data); return data; }, }; } function asArray(value) { if (!value) return []; return Array.isArray(value) ? value : [value]; } function outputStatus(output) { const status = output?.status; if (Array.isArray(status)) return status[0] || ""; return status || ""; } function outputStoreKey(output) { const key = output?.store_key; if (Array.isArray(key)) return key[0] || ""; return key || ""; } function outputEntries(output) { const entries = output?.entries; if (!entries) return []; if (Array.isArray(entries) && entries.length === 1 && Array.isArray(entries[0])) return entries[0]; return asArray(entries); } function outputImages(output) { const images = output?.images; if (!images) return []; if (Array.isArray(images) && images.length === 1 && Array.isArray(images[0])) return images[0]; return asArray(images).filter(Boolean); } function storeKey(node) { return String(node._sxapResolvedStoreKey || widget(node, "store_key")?.value || "").trim(); } function widgetStoreKey(node) { return String(widget(node, "store_key")?.value || "").trim(); } function previewLimit(node) { const value = Number(widget(node, "preview_limit")?.value ?? 64); return Number.isFinite(value) ? Math.max(1, Math.floor(value)) : 64; } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function viewMode(node) { return widget(node, "view_mode")?.value === "carousel" ? "carousel" : "grid"; } function zoomLevel(node) { const value = Number(widget(node, "zoom_level")?.value ?? 1); return Number.isFinite(value) ? clamp(value, 0.5, 3.0) : 1; } function carouselPosition(node, count) { if (count <= 0) return 1; const value = Number(widget(node, "carousel_index")?.value ?? node._sxapCarouselIndex ?? 1); return clamp(Number.isFinite(value) ? Math.round(value) : 1, 1, count); } function setCarouselPosition(node, position) { const count = imageEntries(node).length; const next = count > 0 ? clamp(Math.round(Number(position) || 1), 1, count) : 1; node._sxapCarouselIndex = next; setWidgetValue(node, "carousel_index", next); renderGrid(node); } function actionPayload(node, values = {}) { return {preview_limit: previewLimit(node), ...values}; } function imageEntries(node) { const entries = node._sxapEntries || []; return entries.filter((entry) => entry?.has_image).slice(0, previewLimit(node)); } function entryKey(entry) { const key = String(entry?.preview_key || "").trim(); if (key) return `key:${key}`; const id = String(entry?.id || "").trim(); if (id) return `id:${id}`; return `index:${entry?.index ?? ""}`; } function buildImageMap(entries, images, previous = new Map()) { const next = new Map(previous); const imageEntries = asArray(entries).filter((entry) => entry?.has_image); imageEntries.forEach((entry) => { if (entry.preview_image) next.set(entryKey(entry), entry.preview_image); }); asArray(images).filter(Boolean).forEach((image, index) => { const directKey = String(image?.preview_key || "").trim(); if (directKey) { next.set(`key:${directKey}`, image); return; } const entryId = String(image?.entry_id || "").trim(); if (entryId) { next.set(`id:${entryId}`, image); return; } const entry = imageEntries[index]; if (entry) next.set(entryKey(entry), image); }); return next; } function imageParamsForEntry(node, entry, fallbackIndex) { if (entry?.preview_image) return entry.preview_image; const keyed = node._sxapImageByKey?.get(entryKey(entry)); if (keyed) return keyed; return (node._sxapImages || [])[fallbackIndex]; } function imageUrl(params) { const query = new URLSearchParams(params).toString(); const preview = app.getPreviewFormatParam?.() || ""; const rand = app.getRandParam?.() || `&rand=${Math.random()}`; return api.apiURL(`/view?${query}${preview}${rand}`); } 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 entryTitle(entry) { const value = entry?.value ? `\n${entry.value}` : ""; return `${entryLabel(entry)}${value}`; } async function postJson(path, payload) { debugLog("POST", path, payload); const response = await api.fetchApi(path, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(payload), }); const data = await response.json(); debugLog("POST response", path, data); if (!response.ok) throw new Error(data?.error || response.statusText); 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-carousel { flex-wrap:nowrap; align-items:center; justify-content:center; overflow:hidden; } .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, 170px); 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-cell.sxap-carousel-cell { width:100%; height:100%; display:flex; flex-direction:column; } .sxap-thumb { width:100%; height:var(--sxap-thumb-h, 170px); object-fit:contain; display:block; cursor:grab; background:#111; } .sxap-carousel-cell .sxap-thumb { flex:1 1 auto; min-height:0; } .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-toolbar button:disabled { opacity:0.45; cursor:default; } .sxap-carousel-label { font-size:11px; opacity:0.75; min-width:34px; text-align:center; } .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 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 zoom = zoomLevel(node); if (viewMode(node) === "carousel") { const gridH = clamp(Math.round(BASE_GRID_H * zoom), MIN_GRID_H, MAX_GRID_H); return {cellW: inner, thumbH: Math.max(120, gridH - CAPTION_H - 2 * PAD), gridH}; } const desiredCellW = clamp(Math.round(BASE_CELL_W * zoom), MIN_CELL_W, MAX_CELL_W); const cellW = Math.min(desiredCellW, inner); const thumbH = Math.round(cellW * 1.1); const gridH = clamp(Math.round(BASE_GRID_H * Math.max(0.75, Math.min(zoom, 1.75))), MIN_GRID_H, MAX_GRID_H); return {cellW, thumbH, gridH}; } function recomputeSize(node) { const {cellW, thumbH, gridH} = layoutMetrics(node); node._sxapCellW = cellW; node._sxapThumbH = thumbH; node._sxapWidgetH = 2 * MARGIN + TOOLBAR_H + 6 + Math.max(gridH, EMPTY_GRID_H); } function syncWidgetWidth(node) { if (node._sxapWidget) node._sxapWidget.width = node.size?.[0] || MIN_W; } function resizeToContent(node, force = false) { recomputeSize(node); const targetH = node.computeSize?.()[1] || node._sxapWidgetH || 140; const width = node.size?.[0] || MIN_W; const currentH = node.size?.[1] || 0; const nextH = force || currentH <= 0 ? targetH : Math.max(currentH, targetH); node._sxapAutoHeight = nextH; if (Math.abs(currentH - nextH) > 1) 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, options = {}) { const cell = document.createElement("div"); cell.className = "sxap-cell" + (options.carousel ? " sxap-carousel-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.style.setProperty("--sxap-thumb-h", `${node._sxapThumbH || Math.round(cellW * 1.1)}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; debugLog("drop", { source_index: source.index, source_key: entryKey(source), target_index: entry.index, target_key: entryKey(entry), }); await moveEntryToIndex(node, source, entry.index); }; const thumb = document.createElement("img"); thumb.className = "sxap-thumb"; if (imageParams) { const url = imageUrl(imageParams); thumb.src = url; thumb.onerror = () => debugWarn("image load failed", { entry: {index: entry.index, id: entry.id, preview_key: entry.preview_key}, imageParams, url, }); } else { debugWarn("missing image params for entry", { entry: {index: entry.index, id: entry.id, preview_key: entry.preview_key}, displayIndex, }); } thumb.draggable = true; thumb.onclick = () => markSelected(node, entry.index); thumb.ondragstart = (event) => { node._sxapDragEntry = entry; debugLog("dragstart", {index: entry.index, key: entryKey(entry), id: entry.id}); 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 updateCarouselControls(node, count) { const carousel = viewMode(node) === "carousel"; const position = carouselPosition(node, count); if (node._sxapPrevButton) { node._sxapPrevButton.style.display = carousel ? "" : "none"; node._sxapPrevButton.disabled = !carousel || count <= 1; } if (node._sxapNextButton) { node._sxapNextButton.style.display = carousel ? "" : "none"; node._sxapNextButton.disabled = !carousel || count <= 1; } if (node._sxapCarouselLabel) { node._sxapCarouselLabel.style.display = carousel ? "" : "none"; node._sxapCarouselLabel.textContent = count ? `${position}/${count}` : "0/0"; } } function renderGrid(node) { const grid = node._sxapGridEl; if (!grid) return; const entries = imageEntries(node); const carousel = viewMode(node) === "carousel"; grid.replaceChildren(); grid.classList.toggle("sxap-carousel", carousel); 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 if (carousel) { const position = carouselPosition(node, entries.length); const entry = entries[position - 1]; if (Number(widget(node, "carousel_index")?.value || 1) !== position) { setWidgetValue(node, "carousel_index", position); } markSelected(node, entry.index); grid.appendChild(renderCell(node, entry, imageParamsForEntry(node, entry, position - 1), position - 1, {carousel: true})); } else { debugLog("renderGrid", entries.map((entry, index) => ({ index: entry.index, key: entryKey(entry), has_preview_image: !!entry.preview_image, has_image_params: !!imageParamsForEntry(node, entry, index), }))); entries.forEach((entry, index) => { grid.appendChild(renderCell(node, entry, imageParamsForEntry(node, entry, index), index)); }); } const total = (node._sxapEntries || []).filter((entry) => entry?.has_image).length; const count = entries.length; updateCarouselControls(node, count); if (node._sxapStatusEl) { const prefix = carousel && count ? `image ${carouselPosition(node, count)}/${count}` : 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); } node._sxapImageByKey = buildImageMap(node._sxapEntries, node._sxapImages, node._sxapImageByKey); setStatus(node, status || data?.status || ""); renderGrid(node); } async function refreshEntries(node) { const key = storeKey(node); if (!key) { setStatus(node, "no store_key yet"); renderGrid(node); return; } try { const data = await postJson("/sxcp/accumulator/list", actionPayload(node, {store_key: key})); applyData(node, data, data.status || ""); } catch (err) { console.error(`[${EXTENSION}] refresh failed`, err); alert(`Refresh failed: ${err}`); } } 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; } if (!entry) return; try { const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, { store_key: key, preview_key: entry.preview_key || "", entry_id: entry.id || "", index: entry.preview_key || entry.id ? 0 : entry.index, clear: false, })); applyData(node, data, `${data.status || ""}; deleted=${data.removed || 0}`); } catch (err) { console.error(`[${EXTENSION}] delete failed`, err); alert(`Delete failed: ${err}`); } } async function moveEntryToIndex(node, entry, targetIndex) { const key = storeKey(node); if (!key) { alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); return; } if (!entry) return; try { debugLog("move request", { entry: {index: entry.index, id: entry.id, preview_key: entry.preview_key}, targetIndex, }); const data = await postJson("/sxcp/accumulator/move", actionPayload(node, { store_key: key, preview_key: entry.preview_key || "", entry_id: entry.id || "", index: entry.preview_key || entry.id ? 0 : entry.index, target_index: targetIndex, })); debugLog("move response summary", { moved: data.moved, from_index: data.from_index, to_index: data.to_index, entries: asArray(data.entries).map((item) => ({ index: item.index, id: item.id, preview_key: item.preview_key, has_preview_image: !!item.preview_image, })), images: asArray(data.images).map((item) => ({ filename: item.filename, preview_key: item.preview_key, entry_id: item.entry_id, })), }); 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 clearStore(node) { const key = storeKey(node); if (!key) { alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); return; } if (!confirm(`Clear all entries from accumulator "${key}"?`)) return; try { const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {store_key: key, clear: true})); applyData(node, data, `${data.status || ""}; cleared=${data.removed || 0}`); } catch (err) { console.error(`[${EXTENSION}] clear failed`, err); alert(`Clear failed: ${err}`); } } async function saveBatch(node) { const key = storeKey(node); if (!key) { alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); return; } try { const data = await postJson("/sxcp/accumulator/save", actionPayload(node, { store_key: key, save_path: widget(node, "save_path")?.value || "sxcp_accumulator", filename_prefix: widget(node, "filename_prefix")?.value || "sxcp_accum", clear_after_save: !!widget(node, "clear_after_save")?.value, })); applyData(node, data, data.status || `saved=${data.saved || 0}`); } catch (err) { console.error(`[${EXTENSION}] direct save failed`, err); alert(`Save failed: ${err}`); } } 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)); } } function refreshLayout(node) { recomputeSize(node); renderGrid(node); resizeToContent(node, true); } function wrapWidgetCallback(node, name) { const w = widget(node, name); if (!w || w._sxapWrapped) return; const original = w.callback; w.callback = function () { const result = original?.apply(this, arguments); requestAnimationFrame(() => refreshLayout(node)); return result; }; w._sxapWrapped = true; } function installWidgetRefreshHandlers(node) { wrapWidgetCallback(node, "view_mode"); wrapWidgetCallback(node, "zoom_level"); wrapWidgetCallback(node, "carousel_index"); } function setupNode(node) { injectStyles(); suppressBuiltinPreview(node); hideInternalWidgets(node); installWidgetRefreshHandlers(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 prev = document.createElement("button"); prev.textContent = "Prev"; prev.title = "Previous carousel image"; prev.onclick = () => { const count = imageEntries(node).length; setCarouselPosition(node, carouselPosition(node, count) - 1); }; const next = document.createElement("button"); next.textContent = "Next"; next.title = "Next carousel image"; next.onclick = () => { const count = imageEntries(node).length; setCarouselPosition(node, carouselPosition(node, count) + 1); }; const carouselLabel = document.createElement("span"); carouselLabel.className = "sxap-carousel-label"; const status = document.createElement("span"); status.className = "sxap-status"; toolbar.append(save, refresh, clear, prev, next, carouselLabel, status); wrap.appendChild(toolbar); node._sxapGridEl = grid; node._sxapStatusEl = status; node._sxapPrevButton = prev; node._sxapNextButton = next; node._sxapCarouselLabel = carouselLabel; node._sxapEntries = node._sxapEntries || []; node._sxapImages = node._sxapImages || []; node._sxapImageByKey = node._sxapImageByKey || new Map(); node._sxapLastCount = -1; recomputeSize(node); node._sxapWidget = node.addDOMWidget("accumulator_grid", "div", wrap, { serialize: false, getMinHeight: () => node._sxapWidgetH || 140, }); renderGrid(node); } 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; } 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]); } renderGrid(node); } app.registerExtension({ name: EXTENSION, async setup() { installDebugHelpers(); api.addEventListener("executed", ({detail}) => { const node = getNodeById(detail?.display_node ?? detail?.node); if (!isAccumulatorPreviewNode(node)) return; const output = detail?.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)); }); }, async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== NODE_NAME) return; const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = onNodeCreated?.apply(this, arguments); setupNode(this); return result; }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function () { const result = onConfigure?.apply(this, arguments); queueMicrotask(() => { setupNode(this); refreshEntries(this); }); return result; }; }, });