mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
As a temporary fix for widgets being incorrectly hidden, #9669 allowed all disabled widgets to be displayed. This PR provides a more robust implementation to derive whether the widget, as would be displayed from the root graph, is disabled. Potential regression: - Drag drop handlers are applied on node, not widgets. A subgraph containing a "Load Image" node, does not allow dragging and dropping an image onto the subgraphNode in order to load it. Because app mode widgets would display from the original owning node prior to this PR, these drag/drop handlers would apply. Placing "Load Image" nodes. I believe this change makes behavior more consistent, but it warrants consideration. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9671-Restore-hiding-of-linked-inputs-in-app-mode-31e6d73d365081688e37fbb931f3af68) by [Unito](https://www.unito.io)
411 lines
14 KiB
Vue
411 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import { remove } from 'es-toolkit'
|
|
import { computed, provide, 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 WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
|
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
|
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
|
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
|
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
|
import {
|
|
LGraphEventMode,
|
|
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 { promptRenameWidget } from '@/utils/widgetUtil'
|
|
import { useAppMode } from '@/composables/useAppMode'
|
|
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
|
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
|
import { cn } from '@/utils/tailwindUtil'
|
|
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
|
|
|
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 { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
|
|
useAppMode()
|
|
const hoveringSelectable = ref(false)
|
|
|
|
provide(HideLayoutFieldKey, true)
|
|
|
|
workflowStore.activeWorkflow?.changeTracker?.reset()
|
|
|
|
const arrangeInputs = computed(() =>
|
|
appModeStore.selectedInputs
|
|
.map(([nodeId, widgetName]) => {
|
|
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
|
return node ? { nodeId, widgetName, node, widget } : null
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null)
|
|
)
|
|
|
|
const inputsWithState = computed(() =>
|
|
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
|
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
|
if (!node || !widget) {
|
|
return {
|
|
nodeId,
|
|
widgetName,
|
|
subLabel: t('linearMode.builder.unknownWidget')
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodeId,
|
|
widgetName,
|
|
label: widget.label,
|
|
subLabel: node.title,
|
|
rename: () => promptRenameWidget(widget, node, t)
|
|
}
|
|
})
|
|
)
|
|
const outputsWithState = computed<[NodeId, string][]>(() =>
|
|
appModeStore.selectedOutputs.map((nodeId) => [
|
|
nodeId,
|
|
app.rootGraph.getNodeById(nodeId)?.title ?? String(nodeId)
|
|
])
|
|
)
|
|
|
|
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, widget] = resolveNodeWidget(nodeId, widgetName)
|
|
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`
|
|
}
|
|
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?.mode !== LGraphEventMode.ALWAYS ||
|
|
!nodeTypeValidForApp(node.type) ||
|
|
node.has_errors
|
|
)
|
|
return canvasInteractions.forwardEventToCanvas(e)
|
|
|
|
if (!widget) {
|
|
if (!isSelectOutputsMode.value) return
|
|
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
|
|
}
|
|
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
|
|
|
|
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
|
|
const storeName = isPromotedWidgetView(widget)
|
|
? widget.sourceWidgetName
|
|
: widget.name
|
|
const index = appModeStore.selectedInputs.findIndex(
|
|
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
|
|
)
|
|
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
|
|
else appModeStore.selectedInputs.splice(index, 1)
|
|
}
|
|
|
|
function nodeToDisplayTuple(
|
|
n: LGraphNode
|
|
): [NodeId, MaybeRef<BoundStyle> | 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 &&
|
|
n.mode === LGraphEventMode.ALWAYS &&
|
|
!n.has_errors
|
|
)
|
|
.map(nodeToDisplayTuple)
|
|
})
|
|
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
|
() =>
|
|
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
|
|
`${nodeId}: ${widgetName}`,
|
|
getBounding(nodeId, widgetName)
|
|
])
|
|
)
|
|
</script>
|
|
<template>
|
|
<div class="flex h-full flex-col">
|
|
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
|
|
{{
|
|
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
|
}}
|
|
</div>
|
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
|
<DraggableList
|
|
v-if="isArrangeMode"
|
|
v-slot="{ dragClass }"
|
|
v-model="appModeStore.selectedInputs"
|
|
class="overflow-x-clip"
|
|
>
|
|
<div
|
|
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
|
|
:key="`${nodeId}: ${widgetName}`"
|
|
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
|
|
:aria-label="`${widget?.label ?? widgetName} — ${node.title}`"
|
|
>
|
|
<div v-if="widget" class="pointer-events-none" inert>
|
|
<WidgetItem
|
|
:widget="widget"
|
|
:node="node"
|
|
show-node-name
|
|
hidden-widget-actions
|
|
/>
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="pointer-events-none p-1 text-sm text-muted-foreground"
|
|
>
|
|
{{ widgetName }}
|
|
<p class="text-xs italic">
|
|
({{ t('linearMode.builder.unknownWidget') }})
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</DraggableList>
|
|
<PropertiesAccordionItem
|
|
v-if="isSelectInputsMode"
|
|
:label="t('nodeHelpPage.inputs')"
|
|
enable-empty-state
|
|
:disabled="!appModeStore.selectedInputs.length"
|
|
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
|
:tooltip-delay="100"
|
|
>
|
|
<template #label>
|
|
<div class="flex gap-3">
|
|
{{ t('nodeHelpPage.inputs') }}
|
|
<i class="icon-[lucide--info] bg-muted-foreground" />
|
|
</div>
|
|
</template>
|
|
<template #empty>
|
|
<div
|
|
class="p-4 text-muted-foreground"
|
|
v-text="t('linearMode.builder.promptAddInputs')"
|
|
/>
|
|
</template>
|
|
<DraggableList
|
|
v-slot="{ dragClass }"
|
|
v-model="appModeStore.selectedInputs"
|
|
>
|
|
<IoItem
|
|
v-for="{
|
|
nodeId,
|
|
widgetName,
|
|
label,
|
|
subLabel,
|
|
rename
|
|
} in inputsWithState"
|
|
:key="`${nodeId}: ${widgetName}`"
|
|
:class="
|
|
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
|
|
"
|
|
:title="label ?? widgetName"
|
|
:sub-title="subLabel"
|
|
:rename
|
|
:remove="
|
|
() =>
|
|
remove(
|
|
appModeStore.selectedInputs,
|
|
([id, name]) => nodeId == id && widgetName === name
|
|
)
|
|
"
|
|
/>
|
|
</DraggableList>
|
|
</PropertiesAccordionItem>
|
|
<div
|
|
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
|
|
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"
|
|
>
|
|
{{ t('linearMode.builder.inputPlaceholder') }}
|
|
</div>
|
|
<PropertiesAccordionItem
|
|
v-if="isSelectOutputsMode"
|
|
:label="t('nodeHelpPage.outputs')"
|
|
enable-empty-state
|
|
:disabled="!appModeStore.selectedOutputs.length"
|
|
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
|
|
:tooltip-delay="100"
|
|
>
|
|
<template #label>
|
|
<div class="flex gap-3">
|
|
{{ t('nodeHelpPage.outputs') }}
|
|
<i class="icon-[lucide--info] bg-muted-foreground" />
|
|
</div>
|
|
</template>
|
|
<template #empty>
|
|
<div
|
|
class="p-4 text-muted-foreground"
|
|
v-text="t('linearMode.builder.promptAddOutputs')"
|
|
/>
|
|
</template>
|
|
<DraggableList
|
|
v-slot="{ dragClass }"
|
|
v-model="appModeStore.selectedOutputs"
|
|
>
|
|
<IoItem
|
|
v-for="([key, title], index) in outputsWithState"
|
|
:key
|
|
:class="
|
|
cn(
|
|
dragClass,
|
|
'my-2 rounded-lg bg-warning-background/40 p-2',
|
|
index === 0 && 'ring-2 ring-warning-background'
|
|
)
|
|
"
|
|
:title
|
|
:sub-title="String(key)"
|
|
:remove="
|
|
() => remove(appModeStore.selectedOutputs, (k) => k == key)
|
|
"
|
|
/>
|
|
</DraggableList>
|
|
</PropertiesAccordionItem>
|
|
<div
|
|
v-if="isSelectOutputsMode && !appModeStore.selectedOutputs.length"
|
|
class="m-4 flex flex-1 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-warning-background bg-warning-background/20 text-center text-sm text-warning-background"
|
|
>
|
|
{{ t('linearMode.builder.outputPlaceholder') }}
|
|
<span class="font-bold">
|
|
{{ t('linearMode.builder.outputRequiredPlaceholder') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Teleport
|
|
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
|
|
to="body"
|
|
>
|
|
<div
|
|
:class="
|
|
cn(
|
|
'pointer-events-auto absolute size-full',
|
|
hoveringSelectable ? 'cursor-pointer' : 'cursor-grab'
|
|
)
|
|
"
|
|
@pointerdown="handleDown"
|
|
@pointermove="hoveringSelectable = !!getHovered($event)"
|
|
@click="handleClick"
|
|
@wheel="canvasInteractions.forwardEventToCanvas"
|
|
>
|
|
<TransformPane :canvas="canvasStore.getCanvas()">
|
|
<template v-if="isSelectInputsMode">
|
|
<div
|
|
v-for="[key, style] in renderedInputs"
|
|
:key
|
|
:style="toValue(style)"
|
|
class="fixed rounded-lg bg-primary-background/30"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<div
|
|
v-for="[key, style, isSelected] in renderedOutputs"
|
|
:key
|
|
:style="toValue(style)"
|
|
:class="
|
|
cn(
|
|
'fixed rounded-2xl ring-5 ring-warning-background',
|
|
!isSelected && 'ring-warning-background/50'
|
|
)
|
|
"
|
|
>
|
|
<div class="absolute top-0 right-0 size-8">
|
|
<div
|
|
v-if="isSelected"
|
|
class="pointer-events-auto absolute -top-1/2 -right-1/2 size-full cursor-pointer rounded-lg bg-warning-background p-2"
|
|
@click.stop="
|
|
remove(appModeStore.selectedOutputs, (k) => k == key)
|
|
"
|
|
@pointerdown.stop
|
|
>
|
|
<i class="bg-text-foreground icon-[lucide--check] size-full" />
|
|
</div>
|
|
<div
|
|
v-else
|
|
class="pointer-events-auto absolute -top-1/2 -right-1/2 size-full cursor-pointer rounded-lg bg-component-node-background ring-4 ring-warning-background/50 ring-inset"
|
|
@click.stop="appModeStore.selectedOutputs.push(key)"
|
|
@pointerdown.stop
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</TransformPane>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|