diff --git a/js/base_any_input_connected_node.js b/js/base_any_input_connected_node.js new file mode 100644 index 0000000..c813f29 --- /dev/null +++ b/js/base_any_input_connected_node.js @@ -0,0 +1,107 @@ +import { app } from "../../scripts/app.js"; +import { RgthreeBaseNode } from "./base_node.js"; +import { addConnectionLayoutSupport, addMenuItem, getConnectedInputNodes } from "./utils.js"; +export class BaseAnyInputConnectedNode extends RgthreeBaseNode { + constructor(title = BaseAnyInputConnectedNode.title) { + super(title); + this.isVirtualNode = true; + this.debouncerTempWidth = 0; + this.schedulePromise = null; + this.addInput("", "*"); + } + scheduleStabilizeWidgets(ms = 100) { + if (!this.schedulePromise) { + this.schedulePromise = new Promise((resolve) => { + setTimeout(() => { + this.schedulePromise = null; + this.doStablization(); + resolve(); + }, ms); + }); + } + return this.schedulePromise; + } + stabilizeInputsOutputs() { + let hasEmptyInput = false; + for (let index = this.inputs.length - 1; index >= 0; index--) { + const input = this.inputs[index]; + if (!input.link) { + if (index < this.inputs.length - 1) { + this.removeInput(index); + } + else { + hasEmptyInput = true; + } + } + } + !hasEmptyInput && this.addInput('', '*'); + } + doStablization() { + if (!this.graph) { + return; + } + this._tempWidth = this.size[0]; + const linkedNodes = getConnectedInputNodes(app, this); + this.stabilizeInputsOutputs(); + this.handleLinkedNodesStabilization(linkedNodes); + app.graph.setDirtyCanvas(true, true); + this.scheduleStabilizeWidgets(500); + } + handleLinkedNodesStabilization(linkedNodes) { + linkedNodes; + throw new Error('handleLinkedNodesStabilization should be overridden.'); + } + onConnectionsChainChange() { + this.scheduleStabilizeWidgets(); + } + onConnectionsChange(type, index, connected, linkInfo, ioSlot) { + super.onConnectionsChange && super.onConnectionsChange(type, index, connected, linkInfo, ioSlot); + this.scheduleStabilizeWidgets(); + } + removeInput(slot) { + this._tempWidth = this.size[0]; + return super.removeInput(slot); + } + addInput(name, type, extra_info) { + this._tempWidth = this.size[0]; + return super.addInput(name, type, extra_info); + } + addWidget(type, name, value, callback, options) { + this._tempWidth = this.size[0]; + return super.addWidget(type, name, value, callback, options); + } + removeWidget(widgetOrSlot) { + this._tempWidth = this.size[0]; + super.removeWidget(widgetOrSlot); + } + computeSize(out) { + var _a, _b; + let size = super.computeSize(out); + if (this._tempWidth) { + size[0] = this._tempWidth; + this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth); + this.debouncerTempWidth = setTimeout(() => { + this._tempWidth = null; + }, 32); + } + if (this.properties['collapse_connections']) { + const rows = Math.max(((_a = this.inputs) === null || _a === void 0 ? void 0 : _a.length) || 0, ((_b = this.outputs) === null || _b === void 0 ? void 0 : _b.length) || 0, 1) - 1; + size[1] = size[1] - (rows * LiteGraph.NODE_SLOT_HEIGHT); + } + setTimeout(() => { + app.graph.setDirtyCanvas(true, true); + }, 16); + return size; + } + static setUp(clazz) { + addConnectionLayoutSupport(clazz, app, [['Left', 'Right'], ['Right', 'Left']]); + addMenuItem(clazz, app, { + name: (node) => { var _a; return (`${((_a = node.properties) === null || _a === void 0 ? void 0 : _a['collapse_connections']) ? 'Show' : 'Collapse'} Connections`); }, + property: 'collapse_connections', + prepareValue: (_value, node) => { var _a; return !((_a = node.properties) === null || _a === void 0 ? void 0 : _a['collapse_connections']); }, + callback: (_node) => { app.graph.setDirtyCanvas(true, true); } + }); + LiteGraph.registerNodeType(clazz.type, clazz); + clazz.category = clazz._category; + } +} diff --git a/js/base_node.js b/js/base_node.js index eb34ccd..49e3d35 100644 --- a/js/base_node.js +++ b/js/base_node.js @@ -2,13 +2,12 @@ export class RgthreeBaseNode extends LGraphNode { constructor(title = RgthreeBaseNode.title) { super(title); this.isVirtualNode = true; + this._tempWidth = 0; if (title == '__NEED_NAME__') { throw new Error('RgthreeBaseNode needs overrides.'); } this.properties = this.properties || {}; } - onModeChange() { - } set mode(mode) { if (this.mode_ != mode) { this.mode_ = mode; @@ -18,7 +17,24 @@ export class RgthreeBaseNode extends LGraphNode { get mode() { return this.mode_; } + onModeChange() { + } + async handleAction(action) { + action; + } + removeWidget(widgetOrSlot) { + if (typeof widgetOrSlot === 'number') { + this.widgets.splice(widgetOrSlot, 1); + } + else if (widgetOrSlot) { + const index = this.widgets.indexOf(widgetOrSlot); + if (index > -1) { + this.widgets.splice(index, 1); + } + } + } } +RgthreeBaseNode.exposedActions = []; RgthreeBaseNode.title = "__NEED_NAME__"; RgthreeBaseNode.category = 'rgthree'; RgthreeBaseNode._category = 'rgthree'; diff --git a/js/base_node_collector.js b/js/base_node_collector.js index 0b30221..e39a51c 100644 --- a/js/base_node_collector.js +++ b/js/base_node_collector.js @@ -1,5 +1,6 @@ import { app } from "../../scripts/app.js"; import { RgthreeBaseNode } from "./base_node.js"; +import { getConnectedOutputNodes } from "./utils.js"; export class BaseCollectorNode extends RgthreeBaseNode { constructor(title) { super(title); @@ -11,30 +12,16 @@ export class BaseCollectorNode extends RgthreeBaseNode { const cloned = super.clone(); return cloned; } - updateOutputLinks(startNode = this) { - const type = startNode.constructor.type; - if (startNode.onConnectionsChainChange) { - startNode.onConnectionsChainChange(); - } - if (startNode === this || (type === null || type === void 0 ? void 0 : type.includes('Reroute')) || (type === null || type === void 0 ? void 0 : type.includes('Combiner'))) { - for (const output of startNode.outputs) { - if (!output.links || !output.links.length) - continue; - for (const linkId of output.links) { - const link = app.graph.links[linkId]; - if (!link) - continue; - const targetNode = app.graph.getNodeById(link.target_id); - targetNode && this.updateOutputLinks(targetNode); - } - } - } - } onConnectionsChange(_type, _slotIndex, _isConnected, link_info, _ioSlot) { if (!link_info) return; this.stabilizeInputsOutputs(); - this.updateOutputLinks(); + const connectedNodes = getConnectedOutputNodes(app, this); + for (const node of connectedNodes) { + if (node.onConnectionsChainChange) { + node.onConnectionsChainChange(); + } + } } stabilizeInputsOutputs() { var _a, _b; diff --git a/js/base_node_mode_changer.js b/js/base_node_mode_changer.js index eedc49a..e174967 100644 --- a/js/base_node_mode_changer.js +++ b/js/base_node_mode_changer.js @@ -1,12 +1,9 @@ -import { app } from "../../scripts/app.js"; -import { RgthreeBaseNode } from "./base_node.js"; -import { addConnectionLayoutSupport, addMenuItem, getConnectedInputNodes, wait } from "./utils.js"; -export class BaseNodeModeChanger extends RgthreeBaseNode { +import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js"; +import { wait } from "./utils.js"; +export class BaseNodeModeChanger extends BaseAnyInputConnectedNode { constructor(title) { super(title); this.isVirtualNode = true; - this.debouncer = 0; - this.schedulePromise = null; this.modeOn = -1; this.modeOff = -1; wait(10).then(() => { @@ -14,25 +11,9 @@ export class BaseNodeModeChanger extends RgthreeBaseNode { throw new Error('modeOn and modeOff must be overridden.'); } }); - this.addInput("", "*"); + this.addOutput("OPT_CONNECTION", "*"); } - scheduleStabilizeWidgets() { - if (!this.schedulePromise) { - this.schedulePromise = new Promise((resolve) => { - setTimeout(() => { - resolve(this.stabilizeWidgets()); - this.schedulePromise = null; - }, 100); - }); - } - return this.schedulePromise; - } - stabilizeWidgets() { - if (!this.graph) { - return; - } - const linkedNodes = getConnectedInputNodes(app, this); - this.stabilizeInputsOutputs(); + handleLinkedNodesStabilization(linkedNodes) { for (const [index, node] of linkedNodes.entries()) { let widget = this.widgets && this.widgets[index]; if (!widget) { @@ -42,82 +23,31 @@ export class BaseNodeModeChanger extends RgthreeBaseNode { this.setWidget(widget, node); } if (this.widgets && this.widgets.length > linkedNodes.length) { - this._tempWidth = this.size[0]; this.widgets.length = linkedNodes.length; } - app.graph.setDirtyCanvas(true, true); - setTimeout(() => { this.stabilizeWidgets(); }, 500); } setWidget(widget, linkedNode) { const off = linkedNode.mode === this.modeOff; widget.name = `Enable ${linkedNode.title}`; widget.options = { 'on': 'yes', 'off': 'no' }; widget.value = !off; - widget.callback = () => { - const off = linkedNode.mode === this.modeOff; + widget.doModeChange = (force) => { + let off = force == null ? linkedNode.mode === this.modeOff : force; linkedNode.mode = (off ? this.modeOn : this.modeOff); widget.value = off; }; + widget.callback = () => { + widget.doModeChange(); + }; } - onConnectionsChainChange() { - this.scheduleStabilizeWidgets(); + forceWidgetOff(widget) { + widget.doModeChange(false); } - onConnectionsChange(_type, _index, _connected, _linkInfo, _ioSlot) { - this.scheduleStabilizeWidgets(); - } - removeInput(slot) { - this._tempWidth = this.size[0]; - return super.removeInput(slot); - } - addInput(name, type, extra_info) { - this._tempWidth = this.size[0]; - return super.addInput(name, type, extra_info); - } - stabilizeInputsOutputs() { - let hasEmptyInput = false; - for (let index = this.inputs.length - 1; index >= 0; index--) { - const input = this.inputs[index]; - if (!input.link) { - if (index < this.inputs.length - 1) { - this.removeInput(index); - } - else { - hasEmptyInput = true; - } - } - } - !hasEmptyInput && this.addInput('', '*'); - } - computeSize(out) { - var _a, _b; - let size = super.computeSize(out); - if (this._tempWidth) { - size[0] = this._tempWidth; - this._tempWidth = null; - } - if (this.properties['collapse_connections']) { - const rows = Math.max(((_a = this.inputs) === null || _a === void 0 ? void 0 : _a.length) || 0, ((_b = this.outputs) === null || _b === void 0 ? void 0 : _b.length) || 0, 1) - 1; - size[1] = size[1] - (rows * LiteGraph.NODE_SLOT_HEIGHT); - } - setTimeout(() => { - app.graph.setDirtyCanvas(true, true); - }, 16); - return size; + forceWidgetOn(widget) { + widget.doModeChange(true); } static setUp(clazz) { - addMenuItem(clazz, app, { - name: 'Refresh', - callback: (node) => { node.scheduleStabilizeWidgets(); } - }); - addMenuItem(clazz, app, { - name: (node) => { var _a; return (`${((_a = node.properties) === null || _a === void 0 ? void 0 : _a['collapse_connections']) ? 'Show' : 'Collapse'} Connections`); }, - property: 'collapse_connections', - prepareValue: (_value, node) => { var _a; return !((_a = node.properties) === null || _a === void 0 ? void 0 : _a['collapse_connections']); }, - callback: (_node) => { app.graph.setDirtyCanvas(true, true); } - }); - addConnectionLayoutSupport(clazz, app, [['Left'], ['Right']]); - LiteGraph.registerNodeType(clazz.type, clazz); - clazz.category = clazz._category; + BaseAnyInputConnectedNode.setUp(clazz); } } BaseNodeModeChanger.collapsible = false; diff --git a/js/base_power_prompt.js b/js/base_power_prompt.js new file mode 100644 index 0000000..8f05daf --- /dev/null +++ b/js/base_power_prompt.js @@ -0,0 +1,198 @@ +import { api } from '../../scripts/api.js'; +import { wait } from './utils.js'; +export class PowerPrompt { + constructor(node, nodeData) { + this.combos = {}; + this.combosValues = {}; + this.node = node; + this.node.properties = this.node.properties || {}; + this.nodeData = nodeData; + this.isSimple = this.nodeData.name.includes('Simple'); + this.promptEl = node.widgets[0].inputEl; + this.addAndHandleKeyboardLoraEditWeight(); + this.patchNodeRefresh(); + const oldOnConnectionsChange = this.node.onConnectionsChange; + this.node.onConnectionsChange = (type, slotIndex, isConnected, link_info, _ioSlot) => { + oldOnConnectionsChange === null || oldOnConnectionsChange === void 0 ? void 0 : 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, outputType, outputSlot, outputNode, outputIndex) => { + let canConnect = true; + if (oldOnConnectInput) { + canConnect = oldOnConnectInput.apply(this.node, [inputIndex, outputType, outputSlot, outputNode, outputIndex]); + } + return canConnect && !this.node.inputs[inputIndex].disabled; + }; + const oldOnConnectOutput = this.node.onConnectOutput; + this.node.onConnectOutput = (outputIndex, inputType, inputSlot, inputNode, inputIndex) => { + let canConnect = true; + if (oldOnConnectOutput) { + canConnect = oldOnConnectOutput === null || oldOnConnectOutput === void 0 ? void 0 : oldOnConnectOutput.apply(this.node, [outputIndex, inputType, inputSlot, inputNode, inputIndex]); + } + return canConnect && !this.node.outputs[outputIndex].disabled; + }; + 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); + } + onNodeConnectionsChange(_type, _slotIndex, _isConnected, _linkInfo, _ioSlot) { + this.stabilizeInputsOutputs(); + } + stabilizeInputsOutputs() { + 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.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')) { + output.color_off = '#7F7'; + output.color_on = '#7F7'; + } + if (output.disabled) { + } + } + } + onFreshNodeDefs(event) { + this.refreshCombos(event.detail[this.nodeData.name]); + } + shouldRemoveServerWidget(widget) { + var _a, _b, _c; + return ((_a = widget.name) === null || _a === void 0 ? void 0 : _a.startsWith('insert_')) || ((_b = widget.name) === null || _b === void 0 ? void 0 : _b.startsWith('target_')) || ((_c = widget.name) === null || _c === void 0 ? void 0 : _c.startsWith('crop_')); + } + refreshCombos(nodeData) { + var _a, _b; + this.nodeData = nodeData; + let data = ((_a = this.nodeData.input) === null || _a === void 0 ? void 0 : _a.optional) || {}; + data = Object.assign(data, ((_b = this.nodeData.input) === null || _b === void 0 ? void 0 : _b.hidden) || {}); + for (const [key, value] of Object.entries(data)) { + if (Array.isArray(value[0])) { + const values = value[0]; + if (key.startsWith('insert')) { + 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)) { + 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, + }); + this.combos[key].oldComputeSize = this.combos[key].computeSize; + let node = this.node; + this.combos[key].computeSize = function (width) { + var _a, _b; + const size = ((_b = (_a = this).oldComputeSize) === null || _b === void 0 ? void 0 : _b.call(_a, 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) { + if (!this.promptEl) { + console.error('Asked to insert text, but no textbox found.'); + return; + } + let prompt = this.promptEl.value; + 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; + } + addAndHandleKeyboardLoraEditWeight() { + this.promptEl.addEventListener('keydown', (event) => { + var _a, _b; + if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) + return; + if (!event.ctrlKey && !event.metaKey) + return; + const delta = event.shiftKey ? .01 : .1; + let start = this.promptEl.selectionStart; + let end = this.promptEl.selectionEnd; + let fullText = this.promptEl.value; + let selectedText = fullText.substring(start, end); + if (!selectedText) { + const stopOn = "<>()\r\n\t"; + if (fullText[start] == '>') { + 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); + } + if (!selectedText.startsWith('')) { + return; + } + let weight = (_b = Number((_a = selectedText.match(/:(-?\d*(\.\d*)?)>$/)) === null || _a === void 0 ? void 0 : _a[1])) !== null && _b !== void 0 ? _b : 1; + weight += event.key === "ArrowUp" ? delta : -delta; + const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`); + this.promptEl.setRangeText(updatedText, start, end, 'select'); + event.preventDefault(); + event.stopPropagation(); + }); + } + patchNodeRefresh() { + this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this); + api.addEventListener('fresh-node-defs', this.boundOnFreshNodeDefs); + const oldNodeRemoved = this.node.onRemoved; + this.node.onRemoved = () => { + oldNodeRemoved === null || oldNodeRemoved === void 0 ? void 0 : oldNodeRemoved.call(this.node); + api.removeEventListener('fresh-node-defs', this.boundOnFreshNodeDefs); + }; + } +} diff --git a/js/bypasser.js b/js/bypasser.js index ddbafa1..f919ff8 100644 --- a/js/bypasser.js +++ b/js/bypasser.js @@ -9,7 +9,20 @@ class BypasserNode extends BaseNodeModeChanger { this.modeOn = MODE_ALWAYS; this.modeOff = MODE_BYPASS; } + async handleAction(action) { + if (action === 'Bypass all') { + for (const widget of this.widgets) { + this.forceWidgetOff(widget); + } + } + else if (action === 'Enable all') { + for (const widget of this.widgets) { + this.forceWidgetOn(widget); + } + } + } } +BypasserNode.exposedActions = ['Bypass all', 'Enable all']; BypasserNode.type = NodeTypesString.FAST_BYPASSER; BypasserNode.title = NodeTypesString.FAST_BYPASSER; app.registerExtension({ diff --git a/js/constants.js b/js/constants.js index 841e544..335ecd2 100644 --- a/js/constants.js +++ b/js/constants.js @@ -9,5 +9,6 @@ export const NodeTypesString = { NODE_MODE_REPEATER: addRgthree('Mute / Bypass Repeater'), FAST_MUTER: addRgthree('Fast Muter'), FAST_BYPASSER: addRgthree('Fast Bypasser'), + FAST_ACTIONS_BUTTON: addRgthree('Fast Actions Button'), NODE_COLLECTOR: addRgthree('Node Collector'), }; diff --git a/js/display_int.js b/js/display_int.js index 22562e6..1433ede 100644 --- a/js/display_int.js +++ b/js/display_int.js @@ -19,9 +19,8 @@ app.registerExtension({ addConnectionLayoutSupport(nodeType, app, [['Left'], ['Right']]); const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { - var _a; onExecuted === null || onExecuted === void 0 ? void 0 : onExecuted.apply(this, [message]); - (_a = this.showValueWidget) === null || _a === void 0 ? void 0 : _a.value = message.text[0]; + this.showValueWidget.value = message.text[0]; }; } }, diff --git a/js/fast_actions_button.js b/js/fast_actions_button.js new file mode 100644 index 0000000..f7a3884 --- /dev/null +++ b/js/fast_actions_button.js @@ -0,0 +1,229 @@ +import { app } from "../../scripts/app.js"; +import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js"; +import { NodeTypesString } from "./constants.js"; +const MODE_ALWAYS = 0; +const MODE_MUTE = 2; +const MODE_BYPASS = 4; +class FastActionsButton extends BaseAnyInputConnectedNode { + constructor(title) { + super(title); + this.isVirtualNode = true; + this.serialize_widgets = true; + this.widgetToData = new Map(); + this.nodeIdtoFunctionCache = new Map(); + this.executingFromShortcut = false; + this.properties['buttonText'] = '🎬 Action!'; + this.properties['shortcutModifier'] = 'alt'; + this.properties['shortcutKey'] = ''; + this.buttonWidget = this.addWidget('button', this.properties['buttonText'], null, () => { + this.executeConnectedNodes(); + }, { serialize: false }); + this.keypressBound = this.onKeypress.bind(this); + this.keyupBound = this.onKeyup.bind(this); + } + configure(info) { + super.configure(info); + setTimeout(() => { + if (info.widgets_values) { + for (let [index, value] of info.widgets_values.entries()) { + if (index > 0) { + if (value.startsWith('comfy_action:')) { + this.addComfyActionWidget(index); + value = value.replace('comfy_action:', ''); + } + if (this.widgets[index]) { + this.widgets[index].value = value; + } + } + } + } + }, 100); + } + clone() { + const cloned = super.clone(); + cloned.properties['buttonText'] = '🎬 Action!'; + cloned.properties['shortcutKey'] = ''; + return cloned; + } + onAdded(graph) { + window.addEventListener('keydown', this.keypressBound); + window.addEventListener('keyup', this.keyupBound); + } + onRemoved() { + window.removeEventListener('keydown', this.keypressBound); + window.removeEventListener('keyup', this.keyupBound); + } + async onKeypress(event) { + const target = event.target; + if (this.executingFromShortcut || target.localName == "input" || target.localName == "textarea") { + return; + } + if (this.properties['shortcutKey'].trim() && this.properties['shortcutKey'].toLowerCase() === event.key.toLowerCase()) { + let good = this.properties['shortcutModifier'] !== 'ctrl' || event.ctrlKey; + good = good && this.properties['shortcutModifier'] !== 'alt' || event.altKey; + good = good && this.properties['shortcutModifier'] !== 'shift' || event.shiftKey; + good = good && this.properties['shortcutModifier'] !== 'meta' || event.metaKey; + if (good) { + setTimeout(() => { + this.executeConnectedNodes(); + }, 20); + this.executingFromShortcut = true; + event.preventDefault(); + event.stopImmediatePropagation(); + app.canvas.dirty_canvas = true; + return false; + } + } + return; + } + onKeyup(event) { + const target = event.target; + if (target.localName == "input" || target.localName == "textarea") { + return; + } + this.executingFromShortcut = false; + } + onPropertyChanged(property, value, _prevValue) { + if (property == 'buttonText') { + this.buttonWidget.name = value; + } + if (property == 'shortcutKey') { + value = value.trim(); + this.properties['shortcutKey'] = value && value[0].toLowerCase() || ''; + } + } + handleLinkedNodesStabilization(linkedNodes) { + var _a, _b; + let indexOffset = 1; + for (const [index, node] of linkedNodes.entries()) { + let widgetAtSlot = this.widgets[index + indexOffset]; + if (widgetAtSlot && ((_a = this.widgetToData.get(widgetAtSlot)) === null || _a === void 0 ? void 0 : _a.comfy)) { + indexOffset++; + widgetAtSlot = this.widgets[index + indexOffset]; + } + if (!widgetAtSlot || this.widgetToData.get(widgetAtSlot).node !== node) { + let widget = null; + for (let i = index + indexOffset; i < this.widgets.length; i++) { + if (this.widgetToData.get(this.widgets[i]).node === node) { + widget = this.widgets.splice(i, 1)[0]; + this.widgets.splice(index + indexOffset, 0, widget); + break; + } + } + if (!widget) { + const exposedActions = node.constructor.exposedActions || []; + widget = this.addWidget('combo', node.title, 'None', '', { values: ['None', 'Mute', 'Bypass', 'Enable', ...exposedActions] }); + widget.serializeValue = async (_node, _index) => { + return widget === null || widget === void 0 ? void 0 : widget.value; + }; + this.widgetToData.set(widget, { node }); + } + } + } + for (let i = this.widgets.length - 1; i > linkedNodes.length + indexOffset - 1; i--) { + const widgetAtSlot = this.widgets[i]; + if (widgetAtSlot && ((_b = this.widgetToData.get(widgetAtSlot)) === null || _b === void 0 ? void 0 : _b.comfy)) { + continue; + } + this.removeWidget(widgetAtSlot); + } + } + removeWidget(widgetOrSlot) { + const widget = typeof widgetOrSlot === 'number' ? this.widgets[widgetOrSlot] : widgetOrSlot; + if (widget && this.widgetToData.has(widget)) { + this.widgetToData.delete(widget); + } + super.removeWidget(widgetOrSlot); + } + async executeConnectedNodes() { + var _a; + for (const widget of this.widgets) { + if (widget == this.buttonWidget) { + continue; + } + const action = widget.value; + const { comfy, node } = (_a = this.widgetToData.get(widget)) !== null && _a !== void 0 ? _a : {}; + if (comfy) { + if (action === 'Queue Prompt') { + await comfy.queuePrompt(); + } + continue; + } + if (node) { + if (action === 'Mute') { + node.mode = MODE_MUTE; + } + else if (action === 'Bypass') { + node.mode = MODE_BYPASS; + } + else if (action === 'Enable') { + node.mode = MODE_ALWAYS; + } + if (node.handleAction) { + await node.handleAction(action); + } + app.graph.change(); + continue; + } + console.warn('Fast Actions Button has a widget without correct data.'); + } + } + addComfyActionWidget(slot) { + let widget = this.addWidget('combo', 'Comfy Action', 'None', () => { + if (widget.value.startsWith('MOVE ')) { + this.widgets.push(this.widgets.splice(this.widgets.indexOf(widget), 1)[0]); + widget.value = widget['lastValue_']; + } + else if (widget.value.startsWith('REMOVE ')) { + this.removeWidget(widget); + } + widget['lastValue_'] = widget.value; + }, { + values: ['None', 'Queue Prompt', 'REMOVE Comfy Action', 'MOVE to end'] + }); + widget['lastValue_'] = 'None'; + widget.serializeValue = async (_node, _index) => { + return `comfy_app:${widget === null || widget === void 0 ? void 0 : widget.value}`; + }; + this.widgetToData.set(widget, { comfy: app }); + if (slot != null) { + this.widgets.splice(slot, 0, this.widgets.splice(this.widgets.indexOf(widget), 1)[0]); + } + return widget; + } + onSerialize(o) { + var _a; + super.onSerialize && super.onSerialize(o); + for (let [index, value] of (o.widgets_values || []).entries()) { + if (((_a = this.widgets[index]) === null || _a === void 0 ? void 0 : _a.name) === 'Comfy Action') { + o.widgets_values[index] = `comfy_action:${value}`; + } + } + } + static setUp(clazz) { + BaseAnyInputConnectedNode.setUp(clazz); + addMenuItem(clazz, app, { + name: '➕ Append a Comfy Action', + callback: (nodeArg) => { + nodeArg.addComfyActionWidget(); + } + }); + } +} +FastActionsButton.type = NodeTypesString.FAST_ACTIONS_BUTTON; +FastActionsButton.title = NodeTypesString.FAST_ACTIONS_BUTTON; +FastActionsButton['@buttonText'] = { type: 'string' }; +FastActionsButton['@shortcutModifier'] = { type: 'combo', values: ['ctrl', 'alt', 'shift'] }; +FastActionsButton['@shortcutKey'] = { type: 'string' }; +FastActionsButton.collapsible = false; +app.registerExtension({ + name: "rgthree.FastButtonAction", + registerCustomNodes() { + FastActionsButton.setUp(FastActionsButton); + }, + loadedGraphNode(node) { + if (node.type == FastActionsButton.title) { + node._tempWidth = node.size[0]; + } + } +}); diff --git a/js/muter.js b/js/muter.js index ea42bf3..dcb7470 100644 --- a/js/muter.js +++ b/js/muter.js @@ -9,7 +9,20 @@ class MuterNode extends BaseNodeModeChanger { this.modeOn = MODE_ALWAYS; this.modeOff = MODE_MUTE; } + async handleAction(action) { + if (action === 'Mute all') { + for (const widget of this.widgets) { + this.forceWidgetOff(widget); + } + } + else if (action === 'Enable all') { + for (const widget of this.widgets) { + this.forceWidgetOn(widget); + } + } + } } +MuterNode.exposedActions = ['Mute all', 'Enable all']; MuterNode.type = NodeTypesString.FAST_MUTER; MuterNode.title = NodeTypesString.FAST_MUTER; app.registerExtension({ diff --git a/js/node_collector.js b/js/node_collector.js index 291c6b2..9439def 100644 --- a/js/node_collector.js +++ b/js/node_collector.js @@ -7,7 +7,6 @@ class CollectorNode extends BaseCollectorNode { } CollectorNode.type = NodeTypesString.NODE_COLLECTOR; CollectorNode.title = NodeTypesString.NODE_COLLECTOR; -CollectorNode.legacyType = "Node Combiner (rgthree)"; class CombinerNode extends CollectorNode { constructor(title = CombinerNode.title) { super(title); @@ -37,7 +36,7 @@ class CombinerNode extends CollectorNode { CombinerNode.legacyType = "Node Combiner (rgthree)"; CombinerNode.title = "‼️ Node Combiner [DEPRECATED]"; async function updateCombinerToCollector(node) { - if (node.type === CollectorNode.legacyType) { + if (node.type === CombinerNode.legacyType) { const newNode = new CollectorNode(); if (node.title != CombinerNode.title) { newNode.title = node.title.replace('‼️ ', ''); diff --git a/js/node_mode_relay.js b/js/node_mode_relay.js index ac7b6c6..f88a3f5 100644 --- a/js/node_mode_relay.js +++ b/js/node_mode_relay.js @@ -78,8 +78,8 @@ NodeModeRelay.help = [ app.registerExtension({ name: "rgthree.NodeModeRepeaterHelper", registerCustomNodes() { - addHelp(NodeModeRelay, app); addConnectionLayoutSupport(NodeModeRelay, app, [['Left', 'Right'], ['Right', 'Left']]); + addHelp(NodeModeRelay, app); LiteGraph.registerNodeType(NodeModeRelay.type, NodeModeRelay); NodeModeRelay.category = NodeModeRelay._category; }, diff --git a/js/node_mode_repeater.js b/js/node_mode_repeater.js index b11adb7..ad78fab 100644 --- a/js/node_mode_repeater.js +++ b/js/node_mode_repeater.js @@ -8,7 +8,7 @@ class NodeModeRepeater extends BaseCollectorNode { this.hasRelayInput = false; this.hasTogglerOutput = false; this.removeOutput(0); - this.addOutput('FAST_TOGGLER', '_FAST_TOGGLER_', { + this.addOutput('OPT_CONNECTION', '*', { color_on: '#Fc0', color_off: '#a80', }); @@ -20,7 +20,7 @@ class NodeModeRepeater extends BaseCollectorNode { canConnect = canConnect && ((_a = super.onConnectOutput) === null || _a === void 0 ? void 0 : _a.call(this, outputIndex, inputType, inputSlot, inputNode, inputIndex)); } let nextNode = getConnectedOutputNodes(app, this, inputNode)[0] || inputNode; - return canConnect && (nextNode.type === NodeTypesString.FAST_MUTER || nextNode.type === NodeTypesString.FAST_BYPASSER); + return canConnect && [NodeTypesString.FAST_MUTER, NodeTypesString.FAST_BYPASSER, NodeTypesString.NODE_COLLECTOR, NodeTypesString.FAST_ACTIONS_BUTTON].includes(nextNode.type || ''); } onConnectInput(inputIndex, outputType, outputSlot, outputNode, outputIndex) { var _a; @@ -71,7 +71,7 @@ class NodeModeRepeater extends BaseCollectorNode { } } else if (!this.outputs[0]) { - this.addOutput('FAST_TOGGLER', '_FAST_TOGGLER_', { + this.addOutput('OPT_CONNECTION', '*', { color_on: '#Fc0', color_off: '#a80', }); @@ -103,8 +103,8 @@ NodeModeRepeater.help = [ app.registerExtension({ name: "rgthree.NodeModeRepeater", registerCustomNodes() { - addHelp(NodeModeRepeater, app); addConnectionLayoutSupport(NodeModeRepeater, app, [['Left', 'Right'], ['Right', 'Left']]); + addHelp(NodeModeRepeater, app); LiteGraph.registerNodeType(NodeModeRepeater.type, NodeModeRepeater); NodeModeRepeater.category = NodeModeRepeater._category; }, diff --git a/js/power_prompt.js b/js/power_prompt.js index 6d0dcba..f13b785 100644 --- a/js/power_prompt.js +++ b/js/power_prompt.js @@ -1,201 +1,11 @@ import { app } from '../../scripts/app.js'; -import { api } from '../../scripts/api.js'; -import { addConnectionLayoutSupport, wait } from './utils.js'; -class PowerPrompt { - constructor(node, nodeData) { - this.combos = {}; - this.combosValues = {}; - this.node = node; - this.node.properties = this.node.properties || {}; - this.nodeData = nodeData; - this.isSimple = this.nodeData.name.includes('Simple'); - this.promptEl = node.widgets[0].inputEl; - this.addAndHandleKeyboardLoraEditWeight(); - this.patchNodeRefresh(); - const oldOnConnectionsChange = this.node.onConnectionsChange; - this.node.onConnectionsChange = (type, slotIndex, isConnected, link_info, _ioSlot) => { - oldOnConnectionsChange === null || oldOnConnectionsChange === void 0 ? void 0 : 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, outputType, outputSlot, outputNode, outputIndex) => { - let canConnect = true; - if (oldOnConnectInput) { - canConnect = oldOnConnectInput.apply(this.node, [inputIndex, outputType, outputSlot, outputNode, outputIndex]); - } - return canConnect && !this.node.inputs[inputIndex].disabled; - }; - const oldOnConnectOutput = this.node.onConnectOutput; - this.node.onConnectOutput = (outputIndex, inputType, inputSlot, inputNode, inputIndex) => { - let canConnect = true; - if (oldOnConnectOutput) { - canConnect = oldOnConnectOutput === null || oldOnConnectOutput === void 0 ? void 0 : oldOnConnectOutput.apply(this.node, [outputIndex, inputType, inputSlot, inputNode, inputIndex]); - } - return canConnect && !this.node.outputs[outputIndex].disabled; - }; - this.node.widgets.splice(1); - this.refreshCombos(nodeData); - setTimeout(() => { - this.stabilizeInputsOutputs(); - }, 32); - } - onNodeConnectionsChange(_type, _slotIndex, _isConnected, _linkInfo, _ioSlot) { - this.stabilizeInputsOutputs(); - } - stabilizeInputsOutputs() { - 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.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')) { - output.color_off = '#7F7'; - output.color_on = '#7F7'; - } - if (output.disabled) { - } - } - } - onFreshNodeDefs(event) { - this.refreshCombos(event.detail[this.nodeData.name]); - } - findAndPatchCombos() { - } - refreshCombos(nodeData) { - var _a, _b; - this.nodeData = nodeData; - let data = ((_a = this.nodeData.input) === null || _a === void 0 ? void 0 : _a.optional) || {}; - data = Object.assign(data, ((_b = this.nodeData.input) === null || _b === void 0 ? void 0 : _b.hidden) || {}); - for (const [key, value] of Object.entries(data)) { - if (Array.isArray(value[0])) { - const values = value[0]; - if (key.startsWith('insert')) { - 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)) { - 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, - }); - this.combos[key].oldComputeSize = this.combos[key].computeSize; - let node = this.node; - this.combos[key].computeSize = function (width) { - var _a, _b; - const size = ((_b = (_a = this).oldComputeSize) === null || _b === void 0 ? void 0 : _b.call(_a, 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) { - if (!this.promptEl) { - console.error('Asked to insert text, but no textbox found.'); - return; - } - let prompt = this.promptEl.value; - 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; - } - addAndHandleKeyboardLoraEditWeight() { - this.promptEl.addEventListener('keydown', (event) => { - var _a, _b; - if (!(event.key === "ArrowUp" || event.key === "ArrowDown")) - return; - if (!event.ctrlKey && !event.metaKey) - return; - const delta = event.shiftKey ? .01 : .1; - let start = this.promptEl.selectionStart; - let end = this.promptEl.selectionEnd; - let fullText = this.promptEl.value; - let selectedText = fullText.substring(start, end); - if (!selectedText) { - const stopOn = "<>() \r\n\t"; - if (fullText[start] == '>') { - 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); - } - if (!selectedText.startsWith('')) { - return; - } - let weight = (_b = Number((_a = selectedText.match(/:(-?\d*(\.\d*)?)>$/)) === null || _a === void 0 ? void 0 : _a[1])) !== null && _b !== void 0 ? _b : 1; - weight += event.key === "ArrowUp" ? delta : -delta; - const updatedText = selectedText.replace(/(:-?\d*(\.\d*)?)?>$/, `:${weight.toFixed(2)}>`); - this.promptEl.setRangeText(updatedText, start, end, 'select'); - event.preventDefault(); - event.stopPropagation(); - }); - } - patchNodeRefresh() { - this.boundOnFreshNodeDefs = this.onFreshNodeDefs.bind(this); - api.addEventListener('fresh-node-defs', this.boundOnFreshNodeDefs); - const oldNodeRemoved = this.node.onRemoved; - this.node.onRemoved = () => { - oldNodeRemoved === null || oldNodeRemoved === void 0 ? void 0 : oldNodeRemoved.call(this.node); - api.removeEventListener('fresh-node-defs', this.boundOnFreshNodeDefs); - }; - } -} +import { addConnectionLayoutSupport } from './utils.js'; +import { PowerPrompt } from './base_power_prompt.js'; let nodeData = null; app.registerExtension({ name: 'rgthree.PowerPrompt', async beforeRegisterNodeDef(nodeType, passedNodeData, _app) { - if (passedNodeData.name.startsWith('Power Prompt') && passedNodeData.name.includes('rgthree')) { + if (passedNodeData.name.includes('Power Prompt') && passedNodeData.name.includes('rgthree')) { nodeData = passedNodeData; const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { diff --git a/js/reroute.js b/js/reroute.js index 34822ec..92368a1 100644 --- a/js/reroute.js +++ b/js/reroute.js @@ -1,5 +1,5 @@ import { app } from "../../scripts/app.js"; -import { addConnectionLayoutSupport, addMenuSubMenu } from "./utils.js"; +import { addConnectionLayoutSupport, addMenuItem } from "./utils.js"; app.registerExtension({ name: "rgthree.Reroute", registerCustomNodes() { @@ -24,7 +24,6 @@ app.registerExtension({ return cloned; } onConnectionsChange(type, _slotIndex, connected, _link_info, _ioSlot) { - var _a, _b, _c; if (connected && type === LiteGraph.OUTPUT) { const types = new Set(this.outputs[0].links.map((l) => app.graph.links[l].type).filter((t) => t !== "*")); if (types.size > 1) { @@ -40,6 +39,10 @@ app.registerExtension({ } } } + this.stabilize(); + } + stabilize() { + var _a, _b, _c; let currentNode = this; let updateNodes = []; let inputType = null; @@ -154,23 +157,10 @@ app.registerExtension({ ["Bottom", "Right"], ["Bottom", "Top"], ], (node) => { node.applyNodeSize(); }); - addMenuSubMenu(RerouteNode, app, { - name: 'Height', - property: 'size', - options: (() => { - const options = []; - for (let w = 8; w > 0; w--) { - options.push(`${w * 10}`); - } - return options; - })(), - prepareValue: (value, node) => [node.size[0], Number(value)], - callback: (node) => node.applyNodeSize() - }); - addMenuSubMenu(RerouteNode, app, { + addMenuItem(RerouteNode, app, { name: 'Width', property: 'size', - options: (() => { + subMenuOptions: (() => { const options = []; for (let w = 8; w > 0; w--) { options.push(`${w * 10}`); @@ -180,6 +170,19 @@ app.registerExtension({ prepareValue: (value, node) => [Number(value), node.size[1]], callback: (node) => node.applyNodeSize() }); + addMenuItem(RerouteNode, app, { + name: 'Height', + property: 'size', + subMenuOptions: (() => { + const options = []; + for (let w = 8; w > 0; w--) { + options.push(`${w * 10}`); + } + return options; + })(), + prepareValue: (value, node) => [node.size[0], Number(value)], + callback: (node) => node.applyNodeSize() + }); LiteGraph.registerNodeType(RerouteNode.title, RerouteNode); RerouteNode.category = RerouteNode._category; }, diff --git a/js/seed.js b/js/seed.js index e3eacc9..2df5aa6 100644 --- a/js/seed.js +++ b/js/seed.js @@ -11,6 +11,19 @@ class SeedControl { this.serializedCtx = {}; this.lastSeedValue = null; this.node = node; + this.node.constructor.exposedActions = ['Randomize Each Time', 'Use Last Queued Seed']; + const handleAction = this.node.handleAction; + this.node.handleAction = async (action) => { + handleAction && handleAction.call(this.node, action); + if (action === 'Randomize Each Time') { + this.seedWidget.value = SPECIAL_SEED_RANDOM; + } + else if (action === 'Use Last Queued Seed') { + this.seedWidget.value = this.lastSeed; + this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL; + this.lastSeedButton.disabled = true; + } + }; this.node.properties = this.node.properties || {}; for (const [i, w] of this.node.widgets.entries()) { if (w.name === 'seed') { diff --git a/js/utils.js b/js/utils.js index e0709f2..c0f916d 100644 --- a/js/utils.js +++ b/js/utils.js @@ -5,7 +5,7 @@ api.getNodeDefs = async function () { this.dispatchEvent(new CustomEvent('fresh-node-defs', { detail: defs })); return defs; }; -var IoDirection; +export var IoDirection; (function (IoDirection) { IoDirection[IoDirection["INPUT"] = 0] = "INPUT"; IoDirection[IoDirection["OUTPUT"] = 1] = "OUTPUT"; @@ -26,11 +26,39 @@ const OPPOSITE_LABEL = { export function addMenuItem(node, _app, config) { const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions; node.prototype.getExtraMenuOptions = function (canvas, menuOptions) { + var _a; oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]); - const idx = menuOptions.findIndex(option => option === null || option === void 0 ? void 0 : option.content.includes('Shape')) + 1; - menuOptions.splice((idx > 0 ? idx : menuOptions.length - 1), 0, { + let idx = menuOptions.slice().reverse().findIndex(option => option === null || option === void 0 ? void 0 : option.isRgthree); + if (idx == -1) { + idx = menuOptions.findIndex(option => option === null || option === void 0 ? void 0 : option.content.includes('Shape')) + 1; + if (!idx) { + idx = menuOptions.length - 1; + } + menuOptions.splice(idx, 0, null); + idx++; + } + else { + idx = menuOptions.length - idx; + } + menuOptions.splice(idx, 0, { content: typeof config.name == 'function' ? config.name(this) : config.name, - callback: (_value, _options, _event, _parentMenu, _node) => { + has_submenu: !!((_a = config.subMenuOptions) === null || _a === void 0 ? void 0 : _a.length), + isRgthree: true, + callback: (_value, _options, event, parentMenu, _node) => { + var _a; + if ((_a = config.subMenuOptions) === null || _a === void 0 ? void 0 : _a.length) { + new LiteGraph.ContextMenu(config.subMenuOptions.map(option => ({ content: option })), { + event, + parentMenu, + callback: (subValue, _options, _event, _parentMenu, _node) => { + if (config.property) { + this.properties = this.properties || {}; + this.properties[config.property] = config.prepareValue ? config.prepareValue(subValue.content, this) : subValue.content; + } + config.callback && config.callback(this); + }, + }); + } if (config.property) { this.properties = this.properties || {}; this.properties[config.property] = config.prepareValue ? config.prepareValue(this.properties[config.property], this) : !this.properties[config.property]; @@ -40,35 +68,11 @@ export function addMenuItem(node, _app, config) { }); }; } -export function addMenuSubMenu(node, _app, config) { - const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions; - node.prototype.getExtraMenuOptions = function (canvas, menuOptions) { - oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]); - const idx = menuOptions.findIndex(option => option === null || option === void 0 ? void 0 : option.content.includes('Shape')) + 1; - menuOptions.splice((idx > 0 ? idx : menuOptions.length - 1), 0, { - content: typeof config.name == 'function' ? config.name(this) : config.name, - has_submenu: true, - callback: (_value, _options, event, parentMenu, _node) => { - new LiteGraph.ContextMenu(config.options.map(option => ({ content: option })), { - event, - parentMenu, - callback: (value, _options, _event, _parentMenu, _node) => { - if (config.property) { - this.properties = this.properties || {}; - this.properties[config.property] = config.prepareValue ? config.prepareValue(value.content, this) : value.content; - } - config.callback && config.callback(this); - }, - }); - } - }); - }; -} export function addConnectionLayoutSupport(node, app, options = [['Left', 'Right'], ['Right', 'Left']], callback) { - addMenuSubMenu(node, app, { + addMenuItem(node, app, { name: 'Connections Layout', property: 'connections_layout', - options: options.map(option => option[0] + (option[1] ? ' -> ' + option[1] : '')), + subMenuOptions: options.map(option => option[0] + (option[1] ? ' -> ' + option[1] : '')), prepareValue: (value, node) => { var _a; const values = value.split(' -> '); @@ -121,6 +125,11 @@ export function getConnectionPosForLayout(node, isInput, slotNumber, out) { console.log('No connection found.. weird', isInput, slotNumber); return out; } + if (cxn.hidden) { + out[0] = node.pos[0] - 100000; + out[1] = node.pos[1] - 100000; + return out; + } if (cxn.disabled) { if (cxn.color_on !== '#666665') { cxn._color_on_org = cxn._color_on_org || cxn.color_on; diff --git a/ts/base_any_input_connected_node.ts b/ts/base_any_input_connected_node.ts new file mode 100644 index 0000000..660295f --- /dev/null +++ b/ts/base_any_input_connected_node.ts @@ -0,0 +1,157 @@ +// / +// @ts-ignore +import {app} from "../../scripts/app.js"; +import { RgthreeBaseNode } from "./base_node.js"; +import type {Vector2, LLink, INodeInputSlot, INodeOutputSlot, LGraphNode as TLGraphNode, LiteGraph as TLiteGraph, IWidget} from './typings/litegraph.js'; +import { addConnectionLayoutSupport, addMenuItem, getConnectedInputNodes} from "./utils.js"; + +declare const LiteGraph: typeof TLiteGraph; + +/** + * A Virtual Node that allows any node's output to connect to it. + */ +export class BaseAnyInputConnectedNode extends RgthreeBaseNode { + + override isVirtualNode = true; + + debouncerTempWidth: number = 0; + schedulePromise: Promise | null = null; + + constructor(title = BaseAnyInputConnectedNode.title) { + super(title); + + this.addInput("", "*"); + } + + /** Schedules a promise to run a stabilization. */ + scheduleStabilizeWidgets(ms = 100) { + if (!this.schedulePromise) { + this.schedulePromise = new Promise((resolve) => { + setTimeout(() => { + this.schedulePromise = null + this.doStablization(); + resolve(); + }, ms); + }); + } + return this.schedulePromise; + } + + /** + * Ensures we have at least one empty input at the end. + */ + private stabilizeInputsOutputs() { + let hasEmptyInput = false; + for (let index = this.inputs.length - 1; index >= 0; index--) { + const input = this.inputs[index]!; + if (!input.link) { + if (index < this.inputs.length - 1) { + this.removeInput(index); + } else { + hasEmptyInput = true; + } + } + } + !hasEmptyInput && this.addInput('', '*'); + } + + + /** + * Stabilizes the node's inputs and widgets. + */ + private doStablization() { + if (!this.graph) { + return; + } + // When we add/remove widgets, litegraph is going to mess up the size, so we + // store it so we can retrieve it in computeSize. Hacky.. + (this as any)._tempWidth = this.size[0]; + + const linkedNodes = getConnectedInputNodes(app, this); + this.stabilizeInputsOutputs(); + + this.handleLinkedNodesStabilization(linkedNodes); + + app.graph.setDirtyCanvas(true, true); + + // Schedule another stabilization in the future. + this.scheduleStabilizeWidgets(500); + } + + handleLinkedNodesStabilization(linkedNodes: TLGraphNode[]) { + linkedNodes; // No-op, but makes overridding in VSCode cleaner. + throw new Error('handleLinkedNodesStabilization should be overridden.'); + } + + onConnectionsChainChange() { + this.scheduleStabilizeWidgets(); + } + + override onConnectionsChange(type: number, index: number, connected: boolean, linkInfo: LLink, ioSlot: (INodeOutputSlot | INodeInputSlot)) { + super.onConnectionsChange && super.onConnectionsChange(type, index, connected, linkInfo, ioSlot); + this.scheduleStabilizeWidgets(); + } + + override removeInput(slot: number) { + (this as any)._tempWidth = this.size[0]; + return super.removeInput(slot); + } + + override addInput(name: string, type: string|-1, extra_info?: Partial) { + (this as any)._tempWidth = this.size[0]; + return super.addInput(name, type, extra_info); + } + + override addWidget(type: T["type"], name: string, value: T["value"], callback?: T["callback"] | string, options?: T["options"]) { + (this as any)._tempWidth = this.size[0]; + return super.addWidget(type, name, value, callback, options); + } + + /** + * Guess this doesn't exist in Litegraph... + */ + override removeWidget(widgetOrSlot?: IWidget | number) { + (this as any)._tempWidth = this.size[0]; + super.removeWidget(widgetOrSlot); + } + + override computeSize(out: Vector2) { + let size = super.computeSize(out); + if ((this as any)._tempWidth) { + size[0] = (this as any)._tempWidth; + // We sometimes get repeated calls to compute size, so debounce before clearing. + this.debouncerTempWidth && clearTimeout(this.debouncerTempWidth); + this.debouncerTempWidth = setTimeout(() => { + (this as any)._tempWidth = null; + }, 32); + } + // If we're collapsed, then subtract the total calculated height of the other input slots. + if (this.properties['collapse_connections']) { + const rows = Math.max(this.inputs?.length || 0, this.outputs?.length || 0, 1) - 1; + size[1] = size[1] - (rows * LiteGraph.NODE_SLOT_HEIGHT); + } + setTimeout(() => { + app.graph.setDirtyCanvas(true, true); + }, 16); + return size; + } + + static setUp(clazz: new(...args: any[]) => T) { + // @ts-ignore: Fix incorrect litegraph typings. + addConnectionLayoutSupport(clazz, app, [['Left', 'Right'],['Right', 'Left']]); + + // @ts-ignore: Fix incorrect litegraph typings. + addMenuItem(clazz, app, { + name: (node) => (`${node.properties?.['collapse_connections'] ? 'Show' : 'Collapse'} Connections`), + property: 'collapse_connections', + prepareValue: (_value, node) => !node.properties?.['collapse_connections'], + callback: (_node) => {app.graph.setDirtyCanvas(true, true)} + }); + + + LiteGraph.registerNodeType((clazz as any).type, clazz); + (clazz as any).category = (clazz as any)._category; + } +} + + diff --git a/ts/base_node.ts b/ts/base_node.ts index cd7f341..863d11e 100644 --- a/ts/base_node.ts +++ b/ts/base_node.ts @@ -1,15 +1,19 @@ // / -// @ts-ignore -import {app} from "../../scripts/app.js"; import { NodeMode } from "./typings/comfy.js"; -import type {LGraphNode as TLGraphNode, LiteGraph as TLiteGraph} from './typings/litegraph.js'; +import type {IWidget, LGraphNode as TLGraphNode} from './typings/litegraph.js'; -declare const LiteGraph: typeof TLiteGraph; declare const LGraphNode: typeof TLGraphNode; - +/** + * A base node with standard methods, extending the LGraphNode. + */ export class RgthreeBaseNode extends LGraphNode { + /** + * Action strings that can be exposed and triggered from other nodes, like Fast Actions Button. + */ + static exposedActions: string[] = []; + static override title = "__NEED_NAME__"; // `category` seems to get reset at register, so we'll // re-reset it after the register call. ¯\_(ツ)_/¯ @@ -18,6 +22,13 @@ export class RgthreeBaseNode extends LGraphNode { isVirtualNode = true; + /** A temporary width value that can be used to ensure compute size operates correctly. */ + _tempWidth = 0; + + /** Private Mode member so we can override the setter/getter and call an `onModeChange`. */ + private mode_: NodeMode; + + constructor(title = RgthreeBaseNode.title) { super(title); if (title == '__NEED_NAME__') { @@ -26,12 +37,6 @@ export class RgthreeBaseNode extends LGraphNode { this.properties = this.properties || {}; } - mode_: NodeMode; - - /** When a mode change, we want all connected nodes to match. */ - onModeChange() { - // Override - } // @ts-ignore - Changing the property to an accessor here seems to work, but ts compiler complains. override set mode(mode: NodeMode) { @@ -39,10 +44,36 @@ export class RgthreeBaseNode extends LGraphNode { this.mode_ = mode; this.onModeChange(); } - } override get mode() { return this.mode_; } + /** When a mode change, we want all connected nodes to match. */ + onModeChange() { + // Override + } + + /** + * Given a string, do something. At the least, handle any `exposedActions` that may be called and + * passed into from other nodes, like Fast Actions Button + */ + async handleAction(action: string) { + action; // No-op. Should be overridden but OK if not. + } + + /** + * Guess this doesn't exist in Litegraph... + */ + removeWidget(widgetOrSlot?: IWidget | number) { + if (typeof widgetOrSlot === 'number') { + this.widgets.splice(widgetOrSlot, 1); + } else if (widgetOrSlot) { + const index = this.widgets.indexOf(widgetOrSlot); + if (index > -1) { + this.widgets.splice(index, 1); + } + } + } + } \ No newline at end of file diff --git a/ts/base_node_collector.ts b/ts/base_node_collector.ts index 899c467..bd0d3f1 100644 --- a/ts/base_node_collector.ts +++ b/ts/base_node_collector.ts @@ -1,14 +1,14 @@ // / // @ts-ignore import { app } from "../../scripts/app.js"; -// @ts-ignore -import { ComfyWidgets } from "../../scripts/widgets.js"; - -import type {LLink, LGraph, INodeInputSlot, INodeOutputSlot, LGraphNode as TLGraphNode} from './typings/litegraph.js'; +import type {LLink, LGraph, INodeInputSlot, INodeOutputSlot, LGraphNode} from './typings/litegraph.js'; import { RgthreeBaseNode } from "./base_node.js"; +import { getConnectedOutputNodes } from "./utils.js"; +import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js"; -declare const LGraphNode: typeof TLGraphNode; - +/** + * Base collector node that monitors changing inputs and outputs. + */ export class BaseCollectorNode extends RgthreeBaseNode { override isVirtualNode = true; @@ -24,31 +24,17 @@ export class BaseCollectorNode extends RgthreeBaseNode { return cloned; } - private updateOutputLinks(startNode: TLGraphNode = this) { - const type = (startNode.constructor as typeof TLGraphNode).type; - // @ts-ignore - if (startNode.onConnectionsChainChange) { - // @ts-ignore - startNode.onConnectionsChainChange(); - } - if (startNode === this || type?.includes('Reroute') || type?.includes('Combiner')) { - for (const output of startNode.outputs) { - if (!output.links || !output.links.length) continue; - for (const linkId of output.links) { - const link: LLink = (app.graph as LGraph).links[linkId]!; - if (!link) continue; - const targetNode: TLGraphNode = (app.graph as LGraph).getNodeById(link.target_id)!; - targetNode && this.updateOutputLinks(targetNode) - } - } - } - } - override onConnectionsChange(_type: number, _slotIndex: number, _isConnected: boolean, link_info: LLink, _ioSlot: (INodeOutputSlot | INodeInputSlot)) { if (!link_info) return; this.stabilizeInputsOutputs(); + // Follow outputs to see if we need to trigger an onConnectionChange. - this.updateOutputLinks(); + const connectedNodes = getConnectedOutputNodes(app, this); + for (const node of connectedNodes) { + if ((node as BaseAnyInputConnectedNode).onConnectionsChainChange) { + (node as BaseAnyInputConnectedNode).onConnectionsChainChange(); + } + } } private stabilizeInputsOutputs() { diff --git a/ts/base_node_mode_changer.ts b/ts/base_node_mode_changer.ts index 75b87a6..53f9d13 100644 --- a/ts/base_node_mode_changer.ts +++ b/ts/base_node_mode_changer.ts @@ -1,20 +1,17 @@ // / // @ts-ignore import {app} from "../../scripts/app.js"; -import { RgthreeBaseNode } from "./base_node.js"; -import type {Vector2, LLink, INodeInputSlot, INodeOutputSlot, LGraphNode as TLGraphNode, LiteGraph as TLiteGraph, IWidget} from './typings/litegraph.js'; -import { addConnectionLayoutSupport, addMenuItem, getConnectedInputNodes, wait } from "./utils.js"; +import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js"; +import type {LGraphNode as TLGraphNode, LiteGraph as TLiteGraph, IWidget} from './typings/litegraph.js'; +import { wait } from "./utils.js"; declare const LiteGraph: typeof TLiteGraph; declare const LGraphNode: typeof TLGraphNode; -export class BaseNodeModeChanger extends RgthreeBaseNode { +export class BaseNodeModeChanger extends BaseAnyInputConnectedNode { static collapsible = false; - override isVirtualNode = true; - debouncer: number = 0; - schedulePromise: Promise | null = null; // These Must be overriden readonly modeOn: number = -1; @@ -28,27 +25,10 @@ export class BaseNodeModeChanger extends RgthreeBaseNode { throw new Error('modeOn and modeOff must be overridden.'); } }); - this.addInput("", "*"); + this.addOutput("OPT_CONNECTION", "*"); } - scheduleStabilizeWidgets() { - if (!this.schedulePromise) { - this.schedulePromise = new Promise((resolve) => { - setTimeout(() => { - resolve(this.stabilizeWidgets()); - this.schedulePromise = null; - }, 100); - }); - } - return this.schedulePromise; - } - - stabilizeWidgets() { - if (!this.graph) { - return; - } - const linkedNodes = getConnectedInputNodes(app, this); - this.stabilizeInputsOutputs(); + override handleLinkedNodesStabilization(linkedNodes: TLGraphNode[]) { for (const [index, node] of linkedNodes.entries()) { let widget = this.widgets && this.widgets[index]; if (!widget) { @@ -60,13 +40,8 @@ export class BaseNodeModeChanger extends RgthreeBaseNode { this.setWidget(widget, node); } if (this.widgets && this.widgets.length > linkedNodes.length) { - // When we remove widgets, litegraph is going to mess up the size, so we - // store it so we can retrieve it in computeSize. Hacky.. - (this as any)._tempWidth = this.size[0]; this.widgets.length = linkedNodes.length } - app.graph.setDirtyCanvas(true, true); - setTimeout(() => { this.stabilizeWidgets(); }, 500); } setWidget(widget: IWidget, linkedNode: TLGraphNode) { @@ -74,83 +49,26 @@ export class BaseNodeModeChanger extends RgthreeBaseNode { widget.name = `Enable ${linkedNode.title}`; widget.options = {'on': 'yes', 'off': 'no'} widget.value = !off; - widget.callback = () => { - const off = linkedNode.mode === this.modeOff; + (widget as any).doModeChange = (force?: boolean) => { + let off = force == null ? linkedNode.mode === this.modeOff : force; linkedNode.mode = (off ? this.modeOn : this.modeOff) as 1 | 2 | 3 | 4; widget!.value = off; } - } - - - onConnectionsChainChange() { - this.scheduleStabilizeWidgets(); - } - - override onConnectionsChange(_type: number, _index: number, _connected: boolean, _linkInfo: LLink, _ioSlot: (INodeOutputSlot | INodeInputSlot)) { - this.scheduleStabilizeWidgets(); - } - - override removeInput(slot: number) { - (this as any)._tempWidth = this.size[0]; - return super.removeInput(slot); - } - override addInput(name: string, type: string|-1, extra_info?: Partial) { - (this as any)._tempWidth = this.size[0]; - return super.addInput(name, type, extra_info); - } - - private stabilizeInputsOutputs() { - let hasEmptyInput = false; - for (let index = this.inputs.length - 1; index >= 0; index--) { - const input = this.inputs[index]!; - if (!input.link) { - if (index < this.inputs.length - 1) { - this.removeInput(index); - } else { - hasEmptyInput = true; - } - } + widget.callback = () => { + (widget as any).doModeChange(); } - !hasEmptyInput && this.addInput('', '*'); } - override computeSize(out: Vector2) { - let size = super.computeSize(out); - if ((this as any)._tempWidth) { - size[0] = (this as any)._tempWidth; - (this as any)._tempWidth = null; - } - // If we're collapsed, then subtract the total calculated height of the other input slots. - if (this.properties['collapse_connections']) { - const rows = Math.max(this.inputs?.length || 0, this.outputs?.length || 0, 1) - 1; - size[1] = size[1] - (rows * LiteGraph.NODE_SLOT_HEIGHT); - } - setTimeout(() => { - app.graph.setDirtyCanvas(true, true); - }, 16); - return size; + forceWidgetOff(widget: IWidget) { + (widget as any).doModeChange(false); + } + forceWidgetOn(widget: IWidget) { + (widget as any).doModeChange(true); } - static setUp(clazz: new(...args: any[]) => T) { - // @ts-ignore: Fix incorrect litegraph typings. - addMenuItem(clazz, app, { - name: 'Refresh', - callback: (node) => {(node as T).scheduleStabilizeWidgets()} - }); - // @ts-ignore: Fix incorrect litegraph typings. - addMenuItem(clazz, app, { - name: (node) => (`${node.properties?.['collapse_connections'] ? 'Show' : 'Collapse'} Connections`), - property: 'collapse_connections', - prepareValue: (_value, node) => !node.properties?.['collapse_connections'], - callback: (_node) => {app.graph.setDirtyCanvas(true, true)} - }); - - // @ts-ignore: Fix incorrect litegraph typings. - addConnectionLayoutSupport(clazz, app, [['Left'],['Right']]); - - LiteGraph.registerNodeType((clazz as any).type, clazz); - (clazz as any).category = (clazz as any)._category; + static override setUp(clazz: new(...args: any[]) => T) { + BaseAnyInputConnectedNode.setUp(clazz); } } diff --git a/ts/base_power_prompt.ts b/ts/base_power_prompt.ts new file mode 100644 index 0000000..9e67fb2 --- /dev/null +++ b/ts/base_power_prompt.ts @@ -0,0 +1,263 @@ +// / +// @ts-ignore +import {app} from '../../scripts/app.js'; +// @ts-ignore +import {api} from '../../scripts/api.js'; +// @ts-ignore +import { ComfyWidgets } from '../../scripts/widgets.js'; +import type {LLink, IComboWidget, LGraphNode as TLGraphNode, LiteGraph as TLiteGraph, INodeOutputSlot, INodeInputSlot, IWidget} from './typings/litegraph.js'; +import type {ComfyObjectInfo, ComfyGraphNode} from './typings/comfy.js' +import {wait} from './utils.js'; + +declare const LiteGraph: typeof TLiteGraph; +declare const LGraphNode: typeof TLGraphNode; + +/** 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; + + constructor(node: ComfyGraphNode, nodeData: ComfyObjectInfo) { + this.node = node; + this.node.properties = this.node.properties || {}; + + this.nodeData = nodeData; + this.isSimple = this.nodeData.name.includes('Simple'); + + this.promptEl = (node.widgets[0]! as any).inputEl; + this.addAndHandleKeyboardLoraEditWeight(); + + this.patchNodeRefresh(); + + 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: TLGraphNode, outputIndex: number) => { + let canConnect = true; + if (oldOnConnectInput) { + canConnect = oldOnConnectInput.apply(this.node, [inputIndex, outputType, outputSlot, outputNode,outputIndex]); + } + return canConnect && !this.node.inputs[inputIndex]!.disabled; + } + + const oldOnConnectOutput = this.node.onConnectOutput; + this.node.onConnectOutput = (outputIndex: number, inputType: INodeInputSlot["type"], inputSlot: INodeInputSlot, inputNode: TLGraphNode, inputIndex: number) => { + let canConnect = true; + if (oldOnConnectOutput) { + canConnect = oldOnConnectOutput?.apply(this.node, [outputIndex, inputType, inputSlot, inputNode, inputIndex]); + } + return canConnect && !this.node.outputs[outputIndex]!.disabled; + } + + // 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 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_'); + } + + refreshCombos(nodeData: ComfyObjectInfo) { + + this.nodeData = nodeData; + // Add the combo for hidden inputs of nodeData + let data = this.nodeData.input?.optional || {}; + data = Object.assign(data, this.nodeData.input?.hidden || {}); + + for (const [key, value] of Object.entries(data)) {//Object.entries(this.nodeData.input?.hidden || {})) { + if (Array.isArray(value[0])) { + const values = value[0] as string[]; + if (key.startsWith('insert')) { + 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 ? .01 : .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); + const oldNodeRemoved = this.node.onRemoved; + this.node.onRemoved = () => { + oldNodeRemoved?.call(this.node); + api.removeEventListener('fresh-node-defs', this.boundOnFreshNodeDefs); + } + } +} \ No newline at end of file diff --git a/ts/bypasser.ts b/ts/bypasser.ts index 4223aea..6dcbe80 100644 --- a/ts/bypasser.ts +++ b/ts/bypasser.ts @@ -10,6 +10,8 @@ const MODE_ALWAYS = 0; class BypasserNode extends BaseNodeModeChanger { + static override exposedActions = ['Bypass all', 'Enable all']; + static override type = NodeTypesString.FAST_BYPASSER; static override title = NodeTypesString.FAST_BYPASSER; override readonly modeOn = MODE_ALWAYS; @@ -18,6 +20,19 @@ class BypasserNode extends BaseNodeModeChanger { constructor(title = BypasserNode.title) { super(title); } + + + override async handleAction(action: string) { + if (action === 'Bypass all') { + for (const widget of this.widgets) { + this.forceWidgetOff(widget); + } + } else if (action === 'Enable all') { + for (const widget of this.widgets) { + this.forceWidgetOn(widget); + } + } + } } app.registerExtension({ diff --git a/ts/constants.ts b/ts/constants.ts index 3944dfd..3927bc7 100644 --- a/ts/constants.ts +++ b/ts/constants.ts @@ -12,6 +12,6 @@ export const NodeTypesString = { NODE_MODE_REPEATER: addRgthree('Mute / Bypass Repeater'), FAST_MUTER: addRgthree('Fast Muter'), FAST_BYPASSER: addRgthree('Fast Bypasser'), - FAST_BUTTON_ACTION: addRgthree('Fast Button Action'), + FAST_ACTIONS_BUTTON: addRgthree('Fast Actions Button'), NODE_COLLECTOR: addRgthree('Node Collector'), } \ No newline at end of file diff --git a/ts/display_int.ts b/ts/display_int.ts index f994ff2..899b8f2 100644 --- a/ts/display_int.ts +++ b/ts/display_int.ts @@ -4,7 +4,7 @@ import {app} from "../../scripts/app.js"; // @ts-ignore import { ComfyWidgets } from "../../scripts/widgets.js"; import type {SerializedLGraphNode, LGraphNode as TLGraphNode, LiteGraph as TLiteGraph} from './typings/litegraph.js'; -import type {ComfyApp, ComfyObjectInfo, ComfyWidget} from './typings/comfy.js' +import type {ComfyApp, ComfyObjectInfo} from './typings/comfy.js' import { addConnectionLayoutSupport } from "./utils.js"; declare const LiteGraph: typeof TLiteGraph; @@ -36,7 +36,7 @@ app.registerExtension({ const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { onExecuted?.apply(this, [message]); - (this as any).showValueWidget?.value = message.text[0]; + (this as any).showValueWidget.value = message.text[0]; }; } }, diff --git a/ts/fast_actions_button.ts b/ts/fast_actions_button.ts new file mode 100644 index 0000000..9ef33fb --- /dev/null +++ b/ts/fast_actions_button.ts @@ -0,0 +1,308 @@ +// / +// @ts-ignore +import {app} from "../../scripts/app.js"; +import { BaseAnyInputConnectedNode } from "./base_any_input_connected_node.js"; +import { RgthreeBaseNode } from "./base_node.js"; +import { NodeTypesString } from "./constants.js"; +import { ComfyApp, ComfyWidget } from "./typings/comfy.js"; +import type {IWidget, LGraph, LGraphNode, SerializedLGraphNode} from './typings/litegraph.js'; + +const MODE_ALWAYS = 0; +const MODE_MUTE = 2; +const MODE_BYPASS = 4; + +/** + * The Fast Actions Button. + * + * This adds a button that the user can connect any node to and then choose an action to take on + * that node when the button is pressed. Default actions are "Mute," "Bypass," and "Enable," but + * Nodes can expose actions additional actions that can then be called back. + */ +class FastActionsButton extends BaseAnyInputConnectedNode { + + static override type = NodeTypesString.FAST_ACTIONS_BUTTON; + static override title = NodeTypesString.FAST_ACTIONS_BUTTON; + + static '@buttonText' = {type: 'string'}; + static '@shortcutModifier' = {type: 'combo', values: ['ctrl', 'alt', 'shift']}; + static '@shortcutKey' = {type: 'string'}; + + static collapsible = false; + + override readonly isVirtualNode = true; + + override serialize_widgets = true; + + readonly buttonWidget: IWidget; + + readonly widgetToData = new Map(); + readonly nodeIdtoFunctionCache = new Map(); + + readonly keypressBound; + readonly keyupBound; + + private executingFromShortcut = false; + + constructor(title?: string) { + super(title); + this.properties['buttonText'] = '🎬 Action!'; + this.properties['shortcutModifier'] = 'alt'; + this.properties['shortcutKey'] = ''; + this.buttonWidget = this.addWidget('button', this.properties['buttonText'], null, () => { + this.executeConnectedNodes(); + }, {serialize: false}); + + this.keypressBound = this.onKeypress.bind(this); + this.keyupBound = this.onKeyup.bind(this); + } + + + /** When we're given data to configure, like from a PNG or JSON. */ + override configure(info: SerializedLGraphNode): void { + super.configure(info); + // Since we add the widgets dynamically, we need to wait to set their values + // with a short timeout. + setTimeout(() => { + if (info.widgets_values) { + for (let [index, value] of info.widgets_values.entries()) { + if (index > 0) { + if (value.startsWith('comfy_action:')) { + this.addComfyActionWidget(index); + value = value.replace('comfy_action:', ''); + } + if (this.widgets[index]) { + this.widgets[index]!.value = value; + } + } + } + } + }, 100); + } + + override clone() { + const cloned = super.clone(); + cloned.properties['buttonText'] = '🎬 Action!'; + cloned.properties['shortcutKey'] = ''; + return cloned; + } + + override onAdded(graph: LGraph): void { + window.addEventListener('keydown', this.keypressBound); + window.addEventListener('keyup', this.keyupBound); + } + + override onRemoved(): void { + window.removeEventListener('keydown', this.keypressBound); + window.removeEventListener('keyup', this.keyupBound); + } + + + async onKeypress(event: KeyboardEvent) { + const target = (event.target as HTMLElement)!; + if (this.executingFromShortcut || target.localName == "input" || target.localName == "textarea") { + return; + } + if (this.properties['shortcutKey'].trim() && this.properties['shortcutKey'].toLowerCase() === event.key.toLowerCase()) { + let good = this.properties['shortcutModifier'] !== 'ctrl' || event.ctrlKey; + good = good && this.properties['shortcutModifier'] !== 'alt' || event.altKey; + good = good && this.properties['shortcutModifier'] !== 'shift' || event.shiftKey; + good = good && this.properties['shortcutModifier'] !== 'meta' || event.metaKey; + if (good) { + setTimeout(() => { + this.executeConnectedNodes(); + }, 20); + this.executingFromShortcut = true; + event.preventDefault(); + event.stopImmediatePropagation(); + app.canvas.dirty_canvas = true; + return false; + } + } + return; + } + + onKeyup(event: KeyboardEvent) { + const target = (event.target as HTMLElement)!; + if (target.localName == "input" || target.localName == "textarea") { + return; + } + this.executingFromShortcut = false; + } + + + override onPropertyChanged(property: string, value: any, _prevValue: any): boolean | void { + if (property == 'buttonText') { + this.buttonWidget.name = value; + } + if (property == 'shortcutKey') { + value = value.trim(); + this.properties['shortcutKey'] = value && value[0].toLowerCase() || ''; + } + } + + override handleLinkedNodesStabilization(linkedNodes: LGraphNode[]) { + // Remove any widgets that are no longe linked; + // const deleteWidgets: IWidget[] = []; + // for (const [widget, data] of this.widgetToData.entries()) { + // if (!data.node) { + // continue; + // } + // if (!linkedNodes.includes(data.node)) { + // const index = this.widgets.indexOf(widget); + // if (index > -1) { + // deleteWidgets.push(widget); + // } else { + // console.warn('Had a connected widget that is not in widgets... weird.'); + // } + // } + // } + // deleteWidgets.forEach(w=>this.removeWidget(w)); + + let indexOffset = 1; // Start with button, increment when we hit a non-node widget (like comfy) + for (const [index, node] of linkedNodes.entries()) { + let widgetAtSlot = this.widgets[index + indexOffset]; + if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) { + indexOffset++; + widgetAtSlot = this.widgets[index + indexOffset]; + } + + if (!widgetAtSlot || this.widgetToData.get(widgetAtSlot)!.node !== node) { + // Find the next widget that matches the node. + let widget: IWidget|null = null; + for (let i = index + indexOffset; i < this.widgets.length; i++) { + if (this.widgetToData.get(this.widgets[i]!)!.node === node) { + widget = this.widgets.splice(i, 1)[0]!; + this.widgets.splice(index + indexOffset, 0, widget) + break; + } + } + if (!widget) { + // Add a widget at this spot. + const exposedActions: string[] = (node.constructor as any).exposedActions || []; + widget = this.addWidget('combo', node.title, 'None', '', {values: ['None', 'Mute', 'Bypass', 'Enable', ...exposedActions]}); + (widget as ComfyWidget).serializeValue = async (_node: SerializedLGraphNode, _index: number) => { + return widget?.value; + } + this.widgetToData.set(widget, {node}) + } + } + } + + // Go backwards through widgets, and remove any that are not in out widgetToData + for (let i = this.widgets.length - 1; i > linkedNodes.length + indexOffset - 1; i--) { + const widgetAtSlot = this.widgets[i]; + if (widgetAtSlot && this.widgetToData.get(widgetAtSlot)?.comfy) { + continue; + } + this.removeWidget(widgetAtSlot); + } + } + + override removeWidget(widgetOrSlot?: number|IWidget): void { + const widget = typeof widgetOrSlot === 'number' ? this.widgets[widgetOrSlot] : widgetOrSlot; + if (widget && this.widgetToData.has(widget)) { + this.widgetToData.delete(widget); + } + super.removeWidget(widgetOrSlot); + } + + /** + * Runs through the widgets, and executes the actions. + */ + async executeConnectedNodes() { + for (const widget of this.widgets) { + if (widget == this.buttonWidget) { + continue; + } + const action = widget.value; + const {comfy, node} = this.widgetToData.get(widget) ?? {}; + if (comfy) { + if (action === 'Queue Prompt') { + await comfy.queuePrompt(); + } + continue; + } + if (node) { + if (action === 'Mute') { + node.mode = MODE_MUTE; + } else if (action === 'Bypass') { + node.mode = MODE_BYPASS; + } else if (action === 'Enable') { + node.mode = MODE_ALWAYS; + } + // If there's a handleAction, always call it. + if ((node as RgthreeBaseNode).handleAction) { + await (node as RgthreeBaseNode).handleAction(action); + } + app.graph.change(); + continue; + } + console.warn('Fast Actions Button has a widget without correct data.') + } + } + + /** + * Adds a ComfyActionWidget at the provided slot (or end). + */ + addComfyActionWidget(slot?: number) { + let widget = this.addWidget('combo', 'Comfy Action', 'None', () => { + if (widget.value.startsWith('MOVE ')) { + this.widgets.push(this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!); + widget.value = (widget as any)['lastValue_']; + } else if (widget.value.startsWith('REMOVE ')) { + this.removeWidget(widget); + } + (widget as any)['lastValue_'] = widget.value; + }, { + values: ['None', 'Queue Prompt', 'REMOVE Comfy Action', 'MOVE to end'] + }); + (widget as any)['lastValue_'] = 'None'; + + (widget as ComfyWidget).serializeValue = async (_node: SerializedLGraphNode, _index: number) => { + return `comfy_app:${widget?.value}`; + } + this.widgetToData.set(widget, {comfy: app}); + + if (slot != null) { + this.widgets.splice(slot, 0, this.widgets.splice(this.widgets.indexOf(widget), 1)[0]!); + } + return widget; + } + + override onSerialize(o: SerializedLGraphNode) { + super.onSerialize && super.onSerialize(o); + for (let [index, value] of (o.widgets_values || []).entries()) { + if (this.widgets[index]?.name === 'Comfy Action') { + o.widgets_values![index] = `comfy_action:${value}`; + } + } + } + + + static override setUp(clazz: new(...args: any[]) => T) { + BaseAnyInputConnectedNode.setUp(clazz); + + // @ts-ignore: Fix incorrect litegraph typings. + addMenuItem(clazz, app, { + name: '➕ Append a Comfy Action', + callback: (nodeArg: LGraphNode) => { + (nodeArg as FastActionsButton).addComfyActionWidget(); + } + }); + + } +} + + + +app.registerExtension({ + name: "rgthree.FastButtonAction", + registerCustomNodes() { + FastActionsButton.setUp(FastActionsButton); + }, + loadedGraphNode(node: LGraphNode) { + if (node.type == FastActionsButton.title) { + (node as FastActionsButton)._tempWidth = node.size[0]; + } + } +}); \ No newline at end of file diff --git a/ts/muter.ts b/ts/muter.ts index 955b4ba..758c672 100644 --- a/ts/muter.ts +++ b/ts/muter.ts @@ -10,6 +10,8 @@ const MODE_ALWAYS = 0; class MuterNode extends BaseNodeModeChanger { + static override exposedActions = ['Mute all', 'Enable all']; + static override type = NodeTypesString.FAST_MUTER; static override title = NodeTypesString.FAST_MUTER; override readonly modeOn = MODE_ALWAYS; @@ -18,6 +20,18 @@ class MuterNode extends BaseNodeModeChanger { constructor(title = MuterNode.title) { super(title); } + + override async handleAction(action: string) { + if (action === 'Mute all') { + for (const widget of this.widgets) { + this.forceWidgetOff(widget); + } + } else if (action === 'Enable all') { + for (const widget of this.widgets) { + this.forceWidgetOn(widget); + } + } + } } app.registerExtension({ diff --git a/ts/node_collector.ts b/ts/node_collector.ts index 2253ab9..b11107b 100644 --- a/ts/node_collector.ts +++ b/ts/node_collector.ts @@ -12,20 +12,22 @@ import { NodeTypesString } from "./constants.js"; declare const LiteGraph: typeof TLiteGraph; -/** Legacy "Combiner" */ +/** + * The Collector Node. Takes any number of inputs as connections for nodes and collects them into + * one outputs. The next node will decide what to do with them. + * + * Currently only works with the Fast Muter, Fast Bypasser, and Fast Actions Button. + */ class CollectorNode extends BaseCollectorNode { static override type = NodeTypesString.NODE_COLLECTOR; static override title = NodeTypesString.NODE_COLLECTOR; - - static legacyType = "Node Combiner (rgthree)"; - } /** Legacy "Combiner" */ class CombinerNode extends CollectorNode { - static override legacyType = "Node Combiner (rgthree)"; + static legacyType = "Node Combiner (rgthree)"; static override title = "‼️ Node Combiner [DEPRECATED]"; constructor(title = CombinerNode.title) { @@ -64,7 +66,7 @@ class CombinerNode extends CollectorNode { * Updates a Node Combiner to a Node Collector. */ async function updateCombinerToCollector(node: TLGraphNode) { - if (node.type === CollectorNode.legacyType) { + if (node.type === CombinerNode.legacyType) { // Create a new CollectorNode. const newNode = new CollectorNode(); if (node.title != CombinerNode.title) { diff --git a/ts/node_mode_relay.ts b/ts/node_mode_relay.ts index a5fe3fc..41050fa 100644 --- a/ts/node_mode_relay.ts +++ b/ts/node_mode_relay.ts @@ -2,12 +2,9 @@ // @ts-ignore import { app } from "../../scripts/app.js"; import type {INodeInputSlot, INodeOutputSlot, LGraphNode, LLink, LiteGraph as TLiteGraph,} from './typings/litegraph.js'; +import type { NodeMode } from "./typings/comfy.js"; import { addConnectionLayoutSupport, addHelp, getConnectedInputNodes, getConnectedOutputNodes, wait} from "./utils.js"; -// @ts-ignore -import { ComfyWidgets } from "../../scripts/widgets.js"; -// @ts-ignore import { BaseCollectorNode } from './base_node_collector.js'; -import { NodeMode } from "./typings/comfy.js"; import { NodeTypesString, stripRgthree } from "./constants.js"; declare const LiteGraph: typeof TLiteGraph; @@ -17,7 +14,10 @@ const MODE_MUTE = 2; const MODE_BYPASS = 4; const MODE_REPEATS = [MODE_MUTE, MODE_BYPASS]; - +/** + * Like a BaseCollectorNode, this relay node connects to a Repeater and changes it mode (so it can go + * on to mute it's connections). + */ class NodeModeRelay extends BaseCollectorNode { static override type = NodeTypesString.NODE_MODE_RELAY; @@ -36,6 +36,7 @@ class NodeModeRelay extends BaseCollectorNode { super(title); setTimeout(() => { this.stabilize(); }, 500); + // We want to customize the output, so remove the one BaseCollectorNode adds, and add out own. this.removeOutput(0); this.addOutput('REPEATER', '_NODE_REPEATER_', { color_on: '#Fc0', @@ -102,8 +103,8 @@ app.registerExtension({ name: "rgthree.NodeModeRepeaterHelper", registerCustomNodes() { - addHelp(NodeModeRelay, app); addConnectionLayoutSupport(NodeModeRelay, app, [['Left','Right'],['Right','Left']]); + addHelp(NodeModeRelay, app); LiteGraph.registerNodeType(NodeModeRelay.type, NodeModeRelay); NodeModeRelay.category = NodeModeRelay._category; diff --git a/ts/node_mode_repeater.ts b/ts/node_mode_repeater.ts index 289b9fb..4a747ed 100644 --- a/ts/node_mode_repeater.ts +++ b/ts/node_mode_repeater.ts @@ -36,7 +36,7 @@ class NodeModeRepeater extends BaseCollectorNode { constructor(title?: string) { super(title); this.removeOutput(0); - this.addOutput('FAST_TOGGLER', '_FAST_TOGGLER_', { + this.addOutput('OPT_CONNECTION', '*', { color_on: '#Fc0', color_off: '#a80', }); @@ -48,9 +48,9 @@ class NodeModeRepeater extends BaseCollectorNode { if (super.onConnectOutput) { canConnect = canConnect && super.onConnectOutput?.(outputIndex, inputType, inputSlot, inputNode, inputIndex); } - // Output can only connect to a FAST MUTER or FAST BYPASSER + // Output can only connect to a FAST MUTER, FAST BYPASSER, NODE_COLLECTOR OR ACTION BUTTON let nextNode = getConnectedOutputNodes(app, this, inputNode)[0] || inputNode; - return canConnect && (nextNode.type === NodeTypesString.FAST_MUTER || nextNode.type === NodeTypesString.FAST_BYPASSER); + return canConnect && [NodeTypesString.FAST_MUTER, NodeTypesString.FAST_BYPASSER, NodeTypesString.NODE_COLLECTOR, NodeTypesString.FAST_ACTIONS_BUTTON].includes(nextNode.type || ''); } @@ -62,7 +62,7 @@ class NodeModeRepeater extends BaseCollectorNode { } // Output can only connect to a FAST MUTER or FAST BYPASSER let nextNode = getConnectedOutputNodes(app, this, outputNode)[0] || outputNode; - const isNextNodeRelay = nextNode.type === NodeTypesString.NODE_MODE_RELAY + const isNextNodeRelay = nextNode.type === NodeTypesString.NODE_MODE_RELAY; return canConnect && (!isNextNodeRelay || !this.hasTogglerOutput); } @@ -110,7 +110,7 @@ class NodeModeRepeater extends BaseCollectorNode { this.removeOutput(0); } } else if (!this.outputs[0]) { - this.addOutput('FAST_TOGGLER', '_FAST_TOGGLER_', { + this.addOutput('OPT_CONNECTION', '*', { color_on: '#Fc0', color_off: '#a80', }); @@ -134,8 +134,8 @@ app.registerExtension({ name: "rgthree.NodeModeRepeater", registerCustomNodes() { - addHelp(NodeModeRepeater, app); addConnectionLayoutSupport(NodeModeRepeater, app, [['Left','Right'],['Right','Left']]); + addHelp(NodeModeRepeater, app); LiteGraph.registerNodeType(NodeModeRepeater.type, NodeModeRepeater); NodeModeRepeater.category = NodeModeRepeater._category; diff --git a/ts/power_prompt.ts b/ts/power_prompt.ts index 5bf33c9..8a938cc 100644 --- a/ts/power_prompt.ts +++ b/ts/power_prompt.ts @@ -1,300 +1,25 @@ // / // @ts-ignore import {app} from '../../scripts/app.js'; -// @ts-ignore -import {api} from '../../scripts/api.js'; -// @ts-ignore -import { ComfyWidgets } from '../../scripts/widgets.js'; -import type {LLink, IComboWidget, LGraphNode as TLGraphNode, LiteGraph as TLiteGraph, INodeOutputSlot, INodeInputSlot} from './typings/litegraph.js'; +import type {LGraphNode as TLGraphNode, LiteGraph as TLiteGraph} from './typings/litegraph.js'; import type {ComfyApp, ComfyObjectInfo, ComfyGraphNode} from './typings/comfy.js' -import {addConnectionLayoutSupport, wait} from './utils.js'; +import {addConnectionLayoutSupport} from './utils.js'; +import { PowerPrompt } from './base_power_prompt.js'; declare const LiteGraph: typeof TLiteGraph; declare const LGraphNode: typeof TLGraphNode; -/** Wraps a node instance keeping closure without mucking the finicky types. */ -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; - - constructor(node: ComfyGraphNode, nodeData: ComfyObjectInfo) { - this.node = node; - this.node.properties = this.node.properties || {}; - - this.nodeData = nodeData; - this.isSimple = this.nodeData.name.includes('Simple'); - - this.promptEl = (node.widgets[0]! as any).inputEl; - this.addAndHandleKeyboardLoraEditWeight(); - - this.patchNodeRefresh(); - - 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: TLGraphNode, outputIndex: number) => { - let canConnect = true; - if (oldOnConnectInput) { - canConnect = oldOnConnectInput.apply(this.node, [inputIndex, outputType, outputSlot, outputNode,outputIndex]); - } - return canConnect && !this.node.inputs[inputIndex]!.disabled; - } - - const oldOnConnectOutput = this.node.onConnectOutput; - this.node.onConnectOutput = (outputIndex: number, inputType: INodeInputSlot["type"], inputSlot: INodeInputSlot, inputNode: TLGraphNode, inputIndex: number) => { - let canConnect = true; - if (oldOnConnectOutput) { - canConnect = oldOnConnectOutput?.apply(this.node, [outputIndex, inputType, inputSlot, inputNode, inputIndex]); - } - return canConnect && !this.node.outputs[outputIndex]!.disabled; - } - - // Strip all widgets but prompt (we'll re-add them in refreshCombos) - this.node.widgets.splice(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 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]); - } - - findAndPatchCombos() { - // for (const widget of this.node.widgets) { - // if (widget.type === 'combo' && widget.name!.startsWith('insert_')) { - // widget.callback = (selected) => this.onPromptComboCallback(widget as IComboWidget, selected); - // if (widget.options.values.length === 1) { - // widget.disabled = true; - // } - // // Override comput size so we can add some padding after the last widget. Not sure why it's - // // funky, perhaps the multiline text area. - // (widget as any).oldComputeSize = widget.computeSize; - // let node = this.node; - // widget.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; - // }; - // } - // } - } - - refreshCombos(nodeData: ComfyObjectInfo) { - - this.nodeData = nodeData; - // Add the combo for hidden inputs of nodeData - let data = this.nodeData.input?.optional || {}; - data = Object.assign(data, this.nodeData.input?.hidden || {}); - - for (const [key, value] of Object.entries(data)) {//Object.entries(this.nodeData.input?.hidden || {})) { - if (Array.isArray(value[0])) { - const values = value[0] as string[]; - if (key.startsWith('insert')) { - 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 ? .01 : .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); - const oldNodeRemoved = this.node.onRemoved; - this.node.onRemoved = () => { - oldNodeRemoved?.call(this.node); - api.removeEventListener('fresh-node-defs', this.boundOnFreshNodeDefs); - } - } -} - let nodeData: ComfyObjectInfo | null = null; app.registerExtension({ name: 'rgthree.PowerPrompt', async beforeRegisterNodeDef(nodeType: typeof LGraphNode, passedNodeData: ComfyObjectInfo, _app: ComfyApp) { - if (passedNodeData.name.startsWith('Power Prompt') && passedNodeData.name.includes('rgthree')) { + if (passedNodeData.name.includes('Power Prompt') && passedNodeData.name.includes('rgthree')) { nodeData = passedNodeData; - const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { onNodeCreated ? onNodeCreated.apply(this, []) : undefined; (this as any).powerPrompt = new PowerPrompt(this as ComfyGraphNode, passedNodeData); } - - // This won't actually work until such a thing exists in app.js#refreshComboInNodes - // @ts-ignore - // nodeType.prototype.onRefreshCombos = function (newNodeData: any) { - // (this as any).powerPrompt.refreshCombos(newNodeData); - // } - - // This isn't super useful, because R->L removes the names in order to work with - // litegraph's hardcoded L->R math.. but, ¯\_(ツ)_/¯ addConnectionLayoutSupport(nodeType, app, [['Left', 'Right'], ['Right', 'Left']]); } }, @@ -320,4 +45,4 @@ app.registerExtension({ }, 50) } } -}); \ No newline at end of file +}); diff --git a/ts/reroute.ts b/ts/reroute.ts index 00b38a2..652fcef 100644 --- a/ts/reroute.ts +++ b/ts/reroute.ts @@ -2,7 +2,7 @@ // @ts-ignore import { app } from "../../scripts/app.js"; import type {Vector2, LLink, LGraphCanvas as TLGraphCanvas, LGraph, SerializedLGraphNode, INodeInputSlot, INodeOutputSlot, LGraphNode as TLGraphNode, LiteGraph as TLiteGraph} from './typings/litegraph.js'; -import { addConnectionLayoutSupport, addMenuSubMenu } from "./utils.js"; +import { addConnectionLayoutSupport, addMenuItem } from "./utils.js"; declare const LiteGraph: typeof TLiteGraph; declare const LGraphNode: typeof TLGraphNode; @@ -69,7 +69,10 @@ app.registerExtension({ } } } + this.stabilize(); + } + stabilize() { // Find root input let currentNode: TLGraphNode|null = this; let updateNodes = []; @@ -196,27 +199,11 @@ app.registerExtension({ ["Bottom","Top"], ], (node) => {(node as RerouteNode).applyNodeSize();}); - // @ts-ignore: Fix incorrect litegraph typings. - addMenuSubMenu(RerouteNode, app, { - name: 'Height', - property: 'size', - options: (() => { - const options = []; - for (let w = 8; w > 0; w--) { - options.push(`${w * 10}`); - } - return options; - })(), - prepareValue: (value, node) => [node.size[0], Number(value)], - callback: (node) => (node as RerouteNode).applyNodeSize() - }); - - // @ts-ignore: Fix incorrect litegraph typings. - addMenuSubMenu(RerouteNode, app, { + addMenuItem(RerouteNode, app, { name: 'Width', property: 'size', - options: (() => { + subMenuOptions: (() => { const options = []; for (let w = 8; w > 0; w--) { options.push(`${w * 10}`); @@ -228,6 +215,22 @@ app.registerExtension({ }); + // @ts-ignore: Fix incorrect litegraph typings. + addMenuItem(RerouteNode, app, { + name: 'Height', + property: 'size', + subMenuOptions: (() => { + const options = []; + for (let w = 8; w > 0; w--) { + options.push(`${w * 10}`); + } + return options; + })(), + prepareValue: (value, node) => [node.size[0], Number(value)], + callback: (node) => (node as RerouteNode).applyNodeSize() + }); + + LiteGraph.registerNodeType(RerouteNode.title, RerouteNode); RerouteNode.category = RerouteNode._category; }, diff --git a/ts/seed.ts b/ts/seed.ts index 5487e93..127e7e5 100644 --- a/ts/seed.ts +++ b/ts/seed.ts @@ -5,6 +5,7 @@ import {app} from "../../scripts/app.js"; import { ComfyWidgets } from "../../scripts/widgets.js"; import type {SerializedLGraphNode, ContextMenuItem, IContextMenuOptions, ContextMenu, LGraphNode as TLGraphNode, LiteGraph as TLiteGraph, IWidget} from './typings/litegraph.js'; import type {ComfyApp, ComfyObjectInfo, ComfyWidget, ComfyGraphNode} from './typings/comfy.js' +import { RgthreeBaseNode } from "./base_node.js"; declare const LiteGraph: typeof TLiteGraph; declare const LGraphNode: typeof TLGraphNode; @@ -33,7 +34,22 @@ class SeedControl { lastSeedValue: ComfyWidget|null = null; constructor(node: ComfyGraphNode) { + this.node = node; + + (this.node.constructor as any).exposedActions = ['Randomize Each Time', 'Use Last Queued Seed']; + const handleAction = (this.node as RgthreeBaseNode).handleAction; + (this.node as RgthreeBaseNode).handleAction = async (action: string) => { + handleAction && handleAction.call(this.node, action); + if (action === 'Randomize Each Time') { + this.seedWidget.value = SPECIAL_SEED_RANDOM; + } else if (action === 'Use Last Queued Seed') { + this.seedWidget.value = this.lastSeed; + this.lastSeedButton.name = LAST_SEED_BUTTON_LABEL; + this.lastSeedButton.disabled = true; + } + } + this.node.properties = this.node.properties || {}; // Grab the already available widgets, and remove the built-in control_after_generate diff --git a/ts/utils.ts b/ts/utils.ts index 8e2c635..4b52701 100644 --- a/ts/utils.ts +++ b/ts/utils.ts @@ -18,7 +18,7 @@ api.getNodeDefs = async function() { declare const LGraphNode: typeof TLGraphNode; declare const LiteGraph: typeof TLiteGraph; -enum IoDirection { +export enum IoDirection { INPUT, OUTPUT, } @@ -44,63 +44,63 @@ interface MenuConfig { property?: string; prepareValue?: (value: string, node: TLGraphNode) => any; callback?: (node: TLGraphNode) => void; -} - -interface SubMenuConfig extends MenuConfig { - options: string[], + subMenuOptions?: string[]; } export function addMenuItem(node: typeof LGraphNode, _app: ComfyApp, config: MenuConfig) { const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions; node.prototype.getExtraMenuOptions = function(canvas: TLGraphCanvas, menuOptions: ContextMenuItem[]) { oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]); - const idx = menuOptions.findIndex(option => option?.content.includes('Shape')) + 1; - menuOptions.splice((idx > 0 ? idx : menuOptions.length - 1), 0, { + + let idx = menuOptions.slice().reverse().findIndex(option => (option as any)?.isRgthree); + if (idx == -1) { + idx = menuOptions.findIndex(option => option?.content.includes('Shape')) + 1; + if (!idx) { + idx = menuOptions.length - 1; + } + // Add a separator, and move to the next one. + menuOptions.splice(idx, 0, null); + idx++; + } else { + idx = menuOptions.length - idx; + } + + menuOptions.splice(idx, 0, { content: typeof config.name == 'function' ? config.name(this) : config.name, - callback: (_value: ContextMenuItem, _options: IContextMenuOptions, _event: MouseEvent, _parentMenu: ContextMenu | undefined, _node: TLGraphNode) => { + has_submenu: !!config.subMenuOptions?.length, + isRgthree: true, // Mark it, so we can find it. + callback: (_value: ContextMenuItem, _options: IContextMenuOptions, event: MouseEvent, parentMenu: ContextMenu | undefined, _node: TLGraphNode) => { + if (config.subMenuOptions?.length) { + new LiteGraph.ContextMenu( + config.subMenuOptions.map(option => ({content: option})), + { + event, + parentMenu, + callback: (subValue: ContextMenuItem, _options: IContextMenuOptions, _event: MouseEvent, _parentMenu: ContextMenu | undefined, _node: TLGraphNode) => { + if (config.property) { + this.properties = this.properties || {}; + this.properties[config.property] = config.prepareValue ? config.prepareValue(subValue!.content, this) : subValue!.content; + } + config.callback && config.callback(this); + }, + }); + } if (config.property) { this.properties = this.properties || {}; this.properties[config.property] = config.prepareValue ? config.prepareValue(this.properties[config.property], this) : !this.properties[config.property]; } config.callback && config.callback(this); } - }); + } as ContextMenuItem); }; } -export function addMenuSubMenu(node: typeof LGraphNode, _app: ComfyApp, config: SubMenuConfig) { - const oldGetExtraMenuOptions = node.prototype.getExtraMenuOptions; - node.prototype.getExtraMenuOptions = function(canvas: TLGraphCanvas, menuOptions: ContextMenuItem[]) { - oldGetExtraMenuOptions && oldGetExtraMenuOptions.apply(this, [canvas, menuOptions]); - const idx = menuOptions.findIndex(option => option?.content.includes('Shape')) + 1; - menuOptions.splice((idx > 0 ? idx : menuOptions.length - 1), 0, { - content: typeof config.name == 'function' ? config.name(this) : config.name, - has_submenu: true, - callback: (_value: ContextMenuItem, _options: IContextMenuOptions, event: MouseEvent, parentMenu: ContextMenu | undefined, _node: TLGraphNode) => { - new LiteGraph.ContextMenu( - config.options.map(option => ({content: option})), - { - event, - parentMenu, - callback: (value: ContextMenuItem, _options: IContextMenuOptions, _event: MouseEvent, _parentMenu: ContextMenu | undefined, _node: TLGraphNode) => { - if (config.property) { - this.properties = this.properties || {}; - this.properties[config.property] = config.prepareValue ? config.prepareValue(value!.content, this) : value!.content; - } - config.callback && config.callback(this); - }, - }); - } - }); - } -} - export function addConnectionLayoutSupport(node: typeof LGraphNode, app: ComfyApp, options = [['Left', 'Right'], ['Right', 'Left']], callback?: (node: TLGraphNode) => void) { - addMenuSubMenu(node, app, { + addMenuItem(node, app, { name: 'Connections Layout', property: 'connections_layout', - options: options.map(option => option[0] + (option[1] ? ' -> ' + option[1]: '')), + subMenuOptions: options.map(option => option[0] + (option[1] ? ' -> ' + option[1]: '')), prepareValue: (value, node) => { const values = value.split(' -> '); if (!values[1] && !node.outputs?.length) { @@ -162,11 +162,11 @@ export function getConnectionPosForLayout(node: TLGraphNode, isInput: boolean, s } // Experimental; doesn't work without node.clip_area set (so it won't draw outside), // but litegraph.core inexplicably clips the title off which we want... so, no go. - // if (cxn.hidden) { - // out[0] = node.pos[0] - 100000 - // out[1] = node.pos[1] - 100000 - // return out - // } + if (cxn.hidden) { + out[0] = node.pos[0] - 100000 + out[1] = node.pos[1] - 100000 + return out + } if (cxn.disabled) { // Let's store the original colors if have them and haven't yet overridden if (cxn.color_on !== '#666665') { @@ -180,7 +180,7 @@ export function getConnectionPosForLayout(node: TLGraphNode, isInput: boolean, s cxn.color_off = (cxn as any)._color_off_org || undefined; } // @ts-ignore - const displaySlot = collapseConnections ? 0 : (slotNumber - slotList.reduce((count, ioput, index) => { + const displaySlot = collapseConnections ? 0 : (slotNumber - slotList.reduce((count, ioput, index) => { count += index < slotNumber && ioput.hidden ? 1 : 0; return count }, 0));