mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-11 00:10:40 +00:00
refactor: Unify app builder & app widget lists (#9829)
## Summary Currently app builder & app mode use slightly different rendering paths for the widgets, giving a different preview to what you actually get, this changes it to use the same path for both. ## Changes - **What**: - Extract LinearControls widget rendering - Replace app builder arrange step with this - Add ability to rename/remove widgets during app mode ## Screenshots (if applicable) <img width="1205" height="853" alt="image" src="https://github.com/user-attachments/assets/9160e33f-08c7-4863-a62d-c03929ffd3c8" /> <img width="1246" height="843" alt="image" src="https://github.com/user-attachments/assets/82d8bacd-fd32-4ad7-b09d-eaa6670590ef" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9829-refactor-Unify-app-builder-app-widget-lists-3216d73d36508157a888f08dbe3655ea) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { remove } from 'es-toolkit'
|
||||
import { computed, provide, ref, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
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'
|
||||
@@ -30,7 +30,6 @@ 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 }
|
||||
|
||||
@@ -46,19 +45,8 @@ 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)
|
||||
@@ -201,7 +189,9 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
|
||||
<div
|
||||
class="flex h-12 items-center border-b border-border-subtle px-4 font-bold"
|
||||
>
|
||||
{{
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
@@ -209,34 +199,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<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>
|
||||
<AppModeWidgetList builder-mode />
|
||||
</DraggableList>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectInputsMode"
|
||||
|
||||
212
src/components/builder/AppModeWidgetList.vue
Normal file
212
src/components/builder/AppModeWidgetList.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
nodeData: ReturnType<typeof nodeToNodeData> & {
|
||||
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
|
||||
}
|
||||
action: { widget: IBaseWidget; node: LGraphNode }
|
||||
}
|
||||
|
||||
const { mobile = false, builderMode = false } = defineProps<{
|
||||
mobile?: boolean
|
||||
builderMode?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
)
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
LGraphNode,
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
}
|
||||
const fullNodeData = nodeDataByNode.get(node)!
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
|
||||
return [
|
||||
{
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
widgets: [matchingWidget]
|
||||
},
|
||||
action: { widget, node }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
function getDropIndicator(node: LGraphNode) {
|
||||
if (node.type !== 'LoadImage') return undefined
|
||||
|
||||
const filename = node.widgets?.[0]?.value
|
||||
const resultItem = { type: 'input', filename: `${filename}` }
|
||||
|
||||
const buildImageUrl = () => {
|
||||
if (!filename) return undefined
|
||||
const params = new URLSearchParams(resultItem)
|
||||
appendCloudResParam(params, resultItem.filename)
|
||||
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
|
||||
}
|
||||
|
||||
return {
|
||||
iconClass: 'icon-[lucide--image]',
|
||||
imageUrl: buildImageUrl(),
|
||||
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
|
||||
onClick: () => node.widgets?.[1]?.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const dropIndicator = getDropIndicator(node)
|
||||
const nodeData = extractVueNodeData(node)
|
||||
|
||||
return {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
dropIndicator,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-for="{ key, nodeData, action } in mappedSelections"
|
||||
:key
|
||||
:class="
|
||||
cn(
|
||||
builderMode &&
|
||||
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
builderMode
|
||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
|
||||
builderMode && 'drag-handle'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
|
||||
>
|
||||
{{ action.widget.label || action.widget.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="builderMode"
|
||||
class="pointer-events-none mx-1 min-w-10 flex-1 truncate text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ action.node.title }}
|
||||
</span>
|
||||
<div v-else class="flex-1" />
|
||||
<Popover
|
||||
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
|
||||
:entries="[
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'icon-[lucide--pencil]',
|
||||
command: () => promptRenameWidget(action.widget, action.node, t)
|
||||
},
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () =>
|
||||
appModeStore.removeSelectedInput(action.widget, action.node)
|
||||
}
|
||||
]"
|
||||
>
|
||||
<template #button>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div
|
||||
:class="builderMode && 'pointer-events-none'"
|
||||
:inert="builderMode || undefined"
|
||||
>
|
||||
<DropZone
|
||||
:on-drag-over="nodeData.onDragOver"
|
||||
:on-drag-drop="nodeData.onDragDrop"
|
||||
:drop-indicator="nodeData.dropIndicator"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
<NodeWidgets
|
||||
:node-data
|
||||
:class="
|
||||
cn(
|
||||
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
|
||||
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</DropZone>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user