mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 22:20:03 +00:00
## Summary Various fixes from app mode QA ## Changes - **What**: - fix: prevent inserting nodes from workflow/apps sidebar tabs - fix: hide json extension in workflow tab - fix: hide apps nav button in apps tab when already in apps mode - fix: center text on arrange page - fix: prevent IoItems from "jumping" due to stale transform after drag and drop op - fix: refactor side panels and add custom stable pixel based sizing - fix: make outputs/inputs lists in app builder scrollable - fix: fix rerun not working correctly - feat: add text to interrupt button - feat: add enter app mode button to builder toolbar - feat: add tooltip to download button on linear view - feat: show last output of workflow in arrange tab if available - feat: show download count in download all button, hide if only 1 asset to download ## Review Focus - Rerun - I am not sure why it was triggering widget actions, removing it seemed like the correct fix - useStablePrimeVueSplitter - this is a workaround for the fact it uses percent sizing, I also tried switching to reka-ui splitters, but they also only support % sizing in our version [pixel based looks to have been added in a newer version, will log an issue to upgrade & replace splitters with this] ## Screenshots (if applicable) <img width="1314" height="1129" alt="image" src="https://github.com/user-attachments/assets/c430f9d6-7c29-4853-803e-5b6fe7086fca" /> <img width="511" height="283" alt="image" src="https://github.com/user-attachments/assets/b7e594d4-70a1-41e3-8ba1-78512f2a5c8b" /> <img width="254" height="232" alt="image" src="https://github.com/user-attachments/assets/1d146399-39ea-4b0e-928c-340b74957535" /> <img width="487" height="198" alt="image" src="https://github.com/user-attachments/assets/e2ba7f5d-8ff5-47f4-9526-61ebb99514b8" /> <img width="378" height="647" alt="image" src="https://github.com/user-attachments/assets/a47a3054-9320-4327-bdc0-b0a16e19f83d" /> <img width="1016" height="476" alt="image" src="https://github.com/user-attachments/assets/479ae50e-d380-4d56-a5c9-5df142b14ed0" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9439-feat-fix-App-mode-QA-updates-31a6d73d365081b38337d63207b88817) by [Unito](https://www.unito.io)
408 lines
14 KiB
Vue
408 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 { 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 { useAppMode } from '@/composables/useAppMode'
|
|
import { useAppModeStore } from '@/stores/appModeStore'
|
|
import { resolveNode } 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 = resolveNode(nodeId)
|
|
if (!node) return null
|
|
const widget = node.widgets?.find((w) => w.name === widgetName)
|
|
return { nodeId, widgetName, node, widget }
|
|
})
|
|
.filter((item): item is NonNullable<typeof item> => item !== null)
|
|
)
|
|
|
|
const inputsWithState = computed(() =>
|
|
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
|
|
const node = resolveNode(nodeId)
|
|
const widget = node?.widgets?.find((w) => w.name === widgetName)
|
|
if (!node || !widget) {
|
|
return {
|
|
nodeId,
|
|
widgetName,
|
|
subLabel: t('linearMode.builder.unknownWidget')
|
|
}
|
|
}
|
|
|
|
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 (!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) 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<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)
|
|
.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="min-h-0 flex-1 overflow-y-auto">
|
|
<DraggableList
|
|
v-if="isArrangeMode"
|
|
v-slot="{ dragClass }"
|
|
v-model="appModeStore.selectedInputs"
|
|
>
|
|
<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"
|
|
class="border-b border-border-subtle"
|
|
: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--circle-alert] bg-muted-foreground" />
|
|
</div>
|
|
</template>
|
|
<template #empty>
|
|
<div
|
|
class="w-full p-4 pt-2 text-muted-foreground"
|
|
v-text="t('linearMode.builder.promptAddInputs')"
|
|
/>
|
|
</template>
|
|
<div
|
|
class="w-full p-4 pt-2 text-muted-foreground"
|
|
v-text="t('linearMode.builder.promptAddInputs')"
|
|
/>
|
|
<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>
|
|
<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--circle-alert] bg-muted-foreground" />
|
|
</div>
|
|
</template>
|
|
<template #empty>
|
|
<div
|
|
class="w-full p-4 pt-2 text-muted-foreground"
|
|
v-text="t('linearMode.builder.promptAddOutputs')"
|
|
/>
|
|
</template>
|
|
<div
|
|
class="w-full p-4 pt-2 text-muted-foreground"
|
|
v-text="t('linearMode.builder.promptAddOutputs')"
|
|
/>
|
|
<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>
|
|
</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>
|