From 03041f8bbdac2e5d442dd3f97a8385b813409d81 Mon Sep 17 00:00:00 2001 From: Satrio Dewantono Date: Wed, 21 Oct 2026 11:50:58 -0700 Subject: [PATCH] Fix image list serialization on frontend 1.53+ Frontend 1.53+ adopts custom widgets placed in node.widgets into a store-backed LegacyWidget. The custom ImageListWidget's value then reads from the store, so uploaded images were never serialized ("images": "[]") and the instanceof checks for drag/drop and paste stopped matching. Keep the original "images" STRING widget as the stored value (hidden, same position so old workflows load) and add a display-only "images_ui" widget that reads and writes through it. Use an own-property marker instead of instanceof. Written against frontend 1.52.7 and 1.55.18 sources; not yet browser-tested. Co-Authored-By: Claude Opus 5.5 --- js/reference_chain.js | 159 ++++++++++++++++++------------------------ 1 file changed, 68 insertions(+), 91 deletions(-) diff --git a/js/reference_chain.js b/js/reference_chain.js index 3485082..e60d09b 100644 --- a/js/reference_chain.js +++ b/js/reference_chain.js @@ -11,18 +11,22 @@ const SLOT_PADDING = 8; const REMOVE_BUTTON_SIZE = 16; /** - * Custom widget for managing a list of images with previews + * Display-only widget for managing a list of images with previews. + * The list itself lives in the node's hidden "images" STRING widget (valueWidget), + * which is the widget that gets serialized into workflows and prompts. * Styled to match ComfyUI's LoadImage node */ class ImageListWidget { - constructor(node, inputName, inputData) { + constructor(node, valueWidget) { this.node = node; - this.name = inputName; + this.name = "images_ui"; this.type = "custom"; - this.options = { serialize: true }; + this.serialize = false; + this.options = { serialize: false }; + this.valueWidget = valueWidget; - // Array of image filenames - this._images = []; + // Own-property marker: newer frontends re-parent custom widgets, so instanceof can't be used + this.isReferenceChainList = true; // Cache for loaded image elements with dimensions this.imageCache = new Map(); @@ -38,57 +42,29 @@ class ImageListWidget { // Hover/drag state for drop zone this.dropZoneActive = false; - - // Initialize from default value - try { - const defaultVal = inputData?.[1]?.default || "[]"; - this._images = JSON.parse(defaultVal); - if (!Array.isArray(this._images)) { - this._images = []; - } - } catch (e) { - this._images = []; - } - } - - get value() { - return JSON.stringify(this._images); - } - - set value(v) { - try { - if (typeof v === "string") { - this._images = JSON.parse(v); - } else if (Array.isArray(v)) { - this._images = v; - } else { - this._images = []; - } - } catch (e) { - this._images = []; - } - if (!Array.isArray(this._images)) { - this._images = []; - } - - // Preload images - this.preloadImages(); - - // Request redraw - if (this.node) { - this.node.setDirtyCanvas(true, true); - } } get images() { - return this._images; + let images; + try { + images = JSON.parse(this.valueWidget.value || "[]"); + } catch (e) { + images = []; + } + return Array.isArray(images) ? images.filter((img) => img && typeof img === "string") : []; + } + + setImages(images) { + this.valueWidget.value = JSON.stringify(images); + this.preloadImages(); + this.updateNodeSize(); } /** * Preload images into cache */ preloadImages() { - for (const filename of this._images) { + for (const filename of this.images) { if (!this.imageCache.has(filename)) { const img = new Image(); img.src = `/view?filename=${encodeURIComponent(filename)}&type=input`; @@ -120,7 +96,7 @@ class ImageListWidget { * Calculate widget height based on number of images */ computeSize() { - const numImages = this._images.length; + const numImages = this.images.length; const height = (numImages * this.getSlotHeight()) + DROP_ZONE_HEIGHT + SLOT_PADDING + BUTTON_HEIGHT + SLOT_PADDING * 2; return [this.node?.size?.[0] || 200, height]; } @@ -129,11 +105,10 @@ class ImageListWidget { * Add an image to the list */ addImage(filename) { - if (filename && !this._images.includes(filename)) { - this._images.push(filename); - this.preloadImages(); - this.updateNodeSize(); - this.node?.setDirtyCanvas(true, true); + const images = this.images; + if (filename && !images.includes(filename)) { + images.push(filename); + this.setImages(images); } } @@ -141,12 +116,11 @@ class ImageListWidget { * Remove an image from the list */ removeImage(index) { - if (index >= 0 && index < this._images.length) { - const filename = this._images[index]; - this._images.splice(index, 1); + const images = this.images; + if (index >= 0 && index < images.length) { + const [filename] = images.splice(index, 1); this.imageCache.delete(filename); - this.updateNodeSize(); - this.node?.setDirtyCanvas(true, true); + this.setImages(images); } } @@ -165,6 +139,7 @@ class ImageListWidget { if (widgetIndex > 0) { for (let i = 0; i < widgetIndex; i++) { const w = this.node.widgets[i]; + if (w.hidden) continue; // Standard widget height is ~20-26px, use 26 as safe default otherWidgetsHeight += (w.computedHeight || 26) + 4; } @@ -258,6 +233,10 @@ class ImageListWidget { const x = margin; const width = widgetWidth - margin * 2; + const images = this.images; + // Covers values restored on workflow load, which bypass setImages + this.preloadImages(); + ctx.save(); let currentY = y + SLOT_PADDING; @@ -267,8 +246,8 @@ class ImageListWidget { this._slotPositions = []; // Draw each image slot - for (let i = 0; i < this._images.length; i++) { - const filename = this._images[i]; + for (let i = 0; i < images.length; i++) { + const filename = images[i]; const slotY = currentY + i * slotHeight; // Get cached image data @@ -390,7 +369,7 @@ class ImageListWidget { } // --- Drop zone area --- - const dropZoneY = currentY + this._images.length * slotHeight + SLOT_PADDING; + const dropZoneY = currentY + images.length * slotHeight + SLOT_PADDING; // Drop zone background ctx.fillStyle = this.dropZoneActive ? "#2a2a2a" : "#1a1a1a"; @@ -517,13 +496,10 @@ class ImageListWidget { return false; } +} - /** - * Serialize widget value - */ - serializeValue() { - return this.value; - } +function getImageListWidget(node) { + return node.widgets?.find((w) => w.isReferenceChainList); } /** @@ -543,24 +519,17 @@ app.registerExtension({ nodeType.prototype.onNodeCreated = function () { const result = onNodeCreated?.apply(this, arguments); - // Find the images widget and replace it + // Keep the "images" STRING widget as the serialized value (same position, so old + // workflows restore into it) and show a display-only list widget right after it const imagesWidget = this.widgets?.find((w) => w.name === "images"); if (imagesWidget) { - // Get index - const index = this.widgets.indexOf(imagesWidget); + imagesWidget.hidden = true; - // Create custom widget - const customWidget = new ImageListWidget( - this, - "images", - nodeData.input?.required?.images - ); + const listWidget = this.addCustomWidget(new ImageListWidget(this, imagesWidget)); + this.widgets.splice(this.widgets.indexOf(listWidget), 1); + this.widgets.splice(this.widgets.indexOf(imagesWidget) + 1, 0, listWidget); - // Replace widget - this.widgets[index] = customWidget; - - // Initialize size - customWidget.updateNodeSize(); + listWidget.updateNodeSize(); } // Remove the default image_input_1 slot (created from optional INPUT_TYPES) @@ -601,6 +570,14 @@ app.registerExtension({ return result; }; + // Resize to fit images restored from a saved workflow + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function () { + const result = onConfigure?.apply(this, arguments); + getImageListWidget(this)?.updateNodeSize(); + return result; + }; + // Handle drag and drop const onDragOver = nodeType.prototype.onDragOver; nodeType.prototype.onDragOver = function (e) { @@ -610,8 +587,8 @@ app.registerExtension({ e.preventDefault(); // Update drop zone highlight - const widget = this.widgets?.find((w) => w.name === "images"); - if (widget && widget instanceof ImageListWidget) { + const widget = getImageListWidget(this); + if (widget) { if (!widget.dropZoneActive) { widget.dropZoneActive = true; this.setDirtyCanvas(true, true); @@ -629,8 +606,8 @@ app.registerExtension({ const result = onDragLeave?.apply(this, arguments); // Clear drop zone highlight - const widget = this.widgets?.find((w) => w.name === "images"); - if (widget && widget instanceof ImageListWidget) { + const widget = getImageListWidget(this); + if (widget) { if (widget.dropZoneActive) { widget.dropZoneActive = false; this.setDirtyCanvas(true, true); @@ -643,14 +620,14 @@ app.registerExtension({ const onDragDrop = nodeType.prototype.onDragDrop; nodeType.prototype.onDragDrop = async function (e) { // Clear drop zone highlight - const widget = this.widgets?.find((w) => w.name === "images"); - if (widget && widget instanceof ImageListWidget) { + const widget = getImageListWidget(this); + if (widget) { widget.dropZoneActive = false; this.setDirtyCanvas(true, true); } if (e.dataTransfer?.files?.length > 0) { - if (widget && widget instanceof ImageListWidget) { + if (widget) { for (const file of e.dataTransfer.files) { if (file.type.startsWith("image/")) { await widget.uploadImage(file); @@ -666,8 +643,8 @@ app.registerExtension({ // Handle paste const onPaste = nodeType.prototype.onPaste; nodeType.prototype.onPaste = async function (e) { - const widget = this.widgets?.find((w) => w.name === "images"); - if (widget && widget instanceof ImageListWidget) { + const widget = getImageListWidget(this); + if (widget) { const items = e.clipboardData?.items; if (items) { for (const item of items) {