Use scalable grid for accumulator preview

This commit is contained in:
2026-06-25 10:27:29 +02:00
parent 1340c32732
commit cb63e806c1
+400 -330
View File
@@ -3,9 +3,17 @@ import { api } from "../../scripts/api.js";
const EXTENSION = "ethanfel.prompt_builder.accumulator_preview"; const EXTENSION = "ethanfel.prompt_builder.accumulator_preview";
const NODE_NAME = "SxCPAccumulatorPreview"; const NODE_NAME = "SxCPAccumulatorPreview";
const ANIM_PREVIEW_WIDGET = "$$comfy_animation_preview"; const STYLE_ID = "sxcp-accumulator-preview-styles";
const ENTRY_ACTIONS = ["move up", "move down", "move top", "move bottom", "delete selected"];
const entryCache = new Map(); 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) { function widget(node, name) {
return node.widgets?.find((w) => w.name === name); return node.widgets?.find((w) => w.name === name);
@@ -13,22 +21,10 @@ function widget(node, name) {
function hideWidget(w) { function hideWidget(w) {
if (!w) return; if (!w) return;
if (w.origType === undefined) w.origType = w.type;
w.type = "hidden";
w.hidden = true; w.hidden = true;
w.computeSize = () => [0, -4]; 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) { function isAccumulatorPreviewNode(node) {
return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME; return node?.comfyClass === NODE_NAME || node?.type === NODE_NAME;
} }
@@ -68,47 +64,8 @@ function outputImages(output) {
return asArray(images).filter(Boolean); 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) { 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) { function previewLimit(node) {
@@ -120,11 +77,9 @@ function actionPayload(node, values = {}) {
return {preview_limit: previewLimit(node), ...values}; return {preview_limit: previewLimit(node), ...values};
} }
function removeAnimationPreviewWidget(node) { function imageEntries(node) {
const widgetIndex = node.widgets?.findIndex((w) => w.name === ANIM_PREVIEW_WIDGET) ?? -1; const entries = node._sxapEntries || [];
if (widgetIndex < 0) return; return entries.filter((entry) => entry?.has_image).slice(0, previewLimit(node));
node.widgets[widgetIndex].onRemove?.();
node.widgets.splice(widgetIndex, 1);
} }
function imageUrl(params) { function imageUrl(params) {
@@ -134,194 +89,17 @@ function imageUrl(params) {
return api.apiURL(`/view?${query}${preview}${rand}`); return api.apiURL(`/view?${query}${preview}${rand}`);
} }
function loadImage(src) { function entryLabel(entry) {
return new Promise((resolve) => { const parts = [`#${entry?.index ?? "?"}`];
const img = new Image(); if (entry?.id) parts.push(entry.id);
img.onload = () => resolve(img); if (Array.isArray(entry?.shape) && entry.shape.length >= 2) parts.push(`${entry.shape[1]}x${entry.shape[0]}`);
img.onerror = () => resolve(null); if (entry?.has_metadata) parts.push("metadata");
img.src = src; return parts.join(" ");
});
} }
function selectEntry(node, entry) { function entryTitle(entry) {
if (!node._sxcpEntrySelectWidget || !entry) return; const value = entry?.value ? `\n${entry.value}` : "";
const label = entryLabel(entry); return `${entryLabel(entry)}${value}`;
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 || "");
} }
async function postJson(path, payload) { async function postJson(path, payload) {
@@ -335,34 +113,284 @@ async function postJson(path, payload) {
return data; 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) { async function refreshEntries(node) {
const key = storeKey(node); const key = storeKey(node);
if (!key) { 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; return;
} }
try { try {
const data = await postJson("/sxcp/accumulator/list", actionPayload(node, {store_key: key})); const data = await postJson("/sxcp/accumulator/list", actionPayload(node, {store_key: key}));
applyActionResult(node, data, data.status || ""); applyData(node, data, data.status || "");
} catch (err) { } catch (err) {
console.error(`[${EXTENSION}] refresh failed`, err); console.error(`[${EXTENSION}] refresh failed`, err);
alert(`Refresh failed: ${err}`); alert(`Refresh failed: ${err}`);
} }
} }
async function deleteSelected(node) { async function deleteEntry(node, entry) {
const key = storeKey(node); const key = storeKey(node);
if (!key) { if (!key) {
alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first."); alert("Set the same explicit store_key on the Accumulator and Accumulator Preview first.");
return; return;
} }
const entry = selectedEntry(node); if (!entry) return;
if (!entry) {
alert("No accumulator entry selected.");
return;
}
const label = entryLabel(entry);
if (!confirm(`Delete accumulator entry ${label}?`)) return;
try { try {
const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, { const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {
store_key: key, store_key: key,
@@ -370,38 +398,13 @@ async function deleteSelected(node) {
index: entry.id ? 0 : entry.index, index: entry.id ? 0 : entry.index,
clear: false, clear: false,
})); }));
applyActionResult(node, data, `${data.status || ""}; deleted=${data.removed || 0}`); applyData(node, data, `${data.status || ""}; deleted=${data.removed || 0}`);
} catch (err) { } catch (err) {
console.error(`[${EXTENSION}] delete failed`, err); console.error(`[${EXTENSION}] delete failed`, err);
alert(`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) { async function moveEntryToIndex(node, entry, targetIndex) {
const key = storeKey(node); const key = storeKey(node);
if (!key) { if (!key) {
@@ -416,23 +419,13 @@ async function moveEntryToIndex(node, entry, targetIndex) {
index: entry.id ? 0 : entry.index, index: entry.id ? 0 : entry.index,
target_index: targetIndex, 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) { } catch (err) {
console.error(`[${EXTENSION}] drag move failed`, err); console.error(`[${EXTENSION}] drag move failed`, err);
alert(`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) { async function clearStore(node) {
const key = storeKey(node); const key = storeKey(node);
if (!key) { if (!key) {
@@ -442,58 +435,129 @@ async function clearStore(node) {
if (!confirm(`Clear all entries from accumulator "${key}"?`)) return; if (!confirm(`Clear all entries from accumulator "${key}"?`)) return;
try { try {
const data = await postJson("/sxcp/accumulator/delete", actionPayload(node, {store_key: key, clear: true})); 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) { } catch (err) {
console.error(`[${EXTENSION}] clear failed`, err); console.error(`[${EXTENSION}] clear failed`, err);
alert(`Clear failed: ${err}`); alert(`Clear failed: ${err}`);
} }
} }
function setupNode(node) { async function saveBatch(node) {
hideWidget(widget(node, "delete_action")); const saveWidget = widget(node, "save_batch");
hideWidget(widget(node, "delete_entry_id")); if (!saveWidget) {
hideWidget(widget(node, "delete_index")); 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 [ for (const legacyButton of [
"Move Selected Top", "Move Selected Top",
"Move Selected Up", "Move Selected Up",
"Move Selected Down", "Move Selected Down",
"Move Selected Bottom", "Move Selected Bottom",
"Delete Selected Entry", "Delete Selected Entry",
"Apply Entry Action",
"Clear Accumulator",
"Refresh Entry List",
]) { ]) {
hideWidget(widget(node, legacyButton)); hideWidget(widget(node, legacyButton));
} }
}
if (!node._sxcpEntrySelectWidget) { function setupNode(node) {
node._sxcpEntrySelectWidget = node.addWidget("combo", "selected_entry", "no entries", () => {}, {values: ["no entries"]}); injectStyles();
node._sxcpEntrySelectWidget.serialize = false; suppressBuiltinPreview(node);
} hideInternalWidgets(node);
if (!node._sxcpEntryStripWidget && typeof node.addDOMWidget === "function") {
node._sxcpEntryStripRoot = document.createElement("div"); if (!node._sxapGridEl && typeof node.addDOMWidget === "function") {
node._sxcpEntryStripWidget = node.addDOMWidget("entry_strip", "div", node._sxcpEntryStripRoot, { 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, serialize: false,
hideOnZoom: false, getMinHeight: () => node._sxapWidgetH || 140,
getMinHeight: () => node._sxcpEntryStripHeight || 36,
}); });
renderEntryStrip(node); renderGrid(node);
} }
if (!node._sxcpEntryActionWidget) {
node._sxcpEntryActionWidget = node.addWidget("combo", "entry_action", ENTRY_ACTIONS[0], () => {}, {values: ENTRY_ACTIONS}); const onResize = node.onResize;
node._sxcpEntryActionWidget.serialize = false; 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", () => {}); syncWidgetWidth(node);
node._sxcpAccumulatorStatusWidget.serialize = false; 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) { renderGrid(node);
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);
} }
app.registerExtension({ app.registerExtension({
@@ -504,11 +568,14 @@ app.registerExtension({
const node = getNodeById(detail?.display_node ?? detail?.node); const node = getNodeById(detail?.display_node ?? detail?.node);
if (!isAccumulatorPreviewNode(node)) return; if (!isAccumulatorPreviewNode(node)) return;
const output = detail?.output || {}; const output = detail?.output || {};
node._sxcpResolvedStoreKey = outputStoreKey(output); const key = outputStoreKey(output);
if (Object.prototype.hasOwnProperty.call(output, "images")) { if (key) node._sxapResolvedStoreKey = key;
applyPreviewImages(node, outputImages(output)); applyData(node, {
} store_key: key,
setEntries(node, outputEntries(output), outputStatus(output)); entries: outputEntries(output),
images: outputImages(output),
status: outputStatus(output),
}, outputStatus(output));
}); });
}, },
@@ -525,7 +592,10 @@ app.registerExtension({
const onConfigure = nodeType.prototype.onConfigure; const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () { nodeType.prototype.onConfigure = function () {
const result = onConfigure?.apply(this, arguments); const result = onConfigure?.apply(this, arguments);
queueMicrotask(() => setupNode(this)); queueMicrotask(() => {
setupNode(this);
refreshEntries(this);
});
return result; return result;
}; };
}, },