Compare commits

...

15 Commits

Author SHA1 Message Date
Austin Mroz
85d66f84d2 Fix selection indicator displaying outside builder 2026-02-26 15:41:41 -08:00
Austin Mroz
d6eade60b6 Check value of ref 2026-02-26 14:58:15 -08:00
Austin Mroz
6eb478dc0f Merge pysssss/app-builder-preview 2026-02-26 14:23:15 -08:00
Austin Mroz
05f866335b Revert "Show inputs during preview step"
Separate PR was opened which was cleaner
2026-02-26 14:11:19 -08:00
Austin Mroz
c71e8b9c5e Design nits
- Shorter tooltip delay
- Never show sidebar in builder mode
- Prefer widget name over node name
- Non-breaking space in example node names
- Remove node background colors
2026-02-26 12:49:12 -08:00
pythongosssss
f3120168ef - render app builder in arrange mode
- add orderable widget list
- fix splitter layout issues
2026-02-26 12:41:26 -08:00
Austin Mroz
6acb3a1e49 Show inputs with re-ordering during preview step 2026-02-26 12:41:17 -08:00
Austin Mroz
6c4c9bf58e Support displaying nodes not in the root graph 2026-02-26 12:41:14 -08:00
Austin Mroz
afb2721ef9 Further input indicator iteration 2026-02-26 12:41:11 -08:00
Austin Mroz
4cacdec310 Can toggle selection by outside corner 2026-02-26 12:41:08 -08:00
Austin Mroz
b1c10073f9 Add vue app output selection support 2026-02-26 12:41:05 -08:00
Austin Mroz
297fde5934 Remove subgraphId tracking from widgetValueStore 2026-02-26 12:41:02 -08:00
Austin Mroz
4669c922bc Store inputs by locator id.
Mixed feelings here.
2026-02-26 12:40:59 -08:00
Austin Mroz
1ac110024f Minimal widget promotion 2026-02-26 12:40:56 -08:00
Austin Mroz
a257df9875 Context handler on widgets 2026-02-26 12:40:50 -08:00
18 changed files with 390 additions and 97 deletions

View File

@@ -18,7 +18,7 @@
<Splitter
:key="splitterRefreshKey"
class="bg-transparent pointer-events-none border-none flex-1 overflow-hidden"
:state-key="sidebarStateKey"
:state-key="isSelectMode ? 'builder-splitter' : sidebarStateKey"
state-storage="local"
@resizestart="onResizestart"
>
@@ -35,8 +35,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'left' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="firstPanelStyle"
:role="sidebarLocation === 'left' ? 'complementary' : undefined"
:aria-label="
@@ -54,7 +56,7 @@
</SplitterPanel>
<!-- Main panel (always present) -->
<SplitterPanel :size="80" class="flex flex-col">
<SplitterPanel :size="CENTER_PANEL_SIZE" class="flex flex-col">
<slot name="topmenu" :sidebar-panel-visible />
<Splitter
@@ -95,8 +97,10 @@
)
: 'bg-comfy-menu-bg pointer-events-auto'
"
:min-size="sidebarLocation === 'right' ? 10 : 15"
:size="20"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
"
:size="SIDE_PANEL_SIZE"
:style="lastPanelStyle"
:role="sidebarLocation === 'right' ? 'complementary' : undefined"
:aria-label="
@@ -123,8 +127,14 @@ import SplitterPanel from 'primevue/splitterpanel'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -145,15 +155,17 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const { mode } = useAppMode()
const { isSelectMode, isBuilderMode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || mode.value === 'builder:select'
() => rightSidePanelVisible.value || isSelectMode.value
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
const sidebarPanelVisible = computed(
() => activeSidebarTab.value !== null && !isBuilderMode.value
)
const sidebarStateKey = computed(() => {
return unifiedWidth.value
@@ -174,7 +186,7 @@ function onResizestart({ originalEvent: event }: SplitterResizeStartEvent) {
* to recalculate the width and panel order
*/
const splitterRefreshKey = computed(() => {
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}-${sidebarLocation.value}`
return `main-splitter${rightSidePanelVisible.value ? '-with-right-panel' : ''}${isSelectMode.value ? '-builder' : ''}-${sidebarLocation.value}`
})
const firstPanelStyle = computed(() => {

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, ref, toValue } from 'vue'
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 Button from '@/components/ui/button/Button.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
@@ -23,8 +24,11 @@ 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 { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -36,13 +40,27 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isArrangeMode } = useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = 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 null
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const node = app.rootGraph.getNodeById(nodeId)
const node = getNodeByLocatorId(app.rootGraph, String(nodeId))
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) return { nodeId, widgetName }
@@ -179,17 +197,42 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</script>
<template>
<div class="flex font-bold p-2 border-border-subtle border-b items-center">
{{ t('linearMode.builder.title') }}
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
<Button class="ml-auto" @click="appModeStore.exitBuilder">
{{ t('linearMode.builder.exit') }}
</Button>
</div>
<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, 'p-2 my-2 pointer-events-auto')"
:aria-label="`${widget.label ?? widgetName} ${node.title}`"
>
<div class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
</div>
</DraggableList>
<PropertiesAccordionItem
v-else
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
class="border-border-subtle border-b"
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
@@ -232,10 +275,12 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="!isArrangeMode"
: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">
@@ -274,7 +319,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<Teleport to="body">
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"
to="body"
>
<div
:class="
cn(
@@ -308,13 +356,19 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k === key)
"
@pointerdown.stop
>
<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"
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</div>

View File

@@ -33,8 +33,11 @@ const entries = computed(() => {
</script>
<template>
<div class="p-2 my-2 rounded-lg flex items-center-safe">
<span class="mr-auto" v-text="title" />
<span class="text-muted-foreground mr-2 text-end" v-text="subTitle" />
<span class="mr-auto truncate shrink-1" v-text="title" />
<span
class="text-muted-foreground mr-2 text-end truncate shrink-3"
v-text="subTitle"
/>
<Popover :entries>
<template #button>
<Button variant="muted-textonly">

View File

@@ -11,6 +11,7 @@ const {
enableEmptyState,
tooltip,
size = 'default',
tooltipDelay = 1000,
class: className
} = defineProps<{
disabled?: boolean
@@ -18,6 +19,7 @@ const {
enableEmptyState?: boolean
tooltip?: string
size?: 'default' | 'lg'
tooltipDelay?: number
class?: string
}>()
@@ -27,7 +29,7 @@ const isExpanded = computed(() => !isCollapse.value && !disabled)
const tooltipConfig = computed(() => {
if (!tooltip) return undefined
return { value: tooltip, showDelay: 1000 }
return { value: tooltip, showDelay: tooltipDelay }
})
</script>

View File

@@ -16,10 +16,7 @@ import {
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
@@ -34,6 +31,7 @@ const {
node,
isDraggable = false,
hiddenFavoriteIndicator = false,
hiddenWidgetActions = false,
showNodeName = false,
parents = [],
isShownOnParents = false
@@ -42,6 +40,7 @@ const {
node: LGraphNode
isDraggable?: boolean
hiddenFavoriteIndicator?: boolean
hiddenWidgetActions?: boolean
showNodeName?: boolean
parents?: SubgraphNode[]
isShownOnParents?: boolean
@@ -73,7 +72,7 @@ function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const bareNodeId = String(sourceNode.id)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
@@ -170,7 +169,10 @@ const displayLabel = customRef((track, trigger) => {
>
{{ sourceNodeName }}
</span>
<div class="flex items-center gap-1 shrink-0 pointer-events-auto">
<div
v-if="!hiddenWidgetActions"
class="flex items-center gap-1 shrink-0 pointer-events-auto"
>
<WidgetActions
v-model:label="displayLabel"
:widget="widget"

View File

@@ -222,13 +222,9 @@ function safeWidgetMapper(
read_only: widget.options.read_only
}
: undefined
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const localId = isPromotedWidgetView(widget)
const nodeId = isPromotedWidgetView(widget)
? widget.sourceNodeId
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name

View File

@@ -1,7 +1,12 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
NodeId
} from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getExtraOptionsForWidget } from '@/services/litegraphService'
import { isLGraphGroup } from '@/utils/litegraphUtil'
@@ -46,7 +51,7 @@ export enum BadgeVariant {
// Global singleton for NodeOptions component reference
let nodeOptionsInstance: null | NodeOptionsInstance = null
const hoveredWidgetName = ref<string>()
const hoveredWidget = ref<[string, NodeId | undefined]>()
/**
* Toggle the node options popover
@@ -63,14 +68,12 @@ export function toggleNodeOptions(event: Event) {
* Use this for contextmenu events where we always want to show at the new position
* @param event - The trigger event (must be MouseEvent for position)
*/
export function showNodeOptions(event: MouseEvent) {
hoveredWidgetName.value = undefined
const target = event.target
if (target instanceof HTMLElement) {
const widgetEl = target.closest('.lg-node-widget')
if (widgetEl instanceof HTMLElement)
hoveredWidgetName.value = widgetEl.dataset.widgetName
}
export function showNodeOptions(
event: MouseEvent,
widgetName?: string,
nodeId?: NodeId
) {
hoveredWidget.value = widgetName ? [widgetName, nodeId] : undefined
if (nodeOptionsInstance?.show) {
nodeOptionsInstance.show(event)
}
@@ -259,8 +262,16 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const rawName = hoveredWidgetName.value
const widget = node?.widgets?.find((w) => w.name === rawName)
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -16,8 +16,10 @@ export function useAppMode() {
)
const isBuilderMode = computed(
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
() => isSelectMode.value || isArrangeMode.value
)
const isSelectMode = computed(() => mode.value === 'builder:select')
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
@@ -36,6 +38,8 @@ export function useAppMode() {
mode,
enableAppBuilder,
isBuilderMode,
isSelectMode,
isArrangeMode,
isAppMode,
isGraphMode,
setMode

View File

@@ -0,0 +1,11 @@
/** Default panel size (%) for sidebar and builder panels */
export const SIDE_PANEL_SIZE = 20
/** Default panel size (%) for the center/main panel */
export const CENTER_PANEL_SIZE = 80
/** Minimum panel size (%) for the sidebar */
export const SIDEBAR_MIN_SIZE = 10
/** Minimum panel size (%) for the builder panel */
export const BUILDER_MIN_SIZE = 15

View File

@@ -9,10 +9,7 @@ import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -83,7 +80,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
this.bareNodeId = stripGraphPrefix(nodeId)
this.bareNodeId = nodeId
}
get node(): SubgraphNode {

View File

@@ -3035,11 +3035,11 @@
"promptAddInputs": "Click on node parameters to add them here as inputs",
"noInputs": "No inputs added yet",
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
"inputsExample": "Examples: \u201cLoad image\u201d, \u201cText prompt\u201d, \u201cSteps\u201d",
"inputsExample": "Examples: \u201cLoad\u00a0image\u201d, \u201cText\u00a0prompt\u201d, \u201cSteps\u201d",
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: \u201cSave Image\u201d or \u201cSave Video\u201d"
"outputsExample": "Examples: \u201cSave\u00a0Image\u201d or \u201cSave\u00a0Video\u201d"
},
"queue": {
"clickToClear": "Click to clear queue",

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { mode } = useAppMode()
const appModeStore = useAppModeStore()
const { id, name } = defineProps<{ id: string; name: string }>()
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="mode === 'builder:select'"
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
>
<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,54 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { isSelectMode } = useAppMode()
const appModeStore = useAppModeStore()
const { id } = defineProps<{ id: string }>()
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
v-if="isSelectMode"
: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="togglePromotion"
>
<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"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="flex flex-col justify-center items-center relative"
@@ -284,6 +288,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'

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">
<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>
@@ -84,11 +86,14 @@ import type {
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import { useSettingStore } from '@/platform/settings/settingStore'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
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
@@ -98,10 +103,7 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
@@ -134,6 +136,8 @@ function handleBringToFront() {
}
}
const { handleNodeRightClick } = useNodeEventHandlers()
// Error boundary implementation
const renderError = ref<string | null>(null)
@@ -159,9 +163,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
@@ -191,7 +197,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const { slotMetadata } = widget
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const bareWidgetId = widget.nodeId ?? nodeId
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, widget.name)
: undefined
@@ -236,15 +242,27 @@ 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(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: widget.nodeId ?? nodeId,
name: widget.name,
type: widget.type,
vueComponent,

View File

@@ -15,14 +15,6 @@ import type {
*/
type WidgetKey = `${NodeId}:${string}`
/**
* Strips graph/subgraph prefixes from a scoped node ID to get the bare node ID.
* e.g., "graph1:subgraph2:42" → "42"
*/
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
}
export interface WidgetState<
TValue = unknown,
TType extends string = string,

View File

@@ -6,6 +6,7 @@ import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
@@ -20,13 +21,20 @@ import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBa
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppMode } from '@/composables/useAppMode'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { isBuilderMode, isArrangeMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
@@ -34,17 +42,34 @@ const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
// Builder panel is always on the opposite side of the sidebar.
// In arrange mode we render 3 panels to match the overlay structure,
// so the same stateKey percentage maps to the same pixel width.
const showLeftBuilder = computed(
() => !sidebarOnLeft.value && isArrangeMode.value
)
const showRightBuilder = computed(
() => sidebarOnLeft.value && isArrangeMode.value
)
const hasLeftPanel = computed(
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && activeTab.value) ||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
)
const hasRightPanel = computed(
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
(!sidebarOnLeft.value && activeTab.value)
)
function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
if (isBuilder) return BUILDER_MIN_SIZE
if (isHidden) return undefined
return SIDEBAR_MIN_SIZE
}
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
@@ -52,7 +77,7 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
<template>
<div class="absolute w-full h-full">
<div
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full"
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
>
<div class="flex h-full items-center">
<WorkflowTabs />
@@ -82,31 +107,47 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</div>
<Splitter
v-else
class="h-[calc(100%-38px)] w-full bg-comfy-menu-secondary-bg"
:pt="{ gutter: { class: 'bg-transparent w-4 -mx-1' } }"
:key="isArrangeMode ? 'arrange' : 'normal'"
class="h-[calc(100%-var(--workflow-tabs-height))] w-full border-none bg-comfy-menu-secondary-bg"
:state-key="isArrangeMode ? 'builder-splitter' : undefined"
:state-storage="isArrangeMode ? 'local' : undefined"
@resizestart="({ originalEvent }) => originalEvent.preventDefault()"
>
<SplitterPanel
v-if="hasLeftPanel"
id="linearLeftPanel"
:size="1"
class="min-w-min outline-none"
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
:style="
showRightBuilder && !activeTab ? { display: 'none' } : undefined
"
:class="
cn(
'outline-none arrange-panel',
showLeftBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
)
"
>
<div v-if="showLeftBuilder" class="h-full overflow-y-auto">
<AppBuilder />
</div>
<div
v-if="sidebarOnLeft && activeTab"
v-else-if="sidebarOnLeft && activeTab"
class="flex h-full border-border-subtle border-r"
>
<ExtensionSlot :extension="activeTab" />
</div>
<LinearControls
v-else
v-else-if="!isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
/>
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
:size="98"
:size="isArrangeMode ? CENTER_PANEL_SIZE : 98"
class="flex flex-col min-w-0 gap-4 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
>
<LinearProgressBar
@@ -139,11 +180,23 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
<SplitterPanel
v-if="hasRightPanel"
id="linearRightPanel"
:size="1"
class="min-w-min outline-none"
:size="isArrangeMode ? SIDE_PANEL_SIZE : 1"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
:class="
cn(
'outline-none arrange-panel',
showRightBuilder ? 'bg-comfy-menu-bg min-w-78' : 'min-w-min'
)
"
>
<div v-if="showRightBuilder" class="h-full overflow-y-auto">
<AppBuilder />
</div>
<LinearControls
v-if="sidebarOnLeft"
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomRightRef) ?? undefined"
/>
@@ -157,3 +210,21 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</Splitter>
</div>
</template>
<style scoped>
:deep(.p-splitter-gutter) {
pointer-events: auto;
}
:deep(.p-splitter-gutter:hover),
:deep(.p-splitter-gutter[data-p-gutter-resizing='true']) {
transition: background-color 0.2s ease 300ms;
background-color: var(--p-primary-color);
}
/* Hide gutter next to hidden arrange panels */
:deep(.arrange-panel[style*='display: none'] + .p-splitter-gutter),
:deep(.p-splitter-gutter + .arrange-panel[style*='display: none']) {
display: none;
}
</style>