512 lines
14 KiB
JavaScript
512 lines
14 KiB
JavaScript
import { COLORS } from "./constants.js";
|
|
import { app } from "/scripts/app.js";
|
|
|
|
// --- Text ---
|
|
|
|
export const parseComaString = (raw) => {
|
|
if (!raw || !raw?.trim() || typeof raw !== "string") return null;
|
|
const parsed =
|
|
raw
|
|
.split(",")
|
|
.map((o) => o.trim())
|
|
.filter(Boolean) ?? [];
|
|
return parsed.length ? parsed : null;
|
|
};
|
|
|
|
// Escape HTML to prevent injections
|
|
export const escapeHtml = (str) =>
|
|
str.replace(
|
|
/[&<>"]/g,
|
|
(c) =>
|
|
({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
})[c],
|
|
);
|
|
|
|
// Fit string to width (truncate with ellipsis)
|
|
export const fitString = (ctx, str, maxWidth) => {
|
|
if (ctx.measureText(str).width <= maxWidth) return str;
|
|
while (str.length > 1 && ctx.measureText(str + "…").width > maxWidth) {
|
|
str = str.slice(0, -1);
|
|
}
|
|
return str + "…";
|
|
};
|
|
|
|
// --- Widget helpers ---
|
|
|
|
// Find widget by name
|
|
export const findWidget = (node, name) =>
|
|
node.widgets?.find((w) => w.name === name);
|
|
|
|
export const setWidgetValue = (widget, value) => {
|
|
if (!widget) return;
|
|
widget.value = value;
|
|
widget.callback?.(value);
|
|
};
|
|
|
|
// Hide ComfyUI widget component
|
|
export const hideWidget = (widget, force = false) => {
|
|
widget.type = "hidden";
|
|
widget.computeSize = () => [0, -4]; // -4 to cancel ComfyUI padding
|
|
if (force) {
|
|
widget.hidden = true;
|
|
}
|
|
};
|
|
|
|
export const hideWidgetInput = (node, widget) => {
|
|
const inputIdx = node.inputs?.findIndex(
|
|
(inp) => inp.widget?.name === widget.name,
|
|
);
|
|
if (inputIdx !== -1) node.inputs.splice(inputIdx, 1);
|
|
};
|
|
|
|
// Hook into ComfyUI widget callback (reliable change detection)
|
|
export const hookWidget = (widget, onChange) => {
|
|
if (!widget) return;
|
|
const original = widget.callback;
|
|
widget.callback = function (value) {
|
|
if (original) original.call(this, value);
|
|
onChange(value);
|
|
};
|
|
};
|
|
|
|
export const patchFileDropdown = (widget, noValueLabel, file) => {
|
|
const values = widget.options?.values ?? [];
|
|
const placeholderIdx = values.indexOf(noValueLabel);
|
|
if (placeholderIdx !== -1) values.splice(placeholderIdx, 1);
|
|
if (!values.includes(file)) {
|
|
values.push(file);
|
|
values.sort();
|
|
}
|
|
widget.options.values = values;
|
|
widget.value = file;
|
|
};
|
|
|
|
export const updateSlotVisibility = (node, numSlots, prefix = "input") => {
|
|
const minVisible = 1; // Always keep at least one slot
|
|
|
|
// Remove unused inputs from the end, stop at first connected
|
|
for (let i = node.inputs.length - 1; i >= minVisible; i--) {
|
|
const input = node.inputs[i];
|
|
if (!input) continue;
|
|
if (!input.link) {
|
|
// Only remove if it matches our prefix pattern
|
|
if (input.name.startsWith(prefix)) {
|
|
node.removeInput(i);
|
|
}
|
|
} else {
|
|
break; // Stop at first connected input from the end
|
|
}
|
|
}
|
|
|
|
// Find highest connected index by name
|
|
let highestConnected = -1;
|
|
for (let i = 0; i < numSlots; i++) {
|
|
const input = node.inputs?.find((inp) => inp.name === `${prefix}_${i}`);
|
|
if (input?.link != null) highestConnected = i;
|
|
}
|
|
|
|
// Add one free slot after the last connected
|
|
const targetVisible = highestConnected + 2;
|
|
for (let i = 0; i < Math.min(targetVisible, numSlots); i++) {
|
|
const slotName = `${prefix}_${i}`;
|
|
if (!node.inputs?.find((inp) => inp.name === slotName)) {
|
|
node.addInput(slotName, "STRING");
|
|
}
|
|
}
|
|
|
|
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
|
};
|
|
|
|
export const debounce = (fn, ms = 64) => {
|
|
let timer;
|
|
return (...args) => {
|
|
clearTimeout(timer);
|
|
timer = setTimeout(() => fn(...args), ms);
|
|
};
|
|
};
|
|
|
|
// --- Editor ---
|
|
|
|
// Loop over text nodes in the DOM
|
|
export const walkTextNodes = (root, callback) => {
|
|
const walk = (node) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
if (callback(node) === false) return false;
|
|
} else {
|
|
for (const child of node.childNodes) {
|
|
if (walk(child) === false) return false;
|
|
}
|
|
}
|
|
};
|
|
walk(root);
|
|
};
|
|
|
|
// Build range from offset on text node
|
|
export const buildRangeFromOffsets = (root, start, end) => {
|
|
const range = document.createRange();
|
|
let charCount = 0;
|
|
let startFound = false;
|
|
let complete = false;
|
|
|
|
walkTextNodes(root, (node) => {
|
|
const next = charCount + node.length;
|
|
if (!startFound && next > start) {
|
|
range.setStart(node, start - charCount);
|
|
startFound = true;
|
|
}
|
|
if (startFound && next >= end) {
|
|
range.setEnd(node, end - charCount);
|
|
complete = true;
|
|
return false;
|
|
}
|
|
charCount = next;
|
|
});
|
|
|
|
return complete ? range : null;
|
|
};
|
|
|
|
// Save current caret position in div
|
|
export const saveCaretPosition = (el) => {
|
|
const sel = window.getSelection();
|
|
if (!sel.rangeCount) return 0;
|
|
|
|
const range = sel.getRangeAt(0);
|
|
const preCaretRange = range.cloneRange();
|
|
preCaretRange.selectNodeContents(el);
|
|
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
|
return preCaretRange.toString().length;
|
|
};
|
|
|
|
// Restore caret position in div
|
|
export const restoreCaretPosition = (el, offset) => {
|
|
const range = document.createRange();
|
|
const sel = window.getSelection();
|
|
let charCount = 0;
|
|
let found = false;
|
|
|
|
walkTextNodes(el, (node) => {
|
|
const next = charCount + node.length;
|
|
if (next >= offset) {
|
|
range.setStart(node, offset - charCount);
|
|
range.collapse(true);
|
|
found = true;
|
|
return false;
|
|
}
|
|
charCount = next;
|
|
});
|
|
|
|
if (!found) {
|
|
range.selectNodeContents(el);
|
|
range.collapse(false);
|
|
}
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
};
|
|
|
|
// Get selected word or at caret position
|
|
export const getSelectedOrWordAtCaret = (editor) => {
|
|
const sel = window.getSelection();
|
|
if (!sel.rangeCount) return null;
|
|
|
|
const range = sel.getRangeAt(0);
|
|
|
|
// First cae: selected text
|
|
if (!range.collapsed) {
|
|
return {
|
|
text: range.toString().trim(),
|
|
range: range.cloneRange(),
|
|
};
|
|
}
|
|
|
|
// Second case: no selection
|
|
const fullText = editor.innerText;
|
|
const caretPos = saveCaretPosition(editor);
|
|
|
|
// Look for delimiters
|
|
const delimiters = /[,\n{}%()|]/;
|
|
let start = caretPos;
|
|
let end = caretPos;
|
|
|
|
while (start > 0 && !delimiters.test(fullText[start - 1])) start--;
|
|
while (end < fullText.length && !delimiters.test(fullText[end])) end++;
|
|
|
|
// Trimming spaces
|
|
let word = fullText.slice(start, end);
|
|
const leftTrim = word.length - word.trimStart().length;
|
|
const rightTrim = word.length - word.trimEnd().length;
|
|
start += leftTrim;
|
|
end -= rightTrim;
|
|
word = word.trim();
|
|
|
|
// After trimming, check if surrounded by parentheses in the original text
|
|
const rawWord = fullText.slice(start, end);
|
|
if (
|
|
start > 0 &&
|
|
end < fullText.length &&
|
|
fullText[start - 1] === "(" &&
|
|
fullText[end] === ")"
|
|
) {
|
|
start -= 1;
|
|
end += 1;
|
|
word = fullText.slice(start, end);
|
|
}
|
|
|
|
if (!word) return null;
|
|
|
|
// Rebuild the range on the word
|
|
const wordRange = buildRangeFromOffsets(editor, start, end);
|
|
if (!wordRange) return null;
|
|
|
|
return { text: word, range: wordRange };
|
|
};
|
|
|
|
// Adjust weight on selected word
|
|
export const adjustWeight = (text, delta) => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return text;
|
|
|
|
const weighted = trimmed.match(/^\((.+):(-?\d+(?:\.\d+)?)\)$/s);
|
|
if (weighted) {
|
|
const tag = weighted[1];
|
|
const weight = Math.round((parseFloat(weighted[2]) + delta) * 10) / 10;
|
|
if (weight === 1.0) return text.replace(trimmed, tag);
|
|
return text.replace(trimmed, `(${tag}:${weight.toFixed(1)})`);
|
|
}
|
|
|
|
const weight = Math.round((1.0 + delta) * 10) / 10;
|
|
if (weight === 1.0) return text;
|
|
return text.replace(trimmed, `(${trimmed}:${weight.toFixed(1)})`);
|
|
};
|
|
|
|
export const createEditor = (
|
|
textarea,
|
|
{ activeColor = COLORS.text, onInput } = {},
|
|
) => {
|
|
// Hide native textarea
|
|
textarea.style.display = "none";
|
|
|
|
const editor = document.createElement("div");
|
|
editor.contentEditable = "true";
|
|
editor.spellcheck = false;
|
|
editor.style.cssText = `
|
|
width: 100%;
|
|
height: 100%;
|
|
padding: 6px;
|
|
border: 1px solid #444;
|
|
border-radius: 4px;
|
|
background: #111;
|
|
color: ${activeColor};
|
|
font-family: monospace;
|
|
font-size: 10px;
|
|
white-space: pre-wrap;
|
|
overflow-y: auto;
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
cursor: text;
|
|
line-height: 1.5;
|
|
`;
|
|
|
|
textarea.parentNode.appendChild(editor);
|
|
|
|
// --- Event listener - Input: Sync editor -> textarea ---
|
|
editor.addEventListener("input", () => {
|
|
const pos = saveCaretPosition(editor);
|
|
textarea.value = editor.innerText;
|
|
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
|
if (onInput) onInput();
|
|
restoreCaretPosition(editor, pos);
|
|
});
|
|
|
|
// --- Event listener - Paste: Sanitize text pasting to prevent errors with HTML coloration ---
|
|
editor.addEventListener("paste", (e) => {
|
|
e.preventDefault();
|
|
const text = e.clipboardData.getData("text/plain");
|
|
document.execCommand("insertText", false, text);
|
|
});
|
|
|
|
// --- Event listener - Keydown: Look for keyboard shortcuts ---
|
|
editor.addEventListener("keydown", (e) => {
|
|
// Enter - Add proper line break to track caret
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
document.execCommand("insertLineBreak");
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Up/Down - Adjust weight on selected text
|
|
if (!e.ctrlKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown"))
|
|
return;
|
|
e.preventDefault();
|
|
|
|
const delta = e.key === "ArrowUp" ? 0.1 : -0.1;
|
|
const selection = getSelectedOrWordAtCaret(editor);
|
|
if (!selection) return;
|
|
|
|
const adjusted = adjustWeight(selection.text, delta);
|
|
if (adjusted === selection.text) return;
|
|
|
|
// Save selected text length
|
|
const adjustedLength = adjusted.length;
|
|
|
|
const sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(selection.range);
|
|
document.execCommand("insertText", false, adjusted);
|
|
|
|
// Reselect inserted text
|
|
const pos = saveCaretPosition(editor);
|
|
const newRange = buildRangeFromOffsets(
|
|
editor,
|
|
pos - adjustedLength,
|
|
pos,
|
|
);
|
|
if (newRange) {
|
|
sel.removeAllRanges();
|
|
sel.addRange(newRange);
|
|
}
|
|
});
|
|
|
|
return editor;
|
|
};
|
|
|
|
// --- Registration ---
|
|
|
|
export const registerNode = (name, attachFn) => {
|
|
app.registerExtension({
|
|
name,
|
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== name) return;
|
|
|
|
const original = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function () {
|
|
if (original) original.call(this);
|
|
attachFn(this);
|
|
};
|
|
},
|
|
});
|
|
};
|
|
|
|
export const waitForWidget = (node, widgetName, callback) => {
|
|
const guardKey = `_attached_${widgetName}`;
|
|
if (node[guardKey]) return;
|
|
node[guardKey] = true;
|
|
|
|
// Retry until widgets DOM is ready
|
|
const tryAttach = () => {
|
|
const widget = findWidget(node, widgetName);
|
|
if (widget?.inputEl?.parentNode) {
|
|
callback(node);
|
|
} else {
|
|
requestAnimationFrame(tryAttach);
|
|
}
|
|
};
|
|
requestAnimationFrame(tryAttach);
|
|
};
|
|
|
|
export const waitForWidgets = (node, callback) => {
|
|
if (node._attached) return;
|
|
node._attached = true;
|
|
|
|
// Retry until widgets DOM is ready
|
|
const tryAttach = () => {
|
|
if (node.widgets?.length) {
|
|
callback(node);
|
|
} else {
|
|
requestAnimationFrame(tryAttach);
|
|
}
|
|
};
|
|
requestAnimationFrame(tryAttach);
|
|
};
|
|
|
|
// --- Buttons ---
|
|
|
|
export const flashButton = (node, index, flashLabel) => {
|
|
const buttonRowWidget = findWidget(node, "action_buttons");
|
|
if (!buttonRowWidget) return;
|
|
const btn = buttonRowWidget.buttons[index];
|
|
|
|
// Cancel previous timer if existing
|
|
if (btn._flashTimer) clearTimeout(btn._flashTimer);
|
|
|
|
// Save original if not flashing
|
|
if (!btn._originalLabel) btn._originalLabel = btn.label;
|
|
btn.label = flashLabel;
|
|
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
|
|
|
btn._flashTimer = setTimeout(() => {
|
|
btn.label = btn._originalLabel;
|
|
btn._originalLabel = null;
|
|
btn._flashTimer = null;
|
|
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
|
}, 1500);
|
|
};
|
|
|
|
// --- Coloring ---
|
|
|
|
export const drawGroupBorder = (
|
|
ctx,
|
|
node,
|
|
fromWidgetName,
|
|
toWidgetName,
|
|
colorOn,
|
|
colorOff,
|
|
) => {
|
|
const from = findWidget(node, fromWidgetName);
|
|
const to = findWidget(node, toWidgetName);
|
|
if (!from || !to) return;
|
|
|
|
// If colorOff passed, toggle depending on fromWidget value
|
|
const isOn = !!from.value;
|
|
const color =
|
|
colorOff !== undefined ? (isOn ? colorOn : colorOff) : colorOn;
|
|
|
|
const y = from.last_y - 2;
|
|
const height = to.last_y + (to.computedHeight ?? 20) - y - 2;
|
|
const margin = 9;
|
|
const radius = 5;
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = color;
|
|
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.roundRect(margin, y, node.size[0] - margin * 2, height, 6);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
};
|
|
|
|
export const drawWidgetOutline = (ctx, node, widgetName, colorOn, colorOff) => {
|
|
const w = findWidget(node, widgetName);
|
|
if (!w) return;
|
|
|
|
// If colorOff passed, toggle depending on widget value
|
|
const isOn = !!w.value;
|
|
const color =
|
|
colorOff !== undefined ? (isOn ? colorOn : colorOff) : colorOn;
|
|
|
|
const y = w.last_y - 1;
|
|
const height = w.computedHeight ?? 20;
|
|
const margin = 14;
|
|
const radius = 15;
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = color;
|
|
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.roundRect(margin, y, node.size[0] - margin * 2, height - 2, 15);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
};
|
|
|
|
export const setInputDotColor = (node, inputName, active) => {
|
|
const inp = node.inputs?.find((i) => i.name === inputName);
|
|
if (!inp) return;
|
|
inp.color_on = active ? undefined : COLORS.inactive;
|
|
inp.color_off = inp.color_on;
|
|
};
|