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; };