633 lines
18 KiB
JavaScript
633 lines
18 KiB
JavaScript
/**
|
|
* OliMegaStringList — Mega String List frontend.
|
|
*
|
|
* Each string row is both a canvas-drawn widget AND an input connector bullet.
|
|
* The bullet is positioned at the widget's row Y by LiteGraph's
|
|
* #arrangeWidgetInputSlots mechanism (triggered by inp.widget = { name }).
|
|
* It is hidden by default and appears on hover or when connected — standard
|
|
* ComfyUI "converted widget" behaviour.
|
|
*
|
|
* Each row accepts:
|
|
* - Typed text (widget mode, unconnected)
|
|
* - Any connected node output: STRING scalar, LIST of strings, etc.
|
|
*
|
|
* No separate list1/list2 inputs are needed — the per-row bullet handles both.
|
|
*
|
|
* Features:
|
|
* - Drag-to-reorder with blue insertion-line indicator
|
|
* - Always-visible empty placeholder row at end (also has a bullet)
|
|
* - Toggle (enable/disable individual row)
|
|
* - Delete (✕) — works whether row is connected or not
|
|
* - Serialise / restore from widgets_values
|
|
*
|
|
* Items in the text widget are separated by NEWLINES (one per line).
|
|
* The delimiter widget only affects the joined `string` output.
|
|
*/
|
|
|
|
import { app } from "../../scripts/app.js";
|
|
import {
|
|
ROW_H,
|
|
PAD,
|
|
PAD_L,
|
|
hit,
|
|
drawRowBackground,
|
|
drawHandle,
|
|
drawTogglePill,
|
|
drawDeleteBtn,
|
|
drawDisabledOverlay,
|
|
startRowDrag,
|
|
installDragForeground,
|
|
} from "./oli_widgets_common.js";
|
|
|
|
const NODE_TYPE = "OliMegaStringList";
|
|
const MIN_W = 320;
|
|
|
|
// ── OliHiddenWidget ───────────────────────────────────────────────────────────
|
|
// Zero-height, invisible widget that holds the comma-separated list of
|
|
// disabled slot names (_disabled_slots). Python reads this to skip toggled-off
|
|
// rows even when they are connected to a LiteGraph node (where the widget dict
|
|
// {on,text} never reaches Python).
|
|
|
|
class OliHiddenWidget {
|
|
constructor(name, value) {
|
|
this.name = name;
|
|
this.type = "custom";
|
|
this.value = value ?? "";
|
|
this.options = {};
|
|
this.hidden = true;
|
|
}
|
|
|
|
computeSize(w) {
|
|
return [w, 0];
|
|
}
|
|
serializeValue() {
|
|
return this.value;
|
|
}
|
|
}
|
|
|
|
// ── OliStringRowWidget ────────────────────────────────────────────────────────
|
|
|
|
class OliStringRowWidget {
|
|
constructor(name) {
|
|
this.name = name;
|
|
this.type = "custom";
|
|
this.y = 0;
|
|
this.last_y = 0;
|
|
this.options = {};
|
|
this._value = { on: true, text: "" };
|
|
this._rHandle = null;
|
|
this._rToggle = null;
|
|
this._rText = null;
|
|
this._rDel = null;
|
|
}
|
|
|
|
get value() {
|
|
return this._value;
|
|
}
|
|
set value(v) {
|
|
if (v === undefined) return;
|
|
this._value =
|
|
v && typeof v === "object" && "text" in v
|
|
? { on: true, text: "", ...v }
|
|
: { on: true, text: "" };
|
|
}
|
|
|
|
/** True when this row has no text and no connected input. */
|
|
_isEmpty(node) {
|
|
if ((this._value.text ?? "").trim()) return false;
|
|
const inp = node?.inputs?.find((i) => i.name === this.name);
|
|
return inp ? inp.link == null : true;
|
|
}
|
|
|
|
computeSize(width) {
|
|
return [width, ROW_H];
|
|
}
|
|
|
|
draw(ctx, node, width, posY, height) {
|
|
this.last_y = posY;
|
|
const mid = posY + height / 2;
|
|
const fsize = Math.round(height * 0.6);
|
|
const font = `${fsize}px sans-serif`;
|
|
const empty = this._isEmpty(node);
|
|
|
|
ctx.save();
|
|
// ComfyUI dims connected widget-inputs via globalAlpha before calling draw().
|
|
// We reset it here so buttons stay visually active; the text area uses
|
|
// colour/style (italic, blue) to convey read-only state instead.
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
drawRowBackground(
|
|
ctx,
|
|
posY,
|
|
width,
|
|
height,
|
|
LiteGraph.WIDGET_BGCOLOR ?? "#222",
|
|
);
|
|
|
|
if (empty) {
|
|
// Placeholder: just hint text, no controls
|
|
ctx.fillStyle = "#444";
|
|
ctx.font = font;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
const maxW = width - PAD_L - PAD - 8;
|
|
let phLabel = "← connect string/list or type string";
|
|
while (
|
|
ctx.measureText(phLabel).width > maxW &&
|
|
phLabel.length > 4
|
|
) {
|
|
phLabel = phLabel.slice(0, -4) + "…";
|
|
}
|
|
ctx.fillText(phLabel, PAD_L + 6, mid);
|
|
this._rHandle = null;
|
|
this._rToggle = null;
|
|
this._rDel = null;
|
|
this._rText = [
|
|
PAD_L + 2,
|
|
posY + 1,
|
|
width - PAD_L - PAD - 2,
|
|
height - 2,
|
|
];
|
|
ctx.restore();
|
|
return;
|
|
}
|
|
|
|
const fade = this._value.on ? 1.0 : 0.4;
|
|
let x = PAD_L + 4;
|
|
|
|
// Drag handle ≡
|
|
({ rect: this._rHandle, nextX: x } = drawHandle(
|
|
ctx,
|
|
x,
|
|
posY,
|
|
mid,
|
|
height,
|
|
font,
|
|
));
|
|
|
|
// Toggle pill
|
|
({ rect: this._rToggle, nextX: x } = drawTogglePill(
|
|
ctx,
|
|
x,
|
|
mid,
|
|
height,
|
|
this._value.on,
|
|
));
|
|
|
|
// Delete button ✕
|
|
const { rect: delRect, leftX: dx } = drawDeleteBtn(
|
|
ctx,
|
|
width,
|
|
posY,
|
|
mid,
|
|
height,
|
|
fsize,
|
|
);
|
|
this._rDel = delRect;
|
|
|
|
// Text content (dimmed when row is toggled off)
|
|
ctx.globalAlpha = fade;
|
|
const lx = x,
|
|
lw = dx - x - 6;
|
|
|
|
const inp = node?.inputs?.find((i) => i.name === this.name);
|
|
const connected = inp?.link != null;
|
|
|
|
if (connected) {
|
|
// Show the source node's title so the user knows what's connected
|
|
const link = app.graph.links[inp.link];
|
|
const srcNode = link ? app.graph.getNodeById(link.origin_id) : null;
|
|
const srcTitle = srcNode?.title ?? srcNode?.type ?? "?";
|
|
ctx.fillStyle = "#88aacc";
|
|
ctx.font = `italic ${fsize}px sans-serif`;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
let label = srcTitle;
|
|
while (ctx.measureText(label).width > lw - 2 && label.length > 4) {
|
|
label = label.slice(0, -4) + "…";
|
|
}
|
|
ctx.fillText(label, lx, mid);
|
|
this._rText = null;
|
|
} else {
|
|
const hasText = (this._value.text ?? "").trim().length > 0;
|
|
ctx.fillStyle = hasText
|
|
? (LiteGraph.WIDGET_TEXT_COLOR ?? "#ccc")
|
|
: "#555";
|
|
ctx.font = font;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
let label = (this._value.text || "(empty — click to edit)").replace(
|
|
/\n/g,
|
|
" ↵ ",
|
|
);
|
|
while (ctx.measureText(label).width > lw - 2 && label.length > 4) {
|
|
label = label.slice(0, -4) + "…";
|
|
}
|
|
ctx.fillText(label, lx, mid);
|
|
this._rText = [lx, posY + 1, lw, height - 2];
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Dim row when node's enable widget is false
|
|
const enableW = node.widgets?.find((w) => w.name === "enable");
|
|
if (enableW?.value === false)
|
|
drawDisabledOverlay(ctx, posY, width, height);
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
mouse(event, pos, node) {
|
|
if (node._dragWidget && node._dragWidget !== this) return false;
|
|
|
|
if (event.type === "pointerdown") {
|
|
if (this._rHandle && hit(pos, ...this._rHandle)) {
|
|
startRowDrag(
|
|
node,
|
|
this,
|
|
() =>
|
|
_getStringWidgets(node).filter(
|
|
(w) => !w._isEmpty(node),
|
|
),
|
|
() => _renumberStringSlots(node),
|
|
);
|
|
return true;
|
|
}
|
|
node._pendingClick = this;
|
|
return true;
|
|
}
|
|
|
|
if (event.type === "pointerup") {
|
|
if (node._pendingClick === this) {
|
|
node._pendingClick = null;
|
|
if (this._rDel && hit(pos, ...this._rDel)) {
|
|
this._delete(node);
|
|
} else if (this._rToggle && hit(pos, ...this._rToggle)) {
|
|
this._toggle(node);
|
|
} else if (this._rText && hit(pos, ...this._rText)) {
|
|
this._editText(event, node);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
_delete(node) {
|
|
const idx = node.widgets.indexOf(this);
|
|
if (idx >= 0) {
|
|
node.widgets.splice(idx, 1);
|
|
const inpIdx = (node.inputs ?? []).findIndex(
|
|
(i) => i.name === this.name,
|
|
);
|
|
if (inpIdx >= 0) {
|
|
node.disconnectInput?.(inpIdx);
|
|
node.removeInput(inpIdx);
|
|
}
|
|
_syncStringSlots(node);
|
|
_updateDisabledSlots(node);
|
|
node.setSize([node.size[0], node.computeSize()[1]]);
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
}
|
|
|
|
_toggle(node) {
|
|
this._value.on = !this._value.on;
|
|
_updateDisabledSlots(node);
|
|
node.setDirtyCanvas(true);
|
|
}
|
|
|
|
_editText(event, node) {
|
|
app.canvas.prompt(
|
|
"String",
|
|
this._value.text ?? "",
|
|
(v) => {
|
|
this._value.text = v;
|
|
_syncStringSlots(node);
|
|
node.setDirtyCanvas(true);
|
|
},
|
|
event,
|
|
);
|
|
}
|
|
|
|
serializeValue() {
|
|
return { ...this._value };
|
|
}
|
|
}
|
|
|
|
// ── String slot management ────────────────────────────────────────────────────
|
|
|
|
function _isStringWidget(w) {
|
|
return w instanceof OliStringRowWidget;
|
|
}
|
|
function _getStringWidgets(node) {
|
|
return (node.widgets ?? []).filter(_isStringWidget);
|
|
}
|
|
|
|
/**
|
|
* After a drag reorder, rename widget + input names to string1..N in visual
|
|
* order so Python's _sorted_slot_keys processes them correctly.
|
|
* Links are safe: LiteGraph tracks them by input INDEX, not name.
|
|
*/
|
|
function _renumberStringSlots(node) {
|
|
const strWidgets = _getStringWidgets(node);
|
|
// Capture widget→input associations BEFORE any renaming to avoid
|
|
// collision when two widgets temporarily share the same name mid-loop.
|
|
const inpOf = new Map(
|
|
strWidgets.map((w) => [
|
|
w,
|
|
(node.inputs ?? []).find((s) => s.name === w.name) ?? null,
|
|
]),
|
|
);
|
|
strWidgets.forEach((w, i) => {
|
|
const newName = "string" + (i + 1);
|
|
if (w.name === newName) return;
|
|
w.name = newName;
|
|
const inp = inpOf.get(w);
|
|
if (inp) {
|
|
inp.name = newName;
|
|
if (inp.widget) inp.widget.name = newName;
|
|
}
|
|
});
|
|
node._stringCounter = strWidgets.length;
|
|
_updateDisabledSlots(node);
|
|
}
|
|
|
|
/**
|
|
* Add a new string row widget + its input connector bullet.
|
|
* Setting inp.widget = { name } tells LiteGraph to position the bullet at
|
|
* the widget's Y coordinate (#arrangeWidgetInputSlots).
|
|
*/
|
|
function _addStringRow(node, value) {
|
|
node._stringCounter = (node._stringCounter ?? 0) + 1;
|
|
const name = "string" + node._stringCounter;
|
|
|
|
const w = new OliStringRowWidget(name);
|
|
if (value) w.value = value;
|
|
node.addCustomWidget(w);
|
|
|
|
node.addInput(name, "*");
|
|
const inp = (node.inputs ?? []).find((i) => i.name === name);
|
|
if (inp) inp.widget = { name };
|
|
|
|
node.setSize([node.size[0], node.computeSize()[1]]);
|
|
node.setDirtyCanvas?.(true, true);
|
|
return w;
|
|
}
|
|
|
|
/**
|
|
* Add a string row WIDGET ONLY — no input is created.
|
|
* Used during configure() so we don't create inputs that conflict with
|
|
* LiteGraph's own input restoration from info.inputs.
|
|
*/
|
|
function _addStringRowWidgetOnly(node, value) {
|
|
node._stringCounter = (node._stringCounter ?? 0) + 1;
|
|
const name = "string" + node._stringCounter;
|
|
const w = new OliStringRowWidget(name);
|
|
if (value) w.value = value;
|
|
node.addCustomWidget(w);
|
|
return w;
|
|
}
|
|
|
|
/**
|
|
* Ensure exactly one empty placeholder row at the end.
|
|
* Also cleans orphan inputs and ensures inp.widget is set for all string inputs.
|
|
*/
|
|
function _syncStringSlots(node) {
|
|
// 1. Remove empty placeholder widgets (and their inputs).
|
|
const toRemove = _getStringWidgets(node).filter((w) => w._isEmpty(node));
|
|
for (const w of toRemove) {
|
|
const widgetIdx = node.widgets.indexOf(w);
|
|
if (widgetIdx >= 0) node.widgets.splice(widgetIdx, 1);
|
|
const inpIdx = (node.inputs ?? []).findIndex(
|
|
(inp) => inp.name === w.name,
|
|
);
|
|
if (inpIdx >= 0) node.removeInput(inpIdx);
|
|
}
|
|
|
|
// 2. Remove orphan string inputs (input exists but no widget row).
|
|
const widgetNames = new Set(_getStringWidgets(node).map((w) => w.name));
|
|
const orphans = (node.inputs ?? [])
|
|
.map((inp, i) => ({ inp, i }))
|
|
.filter(
|
|
({ inp }) =>
|
|
/^string\d+$/.test(inp.name) && !widgetNames.has(inp.name),
|
|
)
|
|
.sort((a, b) => b.i - a.i);
|
|
for (const { i } of orphans) node.removeInput(i);
|
|
|
|
// 3. Ensure every remaining string input has inp.widget.
|
|
for (const inp of node.inputs ?? []) {
|
|
if (/^string\d+$/.test(inp.name) && !inp.widget) {
|
|
inp.widget = { name: inp.name };
|
|
}
|
|
}
|
|
|
|
// 4. Ensure placeholder at end.
|
|
const after = _getStringWidgets(node);
|
|
if (after.length === 0 || !after[after.length - 1]._isEmpty(node)) {
|
|
_addStringRow(node, null);
|
|
}
|
|
}
|
|
|
|
function _updateDisabledSlots(node) {
|
|
const dsw = (node.widgets ?? []).find(
|
|
(w) => w instanceof OliHiddenWidget && w.name === "_disabled_slots",
|
|
);
|
|
if (!dsw) return;
|
|
const disabled = _getStringWidgets(node)
|
|
.filter((w) => !w._value.on && !w._isEmpty(node))
|
|
.map((w) => w.name);
|
|
dsw.value = disabled.join(",");
|
|
}
|
|
|
|
// ── Extension ─────────────────────────────────────────────────────────────────
|
|
|
|
app.registerExtension({
|
|
name: "oli.megaStringList",
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== NODE_TYPE) return;
|
|
|
|
const _computeSize = nodeType.prototype.computeSize;
|
|
nodeType.prototype.computeSize = function () {
|
|
const s = _computeSize
|
|
? _computeSize.apply(this, arguments)
|
|
: [MIN_W, 60];
|
|
s[0] = Math.max(s[0], MIN_W);
|
|
return s;
|
|
};
|
|
|
|
const _onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
_onNodeCreated?.apply(this, arguments);
|
|
this.serialize_widgets = true;
|
|
this._stringCounter = 0;
|
|
this._dragWidget = null;
|
|
this._dragCurrentY = null;
|
|
this._pendingClick = null;
|
|
this._configuring = false;
|
|
|
|
const dsIdx = (this.widgets ?? []).findIndex(
|
|
(w) => w.name === "_disabled_slots",
|
|
);
|
|
if (dsIdx >= 0) {
|
|
const cur = this.widgets[dsIdx];
|
|
this.widgets.splice(
|
|
dsIdx,
|
|
1,
|
|
new OliHiddenWidget("_disabled_slots", cur.value ?? ""),
|
|
);
|
|
} else {
|
|
this.addCustomWidget(
|
|
new OliHiddenWidget("_disabled_slots", ""),
|
|
);
|
|
}
|
|
|
|
_syncStringSlots(this);
|
|
|
|
const s = this.computeSize();
|
|
this.setSize([Math.max(s[0], MIN_W), s[1]]);
|
|
};
|
|
|
|
// configure (workflow restore) ─────────────────────────────────────────
|
|
// Strategy: create string WIDGETS only from widgets_values (no inputs),
|
|
// then call _configure which lets LiteGraph restore all inputs from
|
|
// info.inputs (with proper link data). _syncStringSlots reconciles after.
|
|
const _configure = nodeType.prototype.configure;
|
|
nodeType.prototype.configure = function (info) {
|
|
this._configuring = true;
|
|
|
|
// Clear only string widgets; do NOT touch inputs here —
|
|
// LiteGraph's configure will replace this.inputs from info.inputs.
|
|
this.widgets = (this.widgets ?? []).filter(
|
|
(w) => !_isStringWidget(w),
|
|
);
|
|
this._stringCounter = 0;
|
|
|
|
// Parse widgets_values positionally.
|
|
// Order: [0] bool=enable [1] string=delimiter [2] string=_disabled_slots [n…] {on,text}
|
|
// Old workflows: [0] bool [1] string [2] {on,text} (no _disabled_slots)
|
|
const wv = info.widgets_values ?? [];
|
|
let wvi = 0;
|
|
|
|
if (typeof wv[wvi] === "boolean") {
|
|
const w = this.widgets?.find((w) => w.name === "enable");
|
|
if (w) w.value = wv[wvi];
|
|
wvi++;
|
|
}
|
|
if (typeof wv[wvi] === "string") {
|
|
const w = this.widgets?.find((w) => w.name === "delimiter");
|
|
if (w) w.value = wv[wvi];
|
|
wvi++;
|
|
}
|
|
// Second consecutive string → _disabled_slots (new format only).
|
|
if (typeof wv[wvi] === "string") {
|
|
const dsw = (this.widgets ?? []).find(
|
|
(w) => w instanceof OliHiddenWidget,
|
|
);
|
|
if (dsw) dsw.value = wv[wvi];
|
|
wvi++;
|
|
}
|
|
// Create widgets ONLY — inputs are restored by _configure below.
|
|
for (let j = wvi; j < wv.length; j++) {
|
|
const v = wv[j];
|
|
if (v && typeof v === "object" && "text" in v) {
|
|
_addStringRowWidgetOnly(this, v);
|
|
}
|
|
}
|
|
|
|
// LiteGraph configure: replaces this.inputs from info.inputs (with links).
|
|
_configure?.apply(this, [{ ...info, widgets_values: undefined }]);
|
|
|
|
this._configuring = false;
|
|
|
|
// Reconcile widgets ↔ inputs: set inp.widget, clean up orphans,
|
|
// add placeholder. Connected-but-empty rows survive because
|
|
// inp.link is now properly set from the restored inputs.
|
|
_syncStringSlots(this);
|
|
_updateDisabledSlots(this);
|
|
};
|
|
|
|
const _onConnectionsChange = nodeType.prototype.onConnectionsChange;
|
|
nodeType.prototype.onConnectionsChange = function (
|
|
type,
|
|
index,
|
|
connected,
|
|
link,
|
|
ioSlot,
|
|
) {
|
|
_onConnectionsChange?.apply(this, arguments);
|
|
if (type !== LiteGraph.INPUT) return;
|
|
if (this._configuring) return;
|
|
const inp = this.inputs?.[index];
|
|
if (!inp || !/^string\d+$/.test(inp.name)) return;
|
|
|
|
if (!connected) {
|
|
const w = (this.widgets ?? []).find(
|
|
(ww) =>
|
|
ww instanceof OliStringRowWidget &&
|
|
ww.name === inp.name,
|
|
);
|
|
if (w && !(w._value.text ?? "").trim()) {
|
|
const self = this;
|
|
const slotName = inp.name;
|
|
setTimeout(() => {
|
|
const ii = (self.inputs ?? []).findIndex(
|
|
(s) => s.name === slotName,
|
|
);
|
|
if (ii >= 0 && self.inputs[ii].link != null) return;
|
|
const wi = (self.widgets ?? []).indexOf(w);
|
|
if (wi >= 0) self.widgets.splice(wi, 1);
|
|
if (ii >= 0) self.removeInput(ii);
|
|
_syncStringSlots(self);
|
|
_updateDisabledSlots(self);
|
|
self.setSize([self.size[0], self.computeSize()[1]]);
|
|
self.setDirtyCanvas(true, true);
|
|
}, 0);
|
|
return;
|
|
}
|
|
_syncStringSlots(this);
|
|
_updateDisabledSlots(this);
|
|
} else {
|
|
_syncStringSlots(this);
|
|
_updateDisabledSlots(this);
|
|
const self = this;
|
|
setTimeout(() => self.setDirtyCanvas?.(true, true), 0);
|
|
}
|
|
};
|
|
|
|
const _onMouseDown = nodeType.prototype.onMouseDown;
|
|
nodeType.prototype.onMouseDown = function (e, pos) {
|
|
for (const w of _getStringWidgets(this)) {
|
|
const inp = (this.inputs ?? []).find((i) => i.name === w.name);
|
|
if (inp?.link == null) continue;
|
|
if (w._rHandle && hit(pos, ...w._rHandle)) {
|
|
startRowDrag(
|
|
this,
|
|
w,
|
|
() =>
|
|
_getStringWidgets(this).filter(
|
|
(ww) => !ww._isEmpty(this),
|
|
),
|
|
() => _renumberStringSlots(this),
|
|
);
|
|
return true;
|
|
}
|
|
if (w._rDel && hit(pos, ...w._rDel)) {
|
|
w._delete(this);
|
|
return true;
|
|
}
|
|
if (w._rToggle && hit(pos, ...w._rToggle)) {
|
|
w._toggle(this);
|
|
return true;
|
|
}
|
|
}
|
|
return _onMouseDown?.call(this, e, pos) ?? false;
|
|
};
|
|
|
|
installDragForeground(nodeType, _getStringWidgets);
|
|
},
|
|
});
|