diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png index 1635e4e89..ac2f2b066 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-chromium-linux.png differ diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png index 4440ef029..562ec32e6 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-pinned-node-chromium-linux.png differ diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png index 1635e4e89..ac2f2b066 100644 Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-unpinned-node-chromium-linux.png differ diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index d3622a515..34c46f88c 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -22,7 +22,8 @@ - + + !!nodeDef.value) const showColorPicker = computed(() => hasAnySelection.value) const showConvertToSubgraph = computed(() => hasAnySelection.value) const showFrameNodes = computed(() => hasMultipleSelection.value) -const showPublishSubgraph = computed(() => isSingleSubgraph.value) +const showSubgraphButtons = computed(() => isSingleSubgraph.value) const showBypass = computed( () => @@ -130,7 +132,7 @@ const showAnyPrimaryActions = computed( showColorPicker.value || showConvertToSubgraph.value || showFrameNodes.value || - showPublishSubgraph.value + showSubgraphButtons.value ) const showAnyControlActions = computed(() => showBypass.value) diff --git a/src/components/graph/selectionToolbox/ConfigureSubgraph.vue b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue new file mode 100644 index 000000000..5c361aa73 --- /dev/null +++ b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue @@ -0,0 +1,17 @@ + + + + diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index a0ef64275..e28920018 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -68,7 +68,7 @@ const updateDomClipping = () => { return } - const isSelected = selectedNode === widget.node + const isSelected = selectedNode === widgetState.widget.node const renderArea = selectedNode?.renderArea const offset = lgCanvas.ds.offset const scale = lgCanvas.ds.scale diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts index e729667b1..baaff11b2 100644 --- a/src/composables/useCoreCommands.ts +++ b/src/composables/useCoreCommands.ts @@ -5,6 +5,7 @@ import { DEFAULT_DARK_COLOR_PALETTE, DEFAULT_LIGHT_COLOR_PALETTE } from '@/constants/coreColorPalettes' +import { promoteRecommendedWidgets } from '@/core/graph/subgraph/proxyWidgetUtils' import { t } from '@/i18n' import { LGraphEventMode, @@ -909,6 +910,7 @@ export function useCoreCommands(): ComfyCommand[] { const { node } = res canvas.select(node) + promoteRecommendedWidgets(node) canvasStore.updateSelectedItems() } }, diff --git a/src/core/graph/subgraph/SubgraphNode.vue b/src/core/graph/subgraph/SubgraphNode.vue new file mode 100644 index 000000000..f163df53f --- /dev/null +++ b/src/core/graph/subgraph/SubgraphNode.vue @@ -0,0 +1,315 @@ + + + + + + + {{ $t('subgraphStore.shown') }} + + + {{ $t('subgraphStore.hideAll') }} + + + + + + + + + + + {{ $t('subgraphStore.hidden') }} + + + {{ $t('subgraphStore.showAll') }} + + + + + + + + {{ $t('subgraphStore.showRecommended') }} + + + diff --git a/src/core/graph/subgraph/SubgraphNodeWidget.vue b/src/core/graph/subgraph/SubgraphNodeWidget.vue new file mode 100644 index 000000000..929bb9782 --- /dev/null +++ b/src/core/graph/subgraph/SubgraphNodeWidget.vue @@ -0,0 +1,48 @@ + + + + + + {{ nodeTitle }} + {{ widgetName }} + + + + diff --git a/src/core/graph/subgraph/proxyWidget.ts b/src/core/graph/subgraph/proxyWidget.ts index c59fd203a..d7d76a9d1 100644 --- a/src/core/graph/subgraph/proxyWidget.ts +++ b/src/core/graph/subgraph/proxyWidget.ts @@ -1,13 +1,18 @@ -import { useNodeImage } from '@/composables/node/useNodeImage' +import { demoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils' import { parseProxyWidgets } from '@/core/schemas/proxyWidget' -import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { + LGraph, + LGraphCanvas, + LGraphNode +} from '@/lib/litegraph/src/litegraph' import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts' import { disconnectedWidget } from '@/lib/litegraph/src/widgets/DisconnectedWidget' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { DOMWidgetImpl } from '@/scripts/domWidget' +import { useLitegraphService } from '@/services/litegraphService' import { useDomWidgetStore } from '@/stores/domWidgetStore' -import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { getNodeByExecutionId } from '@/utils/graphTraversalUtil' /** @@ -43,14 +48,33 @@ function isProxyWidget(w: IBaseWidget): w is ProxyWidget { return (w as { _overlay?: Overlay })?._overlay?.isProxyWidget ?? false } +export function registerProxyWidgets(canvas: LGraphCanvas) { + //NOTE: canvasStore hasn't been initialized yet + canvas.canvas.addEventListener<'subgraph-opened'>('subgraph-opened', (e) => { + const { subgraph, fromNode } = e.detail + const proxyWidgets = parseProxyWidgets(fromNode.properties.proxyWidgets) + for (const node of subgraph.nodes) { + for (const widget of node.widgets ?? []) { + widget.promoted = proxyWidgets.some( + ([n, w]) => node.id == n && widget.name == w + ) + } + } + }) + SubgraphNode.prototype.onConfigure = onConfigure +} + const originalOnConfigure = SubgraphNode.prototype.onConfigure -SubgraphNode.prototype.onConfigure = function (serialisedNode) { +const onConfigure = function ( + this: LGraphNode, + serialisedNode: ISerialisedNode +) { if (!this.isSubgraphNode()) throw new Error("Can't add proxyWidgets to non-subgraphNode") const canvasStore = useCanvasStore() //Must give value to proxyWidgets prior to defining or it won't serialize - this.properties.proxyWidgets ??= '[]' + this.properties.proxyWidgets ??= [] let proxyWidgets = this.properties.proxyWidgets originalOnConfigure?.call(this, serialisedNode) @@ -62,13 +86,16 @@ SubgraphNode.prototype.onConfigure = function (serialisedNode) { set: (property: string) => { const parsed = parseProxyWidgets(property) const { deactivateWidget, setWidget } = useDomWidgetStore() - for (const w of this.widgets.filter((w) => isProxyWidget(w))) { - if (w instanceof DOMWidgetImpl) deactivateWidget(w.id) + const isActiveGraph = useCanvasStore().canvas?.graph === this.graph + if (isActiveGraph) { + for (const w of this.widgets.filter((w) => isProxyWidget(w))) { + if (w instanceof DOMWidgetImpl) deactivateWidget(w.id) + } } this.widgets = this.widgets.filter((w) => !isProxyWidget(w)) for (const [nodeId, widgetName] of parsed) { const w = addProxyWidget(this, `${nodeId}`, widgetName) - if (w instanceof DOMWidgetImpl) setWidget(w) + if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w) } proxyWidgets = property canvasStore.canvas?.setDirty(true, true) @@ -86,19 +113,23 @@ function addProxyWidget( ) { const name = `${nodeId}: ${widgetName}` const overlay = { + //items specific for proxy management nodeId, - widgetName, graph: subgraphNode.subgraph, - name, - label: name, - isProxyWidget: true, - y: 0, - last_y: undefined, - width: undefined, - computedHeight: undefined, + widgetName, + //Items which normally exist on widgets afterQueued: undefined, + computedHeight: undefined, + isProxyWidget: true, + label: name, + last_y: undefined, + name, + node: subgraphNode, onRemove: undefined, - node: subgraphNode + promoted: undefined, + serialize: false, + width: undefined, + y: 0 } return addProxyFromOverlay(subgraphNode, overlay) } @@ -110,23 +141,20 @@ function resolveLinkedWidget( if (!n) return [undefined, undefined] return [n, n.widgets?.find((w: IBaseWidget) => w.name === widgetName)] } + function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) { + const { updatePreviews } = useLitegraphService() let [linkedNode, linkedWidget] = resolveLinkedWidget(overlay) let backingWidget = linkedWidget ?? disconnectedWidget - if (overlay.widgetName == '$$canvas-image-preview') + if (overlay.widgetName.startsWith('$$')) { overlay.node = new Proxy(subgraphNode, { get(_t, p) { if (p !== 'imgs') return Reflect.get(subgraphNode, p) if (!linkedNode) return [] - const images = - useNodeOutputStore().getNodeOutputs(linkedNode)?.images ?? [] - if (images !== linkedNode.images) { - linkedNode.images = images - useNodeImage(linkedNode).showPreview() - } return linkedNode.imgs } }) + } /** * A set of handlers which define widget interaction * Many arguments are shared between function calls @@ -155,6 +183,12 @@ function addProxyFromOverlay(subgraphNode: SubgraphNode, overlay: Overlay) { let redirectedReceiver = receiver if (property == 'value') redirectedReceiver = backingWidget else if (property == 'computedHeight') { + if (overlay.widgetName.startsWith('$$') && linkedNode) { + updatePreviews(linkedNode) + } + if (linkedNode && linkedWidget?.computedDisabled) { + demoteWidget(linkedNode, linkedWidget, [subgraphNode]) + } //update linkage regularly, but no more than once per frame ;[linkedNode, linkedWidget] = resolveLinkedWidget(overlay) backingWidget = linkedWidget ?? disconnectedWidget diff --git a/src/core/graph/subgraph/proxyWidgetUtils.ts b/src/core/graph/subgraph/proxyWidgetUtils.ts new file mode 100644 index 000000000..e13cc9942 --- /dev/null +++ b/src/core/graph/subgraph/proxyWidgetUtils.ts @@ -0,0 +1,132 @@ +import { + type ProxyWidgetsProperty, + parseProxyWidgets +} from '@/core/schemas/proxyWidget' +import type { + IContextMenuValue, + LGraphNode +} from '@/lib/litegraph/src/litegraph' +import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts' +import { useLitegraphService } from '@/services/litegraphService' +import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore' + +export type WidgetItem = [LGraphNode, IBaseWidget] + +function getProxyWidgets(node: SubgraphNode) { + return parseProxyWidgets(node.properties.proxyWidgets) +} +export function promoteWidget( + node: LGraphNode, + widget: IBaseWidget, + parents: SubgraphNode[] +) { + for (const parent of parents) { + const proxyWidgets = [ + ...getProxyWidgets(parent), + widgetItemToProperty([node, widget]) + ] + parent.properties.proxyWidgets = proxyWidgets + } + widget.promoted = true +} + +export function demoteWidget( + node: LGraphNode, + widget: IBaseWidget, + parents: SubgraphNode[] +) { + for (const parent of parents) { + const proxyWidgets = getProxyWidgets(parent).filter( + (widgetItem) => !matchesPropertyItem([node, widget])(widgetItem) + ) + parent.properties.proxyWidgets = proxyWidgets + } + widget.promoted = false +} + +export function matchesWidgetItem([nodeId, widgetName]: [string, string]) { + return ([n, w]: WidgetItem) => n.id == nodeId && w.name === widgetName +} +export function matchesPropertyItem([n, w]: WidgetItem) { + return ([nodeId, widgetName]: [string, string]) => + n.id == nodeId && w.name === widgetName +} +export function widgetItemToProperty([n, w]: WidgetItem): [string, string] { + return [`${n.id}`, w.name] +} + +function getParentNodes(): SubgraphNode[] { + //NOTE: support for determining parents of a subgraph is limited + //This function will require rework to properly support linked subgraphs + //Either by including actual parents in the navigation stack, + //or by adding a new event for parent listeners to collect from + const { navigationStack } = useSubgraphNavigationStore() + const subgraph = navigationStack.at(-1) + if (!subgraph) throw new Error("Can't promote widget when not in subgraph") + const parentGraph = navigationStack.at(-2) ?? subgraph.rootGraph + return parentGraph.nodes.filter( + (node): node is SubgraphNode => + node.type === subgraph.id && node.isSubgraphNode() + ) +} + +export function addWidgetPromotionOptions( + options: (IContextMenuValue | null)[], + widget: IBaseWidget, + node: LGraphNode +) { + const parents = getParentNodes() + const promotableParents = parents.filter( + (s) => !getProxyWidgets(s).some(matchesPropertyItem([node, widget])) + ) + if (promotableParents.length > 0) + options.unshift({ + content: `Promote Widget: ${widget.label ?? widget.name}`, + callback: () => { + promoteWidget(node, widget, promotableParents) + } + }) + else { + options.unshift({ + content: `Un-Promote Widget: ${widget.label ?? widget.name}`, + callback: () => { + demoteWidget(node, widget, parents) + } + }) + } +} +const recommendedNodes = [ + 'CLIPTextEncode', + 'LoadImage', + 'SaveImage', + 'PreviewImage' +] +const recommendedWidgetNames = ['seed'] +export function isRecommendedWidget([node, widget]: WidgetItem) { + return ( + !widget.computedDisabled && + (recommendedNodes.includes(node.type) || + recommendedWidgetNames.includes(widget.name)) + ) +} + +function nodeWidgets(n: LGraphNode): WidgetItem[] { + return n.widgets?.map((w: IBaseWidget) => [n, w]) ?? [] +} +export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) { + const { updatePreviews } = useLitegraphService() + const interiorNodes = subgraphNode.subgraph.nodes + for (const node of interiorNodes) { + node.updateComputedDisabled() + //NOTE: Since this operation is async, previews still don't exist after the single frame + //Add an onLoad callback to updatePreviews? + updatePreviews(node) + } + const filteredWidgets: WidgetItem[] = interiorNodes + .flatMap(nodeWidgets) + .filter(isRecommendedWidget) + const proxyWidgets: ProxyWidgetsProperty = + filteredWidgets.map(widgetItemToProperty) + subgraphNode.properties.proxyWidgets = proxyWidgets +} diff --git a/src/core/graph/subgraph/useSubgraphNodeDialog.ts b/src/core/graph/subgraph/useSubgraphNodeDialog.ts new file mode 100644 index 000000000..018663dff --- /dev/null +++ b/src/core/graph/subgraph/useSubgraphNodeDialog.ts @@ -0,0 +1,26 @@ +import SubgraphNode from '@/core/graph/subgraph/SubgraphNode.vue' +import { type DialogComponentProps, useDialogStore } from '@/stores/dialogStore' + +const key = 'global-subgraph-node-config' + +export function showSubgraphNodeDialog() { + const dialogStore = useDialogStore() + const dialogComponentProps: DialogComponentProps = { + modal: false, + position: 'topright', + pt: { + root: { + class: 'bg-pure-white dark-theme:bg-charcoal-800 mt-22' + }, + header: { + class: 'h-8 text-xs ml-3' + } + } + } + dialogStore.showDialog({ + title: 'Parameters', + key, + component: SubgraphNode, + dialogComponentProps + }) +} diff --git a/src/core/schemas/proxyWidget.ts b/src/core/schemas/proxyWidget.ts index a85e50a2a..307c4987a 100644 --- a/src/core/schemas/proxyWidget.ts +++ b/src/core/schemas/proxyWidget.ts @@ -4,18 +4,12 @@ import { fromZodError } from 'zod-validation-error' import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode' const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()])) -type ProxyWidgetsProperty = z.infer +export type ProxyWidgetsProperty = z.infer export function parseProxyWidgets( property: NodeProperty | undefined ): ProxyWidgetsProperty { - if (typeof property !== 'string') { - throw new Error( - 'Invalid assignment for properties.proxyWidgets:\nValue must be a string' - ) - } - const parsed = JSON.parse(property) - const result = proxyWidgetsPropertySchema.safeParse(parsed) + const result = proxyWidgetsPropertySchema.safeParse(property) if (result.success) return result.data const error = fromZodError(result.error) diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index f33b0df04..eb2ec3dd5 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -1862,13 +1862,13 @@ export class LGraphCanvas this.#dirty() } - openSubgraph(subgraph: Subgraph): void { + openSubgraph(subgraph: Subgraph, fromNode: SubgraphNode): void { const { graph } = this if (!graph) throw new NullGraphError() const options = { bubbles: true, - detail: { subgraph, closingGraph: graph }, + detail: { subgraph, closingGraph: graph, fromNode }, cancelable: true } const mayContinue = this.canvas.dispatchEvent( @@ -2794,7 +2794,7 @@ export class LGraphCanvas if (pos[1] < 0 && !inCollapse) { node.onNodeTitleDblClick?.(e, pos, this) } else if (node instanceof SubgraphNode) { - this.openSubgraph(node.subgraph) + this.openSubgraph(node.subgraph, node) } node.onDblClick?.(e, pos, this) @@ -8007,7 +8007,7 @@ export class LGraphCanvas if (Object.keys(this.selected_nodes).length > 1) { options.push( { - content: 'Convert to Subgraph 🆕', + content: 'Convert to Subgraph', callback: () => { if (!this.selectedItems.size) throw new Error('Convert to Subgraph: Nothing selected.') @@ -8042,7 +8042,7 @@ export class LGraphCanvas } else { options = [ { - content: 'Convert to Subgraph 🆕', + content: 'Convert to Subgraph', callback: () => { // find groupnodes, degroup and select children if (this.selectedItems.size) { diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts index 557bf03b8..25991f849 100644 --- a/src/lib/litegraph/src/LGraphNode.ts +++ b/src/lib/litegraph/src/LGraphNode.ts @@ -3749,6 +3749,13 @@ export class LGraphNode return !isHidden } + updateComputedDisabled() { + if (!this.widgets) return + for (const widget of this.widgets) + widget.computedDisabled = + widget.disabled || this.getSlotFromWidget(widget)?.link != null + } + drawWidgets( ctx: CanvasRenderingContext2D, { lowQuality = false, editorAlpha = 1 }: DrawWidgetsOptions @@ -3762,6 +3769,7 @@ export class LGraphNode ctx.save() ctx.globalAlpha = editorAlpha + this.updateComputedDisabled() for (const widget of widgets) { if (!this.isWidgetVisible(widget)) continue @@ -3771,9 +3779,6 @@ export class LGraphNode : LiteGraph.WIDGET_OUTLINE_COLOR widget.last_y = y - // Disable widget if it is disabled or if the value is passed from socket connection. - widget.computedDisabled = - widget.disabled || this.getSlotFromWidget(widget)?.link != null ctx.strokeStyle = outlineColour ctx.fillStyle = '#222' diff --git a/src/lib/litegraph/src/LiteGraphGlobal.ts b/src/lib/litegraph/src/LiteGraphGlobal.ts index c2c97f555..c74545313 100644 --- a/src/lib/litegraph/src/LiteGraphGlobal.ts +++ b/src/lib/litegraph/src/LiteGraphGlobal.ts @@ -70,6 +70,7 @@ export class LiteGraphGlobal { WIDGET_BGCOLOR = '#222' WIDGET_OUTLINE_COLOR = '#666' + WIDGET_PROMOTED_OUTLINE_COLOR = '#BF00FF' WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)' WIDGET_TEXT_COLOR = '#DDD' WIDGET_SECONDARY_TEXT_COLOR = '#999' diff --git a/src/lib/litegraph/src/infrastructure/LGraphCanvasEventMap.ts b/src/lib/litegraph/src/infrastructure/LGraphCanvasEventMap.ts index 6b51765f4..1c25e862c 100644 --- a/src/lib/litegraph/src/infrastructure/LGraphCanvasEventMap.ts +++ b/src/lib/litegraph/src/infrastructure/LGraphCanvasEventMap.ts @@ -4,6 +4,7 @@ import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup' import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { ConnectingLink } from '@/lib/litegraph/src/interfaces' import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph' +import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events' export interface LGraphCanvasEventMap { @@ -14,6 +15,11 @@ export interface LGraphCanvasEventMap { /** The old active graph, or `null` if there was no active graph. */ oldGraph: LGraph | Subgraph | null | undefined } + 'subgraph-opened': { + subgraph: Subgraph + closingGraph: LGraph + fromNode: SubgraphNode + } 'litegraph:canvas': | { subType: 'before-change' | 'after-change' } diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index f6d738d1e..4eada68be 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -168,7 +168,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { canvas: LGraphCanvas ): void { if (button.name === 'enter_subgraph') { - canvas.openSubgraph(this.subgraph) + canvas.openSubgraph(this.subgraph, this) } else { super.onTitleButtonClick(button, canvas) } diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts index 8027a1b61..8d77c79e9 100644 --- a/src/lib/litegraph/src/types/widgets.ts +++ b/src/lib/litegraph/src/types/widgets.ts @@ -308,6 +308,13 @@ export interface IBaseWidget< hidden?: boolean advanced?: boolean + /** + * This property is automatically computed on graph change + * and should not be changed. + * Promoted widgets have a colored border + * @see /core/graph/subgraph/proxyWidget.registerProxyWidgets + */ + promoted?: boolean tooltip?: string diff --git a/src/lib/litegraph/src/widgets/BaseWidget.ts b/src/lib/litegraph/src/widgets/BaseWidget.ts index c09668d9e..966a6f486 100644 --- a/src/lib/litegraph/src/widgets/BaseWidget.ts +++ b/src/lib/litegraph/src/widgets/BaseWidget.ts @@ -74,6 +74,7 @@ export abstract class BaseWidget computedDisabled?: boolean hidden?: boolean advanced?: boolean + promoted?: boolean tooltip?: string element?: HTMLElement callback?( @@ -146,6 +147,7 @@ export abstract class BaseWidget } get outline_color() { + if (this.promoted) return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR return this.advanced ? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR : LiteGraph.WIDGET_OUTLINE_COLOR diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 95a5a7e4d..2f2a2fec1 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -96,6 +96,7 @@ "searchModels": "Search Models", "searchKeybindings": "Search Keybindings", "searchExtensions": "Search Extensions", + "search": "Search", "noResultsFound": "No Results Found", "searchFailedMessage": "We couldn't find any settings matching your search. Try adjusting your search terms.", "noTasksFound": "No Tasks Found", @@ -352,6 +353,7 @@ "Color": "Color", "Add Subgraph to Library": "Add Subgraph to Library", "Unpack Subgraph": "Unpack Subgraph", + "Edit Subgraph Widgets": "Edit Subgraph Widgets", "Convert to Subgraph": "Convert to Subgraph", "Align Selected To": "Align Selected To", "Distribute Nodes": "Distribute Nodes", @@ -1075,7 +1077,12 @@ "publish": "Publish Subgraph", "publishSuccess": "Saved to Nodes Library", "publishSuccessMessage": "You can find your subgraph blueprint in the nodes library under \"Subgraph Blueprints\"", - "loadFailure": "Failed to load subgraph blueprints" + "loadFailure": "Failed to load subgraph blueprints", + "shown": "Shown on node", + "showAll": "Show all", + "hidden": "Hidden / nested parameters", + "hideAll": "Hide all", + "showRecommended": "Show recommended widgets" }, "electronFileDownload": { "inProgress": "In Progress", diff --git a/src/platform/workflow/management/stores/workflowStore.ts b/src/platform/workflow/management/stores/workflowStore.ts index dc9cd07c0..f3c8c9651 100644 --- a/src/platform/workflow/management/stores/workflowStore.ts +++ b/src/platform/workflow/management/stores/workflowStore.ts @@ -3,7 +3,11 @@ import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue' import { t } from '@/i18n' -import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph' +import type { + LGraph, + LGraphNode, + Subgraph +} from '@/lib/litegraph/src/litegraph' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail' @@ -182,6 +186,7 @@ interface WorkflowStore { updateActiveGraph: () => void executionIdToCurrentId: (id: string) => any nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId + nodeToNodeLocatorId: (node: LGraphNode) => NodeLocatorId nodeExecutionIdToNodeLocatorId: ( nodeExecutionId: NodeExecutionId | string ) => NodeLocatorId | null @@ -577,6 +582,17 @@ export const useWorkflowStore = defineStore('workflow', () => { return createNodeLocatorId(targetSubgraph.id, nodeId) } + /** + * Convert a node to a NodeLocatorId + * Does not assume the node resides in the active graph + * @param The actual node instance + * @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is) + */ + const nodeToNodeLocatorId = (node: LGraphNode): NodeLocatorId => { + if (isSubgraph(node.graph)) + return createNodeLocatorId(node.graph.id, node.id) + return String(node.id) + } /** * Convert an execution ID to a NodeLocatorId @@ -719,6 +735,7 @@ export const useWorkflowStore = defineStore('workflow', () => { updateActiveGraph, executionIdToCurrentId, nodeIdToNodeLocatorId, + nodeToNodeLocatorId, nodeExecutionIdToNodeLocatorId, nodeLocatorIdToNodeId, nodeLocatorIdToNodeExecutionId diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index b1a7f9acf..0e6acc461 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -332,7 +332,7 @@ const handleEnterSubgraph = () => { return } - canvas.openSubgraph(litegraphNode.subgraph) + canvas.openSubgraph(litegraphNode.subgraph, litegraphNode) } const nodeOutputs = useNodeOutputStore() diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts index d6e89af18..7c3c16d0a 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts @@ -19,7 +19,8 @@ import { is_all_same_aspect_ratio } from '@/utils/imageUtil' const renderPreview = ( ctx: CanvasRenderingContext2D, node: LGraphNode, - shiftY: number + shiftY: number, + computedHeight: number | undefined ) => { const canvas = useCanvasStore().getCanvas() const mouse = canvas.graph_mouse @@ -46,7 +47,7 @@ const renderPreview = ( const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw') const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0 const dw = node.size[0] - const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT + const dh = computedHeight ? computedHeight - IMAGE_TEXT_SIZE_TEXT_HEIGHT : 0 if (imageIndex == null) { // No image selected; draw thumbnails of all @@ -260,7 +261,7 @@ class ImagePreviewWidget extends BaseWidget { } override drawWidget(ctx: CanvasRenderingContext2D): void { - renderPreview(ctx, this.node, this.y) + renderPreview(ctx, this.node, this.y, this.computedHeight) } override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean { diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 99e990f8d..1169325ca 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -5,6 +5,7 @@ import { reactive, unref } from 'vue' import { shallowRef } from 'vue' import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion' +import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget' import { st, t } from '@/i18n' import { LGraph, @@ -883,6 +884,7 @@ export class ComfyApp { } } ) + registerProxyWidgets(this.canvas) this.graph.start() diff --git a/src/scripts/domWidget.ts b/src/scripts/domWidget.ts index a94ddb2ac..a550a4145 100644 --- a/src/scripts/domWidget.ts +++ b/src/scripts/domWidget.ts @@ -173,6 +173,18 @@ abstract class BaseDOMWidgetImpl ) ctx.fill() ctx.fillStyle = originalFillStyle + } else if (this.promoted && this.isVisible()) { + ctx.save() + const adjustedMargin = this.margin - 1 + ctx.beginPath() + ctx.strokeStyle = LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR + ctx.strokeRect( + adjustedMargin, + y + adjustedMargin, + widget_width - adjustedMargin * 2, + (this.computedHeight ?? widget_height) - 2 * adjustedMargin + ) + ctx.restore() } this.options.onDraw?.(this) } diff --git a/src/scripts/ui/draggableList.ts b/src/scripts/ui/draggableList.ts index 5f5db27fa..895d153b7 100644 --- a/src/scripts/ui/draggableList.ts +++ b/src/scripts/ui/draggableList.ts @@ -23,11 +23,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { $el } from '../ui' - -$el('style', { - parent: document.head, - textContent: ` +const styleElement = document.createElement('style') +styleElement.textContent = ` .draggable-item { position: relative; will-change: transform; @@ -40,7 +37,7 @@ $el('style', { z-index: 10; } ` -}) +document.head.append(styleElement) export class DraggableList extends EventTarget { listContainer diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts index e61edb509..dd75081af 100644 --- a/src/scripts/widgets.ts +++ b/src/scripts/widgets.ts @@ -1,4 +1,3 @@ -import '@/core/graph/subgraph/proxyWidget' import { t } from '@/i18n' import { type LGraphNode, isComboWidget } from '@/lib/litegraph/src/litegraph' import type { diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 2149bb378..d345acb67 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -4,6 +4,8 @@ import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteG import { useNodeAnimatedImage } from '@/composables/node/useNodeAnimatedImage' import { useNodeCanvasImagePreview } from '@/composables/node/useNodeCanvasImagePreview' import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage' +import { addWidgetPromotionOptions } from '@/core/graph/subgraph/proxyWidgetUtils' +import { showSubgraphNodeDialog } from '@/core/graph/subgraph/useSubgraphNodeDialog' import { st, t } from '@/i18n' import { type IContextMenuValue, @@ -741,7 +743,7 @@ export const useLitegraphService = () => { ] } - node.prototype.getExtraMenuOptions = function (_, options) { + node.prototype.getExtraMenuOptions = function (canvas, options) { if (this.imgs) { // If this node has images then we add an open in new tab item let img @@ -788,7 +790,7 @@ export const useLitegraphService = () => { content: 'Bypass', callback: () => { toggleSelectedNodesMode(LGraphEventMode.BYPASS) - app.canvas.setDirty(true, true) + canvas.setDirty(true, true) } }) @@ -824,18 +826,88 @@ export const useLitegraphService = () => { } } if (this instanceof SubgraphNode) { - options.unshift({ - content: 'Unpack Subgraph', - callback: () => { - useNodeOutputStore().revokeSubgraphPreviews(this) - this.graph.unpackSubgraph(this) + options.unshift( + { + content: 'Edit Subgraph Widgets', + callback: () => { + showSubgraphNodeDialog() + } + }, + { + content: 'Unpack Subgraph', + callback: () => { + useNodeOutputStore().revokeSubgraphPreviews(this) + this.graph.unpackSubgraph(this) + } } - }) + ) + } + if (this.graph && !this.graph.isRootGraph) { + const [x, y] = canvas.canvas_mouse + const overWidget = this.getWidgetOnPos(x, y, true) + if (overWidget) { + addWidgetPromotionOptions(options, overWidget, this) + } } return [] } } + function updatePreviews(node: LGraphNode) { + try { + unsafeUpdatePreviews.call(node) + } catch (error) { + console.error('Error drawing node background', error) + } + } + function unsafeUpdatePreviews(this: LGraphNode) { + if (this.flags.collapsed) return + + const nodeOutputStore = useNodeOutputStore() + const { showAnimatedPreview, removeAnimatedPreview } = + useNodeAnimatedImage() + const { showCanvasImagePreview, removeCanvasImagePreview } = + useNodeCanvasImagePreview() + + const output = nodeOutputStore.getNodeOutputs(this) + const preview = nodeOutputStore.getNodePreviews(this) + + const isNewOutput = output && this.images !== output.images + const isNewPreview = preview && this.preview !== preview + + if (isNewPreview) this.preview = preview + if (isNewOutput) this.images = output.images + + if (isNewOutput || isNewPreview) { + this.animatedImages = output?.animated?.find(Boolean) + + const isAnimatedWebp = + this.animatedImages && + output?.images?.some((img) => img.filename?.includes('webp')) + const isAnimatedPng = + this.animatedImages && + output?.images?.some((img) => img.filename?.includes('png')) + const isVideo = + (this.animatedImages && !isAnimatedWebp && !isAnimatedPng) || + isVideoNode(this) + if (isVideo) { + useNodeVideo(this).showPreview() + } else { + useNodeImage(this).showPreview() + } + } + + // Nothing to do + if (!this.imgs?.length) return + + if (this.animatedImages) { + removeCanvasImagePreview(this) + showAnimatedPreview(this) + } else { + removeAnimatedPreview(this) + showCanvasImagePreview(this) + } + } /** * Adds Custom drawing logic for nodes @@ -851,62 +923,8 @@ export const useLitegraphService = () => { 'node.setSizeForImage is deprecated. Now it has no effect. Please remove the call to it.' ) } - - function unsafeDrawBackground(this: LGraphNode) { - if (this.flags.collapsed) return - - const nodeOutputStore = useNodeOutputStore() - const { showAnimatedPreview, removeAnimatedPreview } = - useNodeAnimatedImage() - const { showCanvasImagePreview, removeCanvasImagePreview } = - useNodeCanvasImagePreview() - - const output = nodeOutputStore.getNodeOutputs(this) - const preview = nodeOutputStore.getNodePreviews(this) - - const isNewOutput = output && this.images !== output.images - const isNewPreview = preview && this.preview !== preview - - if (isNewPreview) this.preview = preview - if (isNewOutput) this.images = output.images - - if (isNewOutput || isNewPreview) { - this.animatedImages = output?.animated?.find(Boolean) - - const isAnimatedWebp = - this.animatedImages && - output?.images?.some((img) => img.filename?.includes('webp')) - const isAnimatedPng = - this.animatedImages && - output?.images?.some((img) => img.filename?.includes('png')) - const isVideo = - (this.animatedImages && !isAnimatedWebp && !isAnimatedPng) || - isVideoNode(this) - if (isVideo) { - useNodeVideo(this).showPreview() - } else { - useNodeImage(this).showPreview() - } - } - - // Nothing to do - if (!this.imgs?.length) return - - if (this.animatedImages) { - removeCanvasImagePreview(this) - showAnimatedPreview(this) - } else { - removeAnimatedPreview(this) - showCanvasImagePreview(this) - } - } - node.prototype.onDrawBackground = function () { - try { - unsafeDrawBackground.call(this) - } catch (error) { - console.error('Error drawing node background', error) - } + updatePreviews(this) } } @@ -1036,6 +1054,7 @@ export const useLitegraphService = () => { getCanvasCenter, goToNode, resetView, - fitView + fitView, + updatePreviews } } diff --git a/src/stores/imagePreviewStore.ts b/src/stores/imagePreviewStore.ts index aebc23810..848eb0ae7 100644 --- a/src/stores/imagePreviewStore.ts +++ b/src/stores/imagePreviewStore.ts @@ -3,7 +3,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph' -import { Subgraph } from '@/lib/litegraph/src/litegraph' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import type { ExecutedWsMessage, @@ -38,7 +37,7 @@ interface SetOutputOptions { } export const useNodeOutputStore = defineStore('nodeOutput', () => { - const { nodeIdToNodeLocatorId } = useWorkflowStore() + const { nodeIdToNodeLocatorId, nodeToNodeLocatorId } = useWorkflowStore() const { executionIdToNodeLocatorId } = useExecutionStore() const scheduledRevoke: Record void }> = {} @@ -63,11 +62,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { function getNodeOutputs( node: LGraphNode ): ExecutedWsMessage['output'] | undefined { - return app.nodeOutputs[nodeIdToNodeLocatorId(node.id)] + return app.nodeOutputs[nodeToNodeLocatorId(node)] } function getNodePreviews(node: LGraphNode): string[] | undefined { - return app.nodePreviewImages[nodeIdToNodeLocatorId(node.id)] + return app.nodePreviewImages[nodeToNodeLocatorId(node)] } /** @@ -161,10 +160,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => { ) { if (!filenames || !node) return - const locatorId = - node.graph instanceof Subgraph - ? nodeIdToNodeLocatorId(node.id, node.graph ?? undefined) - : `${node.id}` + const locatorId = nodeToNodeLocatorId(node) if (!locatorId) return if (typeof filenames === 'string') { setOutputsByLocatorId( diff --git a/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap b/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap index 5302edcbc..15a7dfca1 100644 --- a/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap +++ b/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap @@ -134,6 +134,7 @@ LiteGraphGlobal { "WIDGET_BGCOLOR": "#222", "WIDGET_DISABLED_TEXT_COLOR": "#666", "WIDGET_OUTLINE_COLOR": "#666", + "WIDGET_PROMOTED_OUTLINE_COLOR": "#BF00FF", "WIDGET_SECONDARY_TEXT_COLOR": "#999", "WIDGET_TEXT_COLOR": "#DDD", "allow_multi_output_for_events": true, diff --git a/tests-ui/tests/widgets/proxyWidget.test.ts b/tests-ui/tests/widgets/proxyWidget.test.ts index 9b2ec1e60..f30e2fa52 100644 --- a/tests-ui/tests/widgets/proxyWidget.test.ts +++ b/tests-ui/tests/widgets/proxyWidget.test.ts @@ -1,21 +1,30 @@ import { describe, expect, test, vi } from 'vitest' -import '@/core/graph/subgraph/proxyWidget' -//import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget' - -import { LGraphNode, type SubgraphNode } from '@/lib/litegraph/src/litegraph' +import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget' +import { + type LGraphCanvas, + LGraphNode, + type SubgraphNode +} from '@/lib/litegraph/src/litegraph' import { createTestSubgraph, createTestSubgraphNode } from '../litegraph/subgraph/fixtures/subgraphHelpers' +const canvasEl: Partial = { addEventListener() {} } +const canvas: Partial = { canvas: canvasEl as HTMLCanvasElement } +registerProxyWidgets(canvas as LGraphCanvas) + vi.mock('@/renderer/core/canvas/canvasStore', () => ({ useCanvasStore: () => ({}) })) vi.mock('@/stores/domWidgetStore', () => ({ useDomWidgetStore: () => ({ widgetStates: new Map() }) })) +vi.mock('@/services/litegraphService', () => ({ + useLitegraphService: () => ({ updatePreviews: () => ({}) }) +})) function setupSubgraph( innerNodeCount: number = 0 @@ -38,22 +47,20 @@ describe('Subgraph proxyWidgets', () => { test('Can add simple widget', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) - subgraphNode.properties.proxyWidgets = JSON.stringify([ + subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']] + expect(subgraphNode.widgets.length).toBe(1) + expect(subgraphNode.properties.proxyWidgets).toStrictEqual([ ['1', 'stringWidget'] ]) - expect(subgraphNode.widgets.length).toBe(1) - expect(subgraphNode.properties.proxyWidgets).toBe( - JSON.stringify([['1', 'stringWidget']]) - ) }) test('Can add multiple widgets with same name', () => { const [subgraphNode, innerNodes] = setupSubgraph(2) for (const innerNode of innerNodes) innerNode.addWidget('text', 'stringWidget', 'value', () => {}) - subgraphNode.properties.proxyWidgets = JSON.stringify([ + subgraphNode.properties.proxyWidgets = [ ['1', 'stringWidget'], ['2', 'stringWidget'] - ]) + ] expect(subgraphNode.widgets.length).toBe(2) expect(subgraphNode.widgets[0].name).not.toEqual( subgraphNode.widgets[1].name @@ -63,19 +70,15 @@ describe('Subgraph proxyWidgets', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) subgraphNode.addWidget('text', 'stringWidget', 'value', () => {}) - subgraphNode.properties.proxyWidgets = JSON.stringify([ - ['1', 'stringWidget'] - ]) + subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']] expect(subgraphNode.widgets.length).toBe(2) - subgraphNode.properties.proxyWidgets = JSON.stringify([]) + subgraphNode.properties.proxyWidgets = [] expect(subgraphNode.widgets.length).toBe(1) }) test('Will mirror changes to value', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) - subgraphNode.properties.proxyWidgets = JSON.stringify([ - ['1', 'stringWidget'] - ]) + subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']] expect(subgraphNode.widgets.length).toBe(1) expect(subgraphNode.widgets[0].value).toBe('value') innerNodes[0].widgets![0].value = 'test' @@ -86,9 +89,7 @@ describe('Subgraph proxyWidgets', () => { test('Will not modify position or sizing of existing widgets', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) - subgraphNode.properties.proxyWidgets = JSON.stringify([ - ['1', 'stringWidget'] - ]) + subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']] if (!innerNodes[0].widgets) throw new Error('node has no widgets') innerNodes[0].widgets[0].y = 10 innerNodes[0].widgets[0].last_y = 11 @@ -103,9 +104,7 @@ describe('Subgraph proxyWidgets', () => { test('Can detach and re-attach widgets', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {}) - subgraphNode.properties.proxyWidgets = JSON.stringify([ - ['1', 'stringWidget'] - ]) + subgraphNode.properties.proxyWidgets = [['1', 'stringWidget']] if (!innerNodes[0].widgets) throw new Error('node has no widgets') expect(subgraphNode.widgets[0].value).toBe('value') const poppedWidget = innerNodes[0].widgets.pop()