From 1ab48b42a734b1f644c3fc97082ebadf5e145139 Mon Sep 17 00:00:00 2001 From: AustinMroz Date: Wed, 25 Feb 2026 08:53:00 -0800 Subject: [PATCH] Add App I/O selection system (#8965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a system for selecting the inputs and outputs which should be displayed when inside linear mode. Functions only in litegraph currently. Vue support will require a separate, larger PR. Inputs and outputs can be re-ordered by dragging and dropping on the side panel. ![builder_00001](https://github.com/user-attachments/assets/6345adbd-519e-455d-b71e-0020aa03c6b7) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8965-Add-App-I-O-selection-system-30b6d73d365081569b36c1682a1fdbc5) by [Unito](https://www.unito.io) --- .../LiteGraphCanvasSplitterOverlay.vue | 9 +- src/components/builder/AppBuilder.vue | 324 ++++++++++++++++++ src/components/builder/BuilderToolbar.vue | 9 + src/components/builder/IoItem.vue | 46 +++ src/components/builder/useBuilderSave.ts | 7 +- src/components/common/DraggableList.vue | 59 ++++ src/components/graph/GraphCanvas.vue | 4 +- .../subgraph/SubgraphEditor.vue | 62 +--- .../subgraph/SubgraphNodeWidget.vue | 3 +- src/lib/litegraph/src/types/widgets.ts | 1 - src/locales/en/main.json | 14 + .../validation/schemas/workflowSchema.ts | 8 +- .../extensions/linearMode/LinearControls.vue | 29 +- src/stores/appModeStore.ts | 69 +++- 14 files changed, 572 insertions(+), 72 deletions(-) create mode 100644 src/components/builder/AppBuilder.vue create mode 100644 src/components/builder/IoItem.vue create mode 100644 src/components/common/DraggableList.vue diff --git a/src/components/LiteGraphCanvasSplitterOverlay.vue b/src/components/LiteGraphCanvasSplitterOverlay.vue index 4a7695d184..389629f01d 100644 --- a/src/components/LiteGraphCanvasSplitterOverlay.vue +++ b/src/components/LiteGraphCanvasSplitterOverlay.vue @@ -25,7 +25,7 @@ +import { remove } from 'es-toolkit' +import { computed, ref, toValue } from 'vue' +import type { MaybeRef } from 'vue' +import { useI18n } from 'vue-i18n' + +import DraggableList from '@/components/common/DraggableList.vue' +import IoItem from '@/components/builder/IoItem.vue' +import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue' +import Button from '@/components/ui/button/Button.vue' +import { LiteGraph } from '@/lib/litegraph/src/litegraph' +import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' +import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' +import { TitleMode } from '@/lib/litegraph/src/types/globalEnums' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget' +import { useSettingStore } from '@/platform/settings/settingStore' +import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' +import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' +import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' +import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue' +import { app } from '@/scripts/app' +import { DOMWidgetImpl } from '@/scripts/domWidget' +import { useDialogService } from '@/services/dialogService' +import { useAppModeStore } from '@/stores/appModeStore' +import { cn } from '@/utils/tailwindUtil' + +type BoundStyle = { top: string; left: string; width: string; height: string } + +const appModeStore = useAppModeStore() +const canvasInteractions = useCanvasInteractions() +const canvasStore = useCanvasStore() +const settingStore = useSettingStore() +const workflowStore = useWorkflowStore() +const { t } = useI18n() +const canvas: LGraphCanvas = canvasStore.getCanvas() + +const hoveringSelectable = ref(false) + +workflowStore.activeWorkflow?.changeTracker?.reset() + +const inputsWithState = computed(() => + appModeStore.selectedInputs.map(([nodeId, widgetName]) => { + const node = app.rootGraph.getNodeById(nodeId) + const widget = node?.widgets?.find((w) => w.name === widgetName) + if (!node || !widget) return { nodeId, widgetName } + + const input = node.inputs.find((i) => i.widget?.name === widget.name) + const rename = input && (() => renameWidget(widget, input)) + + return { + nodeId, + widgetName, + label: widget.label, + subLabel: node.title, + rename + } + }) +) +const outputsWithState = computed<[NodeId, string][]>(() => + appModeStore.selectedOutputs.map((nodeId) => [ + nodeId, + app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId) + ]) +) + +async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) { + const newLabel = await useDialogService().prompt({ + title: t('g.rename'), + message: t('g.enterNewNamePrompt'), + defaultValue: widget.label, + placeholder: widget.name + }) + if (newLabel === null) return + widget.label = newLabel || undefined + input.label = newLabel || undefined + widget.callback?.(widget.value) + useCanvasStore().canvas?.setDirty(true) +} + +function getHovered( + e: MouseEvent +): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] { + const { graph } = canvas + if (!canvas || !graph) return + + if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined + if (!e) return + + canvas.adjustMouseEvent(e) + const node = graph.getNodeOnPos(e.canvasX, e.canvasY) + if (!node) return + + const widget = node.getWidgetOnPos(e.canvasX, e.canvasY, false) + + if (widget || node.constructor.nodeData?.output_node) return [node, widget] +} + +function getBounding(nodeId: NodeId, widgetName?: string) { + if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined + const node = app.rootGraph.getNodeById(nodeId) + if (!node) return + + const titleOffset = + node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0 + + if (!widgetName) + return { + width: `${node.size[0]}px`, + height: `${node.size[1] + titleOffset}px`, + left: `${node.pos[0]}px`, + top: `${node.pos[1] - titleOffset}px` + } + const widget = node.widgets?.find((w) => w.name === widgetName) + if (!widget) return + + const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined + const marginX = margin ?? BaseWidget.margin + const height = + (widget.computedHeight !== undefined + ? widget.computedHeight - 4 + : LiteGraph.NODE_WIDGET_HEIGHT) - (margin ? 2 * margin - 4 : 0) + return { + width: `${node.size[0] - marginX * 2}px`, + height: `${height}px`, + left: `${node.pos[0] + marginX}px`, + top: `${node.pos[1] + widget.y + (margin ?? 0)}px` + } +} + +function handleDown(e: MouseEvent) { + const [node] = getHovered(e) ?? [] + if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e) +} +function handleClick(e: MouseEvent) { + const [node, widget] = getHovered(e) ?? [] + if (!node) return canvasInteractions.forwardEventToCanvas(e) + + if (!widget) { + if (!node.constructor.nodeData?.output_node) + return canvasInteractions.forwardEventToCanvas(e) + const index = appModeStore.selectedOutputs.findIndex((id) => id === node.id) + if (index === -1) appModeStore.selectedOutputs.push(node.id) + else appModeStore.selectedOutputs.splice(index, 1) + return + } + + const index = appModeStore.selectedInputs.findIndex( + ([nodeId, widgetName]) => node.id === nodeId && widget.name === widgetName + ) + if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name]) + else appModeStore.selectedInputs.splice(index, 1) +} + +function nodeToDisplayTuple( + n: LGraphNode +): [NodeId, MaybeRef | undefined, boolean] { + return [ + n.id, + getBounding(n.id), + appModeStore.selectedOutputs.some((id) => n.id === id) + ] +} + +const renderedOutputs = computed(() => { + void appModeStore.selectedOutputs.length + return canvas + .graph!.nodes.filter((n) => n.constructor.nodeData?.output_node) + .map(nodeToDisplayTuple) +}) +const renderedInputs = computed<[string, MaybeRef | undefined][]>( + () => + appModeStore.selectedInputs.map(([nodeId, widgetName]) => [ + `${nodeId}: ${widgetName}`, + getBounding(nodeId, widgetName) + ]) +) + + diff --git a/src/components/builder/BuilderToolbar.vue b/src/components/builder/BuilderToolbar.vue index c3c8978d0a..985124034e 100644 --- a/src/components/builder/BuilderToolbar.vue +++ b/src/components/builder/BuilderToolbar.vue @@ -62,6 +62,7 @@ + diff --git a/src/components/builder/useBuilderSave.ts b/src/components/builder/useBuilderSave.ts index 4e7503561d..6c5a0018fa 100644 --- a/src/components/builder/useBuilderSave.ts +++ b/src/components/builder/useBuilderSave.ts @@ -33,12 +33,10 @@ export function useBuilderSave() { return } - // TODO: Update this to show the save dialog if it is temp OR if the user has not saved app mode before. - // If they have saved app mode before, just save the workflow, but use the initial app mode state not current. - - if (!workflow.isTemporary) { + if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) { try { workflow.changeTracker?.checkState() + appModeStore.saveSelectedToWorkflow() await workflowService.saveWorkflow(workflow) showSuccessDialog(workflow.filename, appModeStore.isAppMode) } catch { @@ -75,6 +73,7 @@ export function useBuilderSave() { const workflow = workflowStore.activeWorkflow if (!workflow) return + appModeStore.saveSelectedToWorkflow() const saved = await workflowService.saveWorkflowAs(workflow, { filename, openAsApp diff --git a/src/components/common/DraggableList.vue b/src/components/common/DraggableList.vue new file mode 100644 index 0000000000..90ba9925b9 --- /dev/null +++ b/src/components/common/DraggableList.vue @@ -0,0 +1,59 @@ + + diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 9e250999ce..1c713f5596 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -38,7 +38,8 @@