Support selection of app inputs and outputs from vue mode (#9259)

- The input and output indicators are now plugged directly into the
`LGraphNode.vue` template. Care was taken to make implementation to have
low cost for performance and complexity when not in app mode setup.
- Context menu event handlers are added to each widget in vue mode
instead of resolving the target widget of an event
- Swap the nodeId passed by `useGraphNodeManager` to not include the
locator id. This id was never used and was incorrect since it didn't
resolve across nested subgraphs.
- Continued bug fixes for app mode as a whole.

Known issue: There is disparity of nodeId between litegraph (which
references the widget in the root graph) and vue (which promotes the
original widget). Efforts to reconcile are ongoing.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9259-Support-selection-app-inputs-and-outputs-from-vue-mode-3136d73d365081ae8e56e35bf6322409)
by [Unito](https://www.unito.io)

---------

Co-authored-by: pythongosssss <125205205+pythongosssss@users.noreply.github.com>
This commit is contained in:
AustinMroz
2026-03-02 09:49:21 -08:00
committed by GitHub
parent 84d7aa0fd9
commit 1dd789fa54
11 changed files with 237 additions and 54 deletions

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { id, name } = defineProps<{
id: string
isSelectMode: boolean
name: string
}>()
const appModeStore = useAppModeStore()
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
function matchesThis([nodeId, widgetName]: [NodeId, string]) {
return id == nodeId && name === widgetName
}
function togglePromotion() {
if (isPromoted.value) remove(appModeStore.selectedInputs, matchesThis)
else appModeStore.selectedInputs.push([id, name])
}
</script>
<template>
<div
v-if="isSelectMode"
class="col-span-2 flex flex-row pointer-events-auto cursor-pointer gap-1 relative"
@pointerdown.capture.stop.prevent="togglePromotion"
@click.capture.stop.prevent
@pointerup.capture.stop.prevent
@pointermove.capture.stop.prevent
@contextmenu.capture.stop.prevent
>
<div
:class="
cn(
'border-primary-background border rounded-sm size-4 self-center m-1',
isPromoted && 'bg-primary-background flex items-center'
)
"
>
<i
v-if="isPromoted"
class="icon-[lucide--check] bg-primary-foreground place-center"
/>
</div>
<div
:class="
cn(
'grid grid-cols-2 items-stretch ring-primary-background rounded-lg pointer-events-none flex-1',
isPromoted && 'ring-2'
)
"
>
<slot />
</div>
<div class="absolute size-full hover:bg-primary-background/10 rounded-lg" />
</div>
<slot v-else />
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { id } = defineProps<{ id: string }>()
const appModeStore = useAppModeStore()
const isPromoted = computed(() =>
appModeStore.selectedOutputs.some(matchesThis)
)
function matchesThis(nodeId: NodeId) {
return id == nodeId
}
function togglePromotion() {
if (isPromoted.value) remove(appModeStore.selectedOutputs, matchesThis)
else appModeStore.selectedOutputs.push(id)
}
</script>
<template>
<div
:class="
cn(
'absolute w-full h-full pointer-events-auto ring-warning-background/50 ring-5 rounded-2xl',
isPromoted && 'ring-warning-background'
)
"
@click.capture.stop.prevent
@pointerup.capture.stop.prevent
@pointermove.capture.stop.prevent
@pointerdown.capture.stop="togglePromotion"
@contextmenu.capture.stop.prevent
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isPromoted"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg"
/>
</div>
</div>
</template>

View File

@@ -16,7 +16,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -69,7 +68,11 @@ const mappedSelections = computed(() => {
([id]) => id === nodeId
).map(([, widgetName]) => widgetName)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
const node = app.rootGraph.getNodeById(nodeId)
const node =
app.rootGraph.getNodeById(nodeId) ??
[...app.rootGraph.subgraphs.values()]
.flatMap((sg) => sg.nodes)
.find((n) => n.id == nodeId)
if (!node) continue
const nodeData = nodeToNodeData(node)
@@ -239,7 +242,6 @@ defineExpose({ runButtonClick })
/>
<NodeWidgets
:node-data
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
class="py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg max-w-100"
/>
</template>
@@ -279,7 +281,6 @@ defineExpose({ runButtonClick })
'ring-2 ring-inset ring-node-stroke-error'
)
"
:style="{ background: applyLightThemeColor(nodeData.bgcolor) }"
/>
</DropZone>
</template>

View File

@@ -50,6 +50,10 @@
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<AppOutput
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="flex flex-col justify-center items-center relative"
@@ -265,6 +269,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/unpromotedWidgetUtils'
import { st } from '@/i18n'
@@ -284,6 +289,7 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
import { LayoutSource } from '@/renderer/core/layout/types'
import AppOutput from '@/renderer/extensions/linearMode/AppOutput.vue'
import SlotConnectionDot from '@/renderer/extensions/vueNodes/components/SlotConnectionDot.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/composables/useNodePointerInteractions'
@@ -329,6 +335,7 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const { isSelectMode } = useAppMode()
const settingStore = useSettingStore()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =

View File

@@ -28,7 +28,6 @@
<div
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
:data-widget-name="widget.name"
>
<!-- Widget Input Slot Dot -->
<div
@@ -54,21 +53,24 @@
/>
</div>
<!-- Widget Component -->
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'text-node-stroke-error font-bold'
)
"
@update:model-value="widget.updateHandler"
/>
<AppInput :id="widget.id" :name="widget.name" :is-select-mode>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'text-node-stroke-error font-bold'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
@@ -83,12 +85,16 @@ import type {
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
// Import widget components directly
@@ -117,6 +123,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { isSelectMode } = useAppMode()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()
@@ -134,6 +141,8 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -159,9 +168,11 @@ const widgetValueStore = useWidgetValueStore()
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id: string
name: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
@@ -240,15 +251,29 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
}
result.push({
advanced: widget.options?.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError:
nodeErrors?.errors?.some(
(error) => error.extra_info?.input_name === widget.name
) ?? false,
hidden: widget.options?.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
type: widget.type,
vueComponent,