import { api } from "scripts/api.js"; import type { LLink, IComboWidget, LGraphNode, INodeOutputSlot, INodeInputSlot, IWidget, SerializedLGraphNode, } from "typings/litegraph.js"; import type { ComfyObjectInfo, ComfyGraphNode } from "typings/comfy.js"; import { wait } from "rgthree/common/shared_utils.js"; import { rgthree } from "./rgthree.js"; /** Wraps a node instance keeping closure without mucking the finicky types. */ export class PowerPrompt { readonly isSimple: boolean; readonly node: ComfyGraphNode; readonly promptEl: HTMLTextAreaElement; nodeData: ComfyObjectInfo; readonly combos: { [key: string]: IComboWidget } = {}; readonly combosValues: { [key: string]: string[] } = {}; boundOnFreshNodeDefs!: (event: CustomEvent) => void; private configuring = false; constructor(node: ComfyGraphNode, nodeData: ComfyObjectInfo) { this.node = node; this.node.properties = this.node.properties || {}; this.node.properties["combos_filter"] = ""; this.nodeData = nodeData; this.isSimple = this.nodeData.name.includes("Simple"); this.promptEl = (node.widgets[0]! as any).inputEl; this.addAndHandleKeyboardLoraEditWeight(); this.patchNodeRefresh(); const oldConfigure = this.node.configure; this.node.configure = (info: SerializedLGraphNode) => { this.configuring = true; oldConfigure?.apply(this.node, [info]); this.configuring = false; }; const oldOnConnectionsChange = this.node.onConnectionsChange; this.node.onConnectionsChange = ( type: number, slotIndex: number, isConnected: boolean, link_info: LLink, _ioSlot: INodeOutputSlot | INodeInputSlot, ) => { oldOnConnectionsChange?.apply(this.node, [type, slotIndex, isConnected, link_info, _ioSlot]); this.onNodeConnectionsChange(type, slotIndex, isConnected, link_info, _ioSlot); }; const oldOnConnectInput = this.node.onConnectInput; this.node.onConnectInput = ( inputIndex: number, outputType: INodeOutputSlot["type"], outputSlot: INodeOutputSlot, outputNode: LGraphNode, outputIndex: number, ) => { let canConnect = true; if (oldOnConnectInput) { canConnect = oldOnConnectInput.apply(this.node, [ inputIndex, outputType, outputSlot, outputNode, outputIndex, ]); } return ( this.configuring || rgthree.loadingApiJson || (canConnect && !this.node.inputs[inputIndex]!.disabled) ); }; const oldOnConnectOutput = this.node.onConnectOutput; this.node.onConnectOutput = ( outputIndex: number, inputType: INodeInputSlot["type"], inputSlot: INodeInputSlot, inputNode: LGraphNode, inputIndex: number, ) => { let canConnect = true; if (oldOnConnectOutput) { canConnect = oldOnConnectOutput?.apply(this.node, [ outputIndex, inputType, inputSlot, inputNode, inputIndex, ]); } return ( this.configuring || rgthree.loadingApiJson || (canConnect && !this.node.outputs[outputIndex]!.disabled) ); }; const onPropertyChanged = this.node.onPropertyChanged; this.node.onPropertyChanged = (property: string, value: any, prevValue: any) => { onPropertyChanged && onPropertyChanged.call(this, property, value, prevValue); if (property === "combos_filter") { this.refreshCombos(this.nodeData); } }; // Strip all widgets but prompt (we'll re-add them in refreshCombos) // this.node.widgets.splice(1); for (let i = this.node.widgets.length - 1; i >= 0; i--) { if (this.shouldRemoveServerWidget(this.node.widgets[i]!)) { this.node.widgets.splice(i, 1); } } this.refreshCombos(nodeData); setTimeout(() => { this.stabilizeInputsOutputs(); }, 32); } /** * Cleans up optional out puts when we don't have the optional input. Purely a vanity function. */ onNodeConnectionsChange( _type: number, _slotIndex: number, _isConnected: boolean, _linkInfo: LLink, _ioSlot: INodeOutputSlot | INodeInputSlot, ) { this.stabilizeInputsOutputs(); } private stabilizeInputsOutputs() { // If we are currently "configuring" then skip this stabilization. The connected nodes may // not yet be configured. if (this.configuring || rgthree.loadingApiJson) { return; } // If our first input is connected, then we can show the proper output. const clipLinked = this.node.inputs.some((i) => i.name.includes("clip") && !!i.link); const modelLinked = this.node.inputs.some((i) => i.name.includes("model") && !!i.link); for (const output of this.node.outputs) { const type = (output.type as string).toLowerCase(); if (type.includes("model")) { output.disabled = !modelLinked; } else if (type.includes("conditioning")) { output.disabled = !clipLinked; } else if (type.includes("clip")) { output.disabled = !clipLinked; } else if (type.includes("string")) { // Our text prompt is always enabled, but let's color it so it stands out // if the others are disabled. #7F7 is Litegraph's default. output.color_off = "#7F7"; output.color_on = "#7F7"; } if (output.disabled) { // this.node.disconnectOutput(index); } } } onFreshNodeDefs(event: CustomEvent) { this.refreshCombos(event.detail[this.nodeData.name]); } shouldRemoveServerWidget(widget: IWidget) { return ( widget.name?.startsWith("insert_") || widget.name?.startsWith("target_") || widget.name?.startsWith("crop_") || widget.name?.startsWith("values_") ); } refreshCombos(nodeData: ComfyObjectInfo) { this.nodeData = nodeData; let filter: RegExp | null = null; if (this.node.properties["combos_filter"]?.trim()) { try { filter = new RegExp(this.node.properties["combos_filter"].trim(), "i"); } catch (e) { console.error(`Could not parse "${filter}" for Regular Expression`, e); filter = null; } } // Add the combo for hidden inputs of nodeData let data = Object.assign( {}, this.nodeData.input?.optional || {}, this.nodeData.input?.hidden || {}, ); for (const [key, value] of Object.entries(data)) { //Object.entries(this.nodeData.input?.hidden || {})) { if (Array.isArray(value[0])) { let values = value[0] as string[]; if (key.startsWith("insert")) { values = filter ? values.filter( (v, i) => i < 1 || (i == 1 && v.match(/^disable\s[a-z]/i)) || filter?.test(v), ) : values; const shouldShow = values.length > 2 || (values.length > 1 && !values[1]!.match(/^disable\s[a-z]/i)); if (shouldShow) { if (!this.combos[key]) { this.combos[key] = this.node.addWidget( "combo", key, values, (selected) => { if (selected !== values[0] && !selected.match(/^disable\s[a-z]/i)) { // We wait a frame because if we use a keydown event to call, it'll wipe out // the selection. wait().then(() => { if (key.includes("embedding")) { this.insertSelectionText(`embedding:${selected}`); } else if (key.includes("saved")) { this.insertSelectionText( this.combosValues[`values_${key}`]![values.indexOf(selected)]!, ); } else if (key.includes("lora")) { this.insertSelectionText(``); } this.combos[key]!.value = values[0]; }); } }, { values, serialize: true, // Don't include this in prompt. }, ); (this.combos[key]! as any).oldComputeSize = this.combos[key]!.computeSize; let node = this.node; this.combos[key]!.computeSize = function (width: number) { const size = (this as any).oldComputeSize?.(width) || [ width, LiteGraph.NODE_WIDGET_HEIGHT, ]; if (this === node.widgets[node.widgets.length - 1]) { size[1] += 10; } return size; }; } this.combos[key]!.options!.values = values; this.combos[key]!.value = values[0]; } else if (!shouldShow && this.combos[key]) { this.node.widgets.splice(this.node.widgets.indexOf(this.combos[key]!), 1); delete this.combos[key]; } } else if (key.startsWith("values")) { this.combosValues[key] = values; } } } } insertSelectionText(text: string) { if (!this.promptEl) { console.error("Asked to insert text, but no textbox found."); return; } let prompt = this.promptEl.value; // Use selectionEnd as the split; if we have highlighted text, then we likely don't want to // overwrite it (we could have just deleted it more easily). let first = prompt.substring(0, this.promptEl.selectionEnd).replace(/ +$/, ""); first = first + (["\n"].includes(first[first.length - 1]!) ? "" : first.length ? " " : ""); let second = prompt.substring(this.promptEl.selectionEnd).replace(/^ +/, ""); second = (["\n"].includes(second[0]!) ? "" : second.length ? " " : "") + second; this.promptEl.value = first + text + second; this.promptEl.focus(); this.promptEl.selectionStart = first.length; this.promptEl.selectionEnd = first.length + text.length; } /** * Adds a keydown event listener to our prompt so we can see if we're using the * ctrl/cmd + up/down arrows shortcut. This kind of competes with the core extension * "Comfy.EditAttention" but since that only handles parenthesis and listens on window, we should * be able to intercept and cancel the bubble if we're doing the same action within the lora tag. */ addAndHandleKeyboardLoraEditWeight() { this.promptEl.addEventListener("keydown", (event: KeyboardEvent) => { // If we're not doing a ctrl/cmd + arrow key, then bail. if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) return; if (!event.ctrlKey && !event.metaKey) return; // Unfortunately, we can't see Comfy.EditAttention delta in settings, so we hardcode to 0.01. // We can acutally do better too, let's make it .1 by default, and .01 if also holding shift. const delta = event.shiftKey ? 0.01 : 0.1; let start = this.promptEl.selectionStart; let end = this.promptEl.selectionEnd; let fullText = this.promptEl.value; let selectedText = fullText.substring(start, end); // We don't care about fully rewriting Comfy.EditAttention, we just want to see if our // selected text is a lora, which will always start with "") { start -= 2; end -= 2; } if (fullText[end - 1] == "<") { start += 2; end += 2; } while (!stopOn.includes(fullText[start]!) && start > 0) { start--; } while (!stopOn.includes(fullText[end - 1]!) && end < fullText.length) { end++; } selectedText = fullText.substring(start, end); } // Bail if this isn't a lora. if (!selectedText.startsWith("")) { return; } let weight = Number(selectedText.match(/:(-?\d*(\.\d*)?)>$/)?.[1]) ?? 1; weight += event.key === "ArrowUp" ? delta : -delta; const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`); // Handle the new value and cancel the bubble so Comfy.EditAttention doesn't also try. this.promptEl.setRangeText(updatedText, start, end, "select"); event.preventDefault(); event.stopPropagation(); }); } /** * Patches over api.getNodeDefs in comfy's api.js to fire a custom event that we can listen to * here and manually refresh our combos when a request comes in to fetch the node data; which * only happens once at startup (but before custom nodes js runs), and then after clicking * the "Refresh" button in the floating menu, which is what we care about. */ patchNodeRefresh() { this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this); api.addEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener); const oldNodeRemoved = this.node.onRemoved; this.node.onRemoved = () => { oldNodeRemoved?.call(this.node); api.removeEventListener("fresh-node-defs", this.boundOnFreshNodeDefs as EventListener); }; } }