Add layout templates, arrange UI & zone drag/drop

Introduce a new arrange/builder layout system: add layoutTemplates, an ArrangeLayout view, LayoutZoneGrid, LayoutTemplateSelector and ZoneResizeHandle components to render configurable grid templates and support live resizing. Implement drag/drop and reorder behavior with new directives/composables (useZoneDrop, useZoneReorder, useWidgetReorder) plus a useZoneWidgets composable and unit tests. Enable dragging of widgets, outputs and run-controls between zones, zone reordering, and item-level reordering; persist grid overrides via appModeStore. Also update AppModeWidgetList to adjust removal logic, and apply small related changes to linear-mode renderer, stores, litegraph utilities and locale/test files to integrate the new features.
This commit is contained in:
Koshi
2026-03-20 03:49:59 +01:00
parent 4d57c41fdb
commit 3d0a145061
22 changed files with 2559 additions and 207 deletions

View File

@@ -137,21 +137,6 @@ function nodeToNodeData(node: LGraphNode) {
onDragOver: node.onDragOver
}
}
async function handleDragDrop(e: DragEvent) {
for (const { nodeData } of mappedSelections.value) {
if (!nodeData?.onDragOver?.(e)) continue
const rawResult = nodeData?.onDragDrop?.(e)
if (rawResult === false) continue
e.stopPropagation()
e.preventDefault()
if ((await rawResult) === true) return
}
}
defineExpose({ handleDragDrop })
</script>
<template>
<div
@@ -200,8 +185,13 @@ defineExpose({ handleDragDrop })
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
command: () => {
const idx = appModeStore.selectedInputs.findIndex(
([nId, wName]) =>
nId === action.node.id && wName === action.widget.name
)
if (idx !== -1) appModeStore.selectedInputs.splice(idx, 1)
}
}
]"
>

View File

@@ -0,0 +1,296 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import LayoutZoneGrid from '@/components/builder/LayoutZoneGrid.vue'
import { getTemplate } from '@/components/builder/layoutTemplates'
import {
OUTPUT_ZONE_KEY,
useArrangeZoneWidgets
} from '@/components/builder/useZoneWidgets'
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
import {
vOutputDraggable,
vRunControlsDraggable,
vWidgetDraggable,
vZoneDropTarget
} from '@/components/builder/useZoneDrop'
import {
vZoneReorderDraggable,
vZoneReorderDropTarget
} from '@/components/builder/useZoneReorder'
import { vZoneItemReorderTarget } from '@/components/builder/useWidgetReorder'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Button from '@/components/ui/button/Button.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { resolveNode } from '@/utils/litegraphUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const commandStore = useCommandStore()
const settingStore = useSettingStore()
const queueSettingsStore = useQueueSettingsStore()
const template = computed(
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
)
const zoneWidgets = useArrangeZoneWidgets()
const runControlsZoneId = computed(() => {
if (appModeStore.runControlsZoneId) return appModeStore.runControlsZoneId
const zones = template.value.zones
const inputZones = zones.filter((z) => !z.isOutput)
return inputZones.at(-1)?.id ?? zones.at(-1)?.id ?? ''
})
onMounted(() => appModeStore.autoAssignInputs())
async function runPrompt(e: Event) {
const commandId =
e instanceof MouseEvent && e.shiftKey
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
try {
await commandStore.execute(commandId, {
metadata: { subscribe_to_run: false, trigger_source: 'linear' }
})
} catch (err) {
useToastStore().addAlert(t('linearMode.arrange.queueFailed'))
}
}
/** Zone number for non-empty zones (1-based, skips empty). */
const zoneNumbers = computed(() => {
const map = new Map<string, number>()
let num = 1
for (const zone of template.value.zones) {
const items = getOrderedItems(zone.id)
if (items.length > 0) {
map.set(zone.id, num++)
}
}
return map
})
/** Pre-computed output nodes per zone. */
const zoneOutputs = computed(() => {
const map = new Map<string, { nodeId: NodeId; node: LGraphNode }[]>()
for (const zone of template.value.zones) {
const outputs = appModeStore.selectedOutputs
.filter((nodeId) => {
const assigned = appModeStore.getZone(nodeId, OUTPUT_ZONE_KEY)
if (assigned) return assigned === zone.id
return zone.isOutput ?? false
})
.map((nodeId) => {
const node = resolveNode(nodeId)
return node ? { nodeId, node } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
map.set(zone.id, outputs)
}
return map
})
/** Lookup maps for rendering items by key. */
const outputsByKey = computed(() => {
const map = new Map<string, { nodeId: NodeId; node: LGraphNode }>()
for (const [, outputs] of zoneOutputs.value) {
for (const o of outputs) map.set(`output:${o.nodeId}`, o)
}
return map
})
const widgetsByKey = computed(() => {
const map = new Map<string, ResolvedArrangeWidget>()
for (const [, widgets] of zoneWidgets.value) {
for (const w of widgets) map.set(`input:${w.nodeId}:${w.widgetName}`, w)
}
return map
})
/** Unified ordered item list per zone. */
function getOrderedItems(zoneId: string) {
const outputs = zoneOutputs.value.get(zoneId) ?? []
const widgets = zoneWidgets.value.get(zoneId) ?? []
const hasRun = zoneId === runControlsZoneId.value
return appModeStore.getZoneItems(zoneId, outputs, widgets, hasRun)
}
</script>
<template>
<div class="mx-auto flex size-full max-w-[90%] flex-col pt-20 pb-16">
<LayoutZoneGrid
:template="template"
:grid-overrides="appModeStore.gridOverrides"
:resizable="true"
class="min-h-0 flex-1"
>
<template #zone="{ zone }">
<div
v-zone-reorder-drop-target="zone.id"
class="size-full [&.zone-reorder-over]:rounded-xl [&.zone-reorder-over]:bg-primary-background/10 [&.zone-reorder-over]:ring-2 [&.zone-reorder-over]:ring-primary-background [&.zone-reorder-over]:ring-inset"
>
<div
v-zone-drop-target="zone.id"
:class="
cn(
'flex size-full flex-col gap-2 p-2 [&.zone-drag-over]:bg-primary-background/10 [&.zone-drag-over]:ring-2 [&.zone-drag-over]:ring-primary-background [&.zone-drag-over]:ring-inset',
(appModeStore.zoneAlign[zone.id] ?? 'top') === 'bottom' &&
'justify-end'
)
"
:data-zone-id="zone.id"
>
<!-- Zone drag handle with number badge + align toggle -->
<div class="flex shrink-0 items-center gap-2 rounded-sm py-0.5">
<div
v-if="zoneNumbers.get(zone.id)"
v-tooltip.bottom="
t('linearMode.arrange.mobileOrder', {
order: zoneNumbers.get(zone.id)
})
"
class="flex size-5 items-center justify-center rounded-full border border-muted-foreground/30 text-[10px] font-bold text-muted-foreground"
>
{{ zoneNumbers.get(zone.id) }}
</div>
<div
v-zone-reorder-draggable="zone.id"
class="flex flex-1 cursor-grab items-center justify-center text-muted-foreground/40 hover:text-muted-foreground"
>
<i class="icon-[lucide--grip-horizontal] size-4" />
</div>
<button
v-tooltip.bottom="
(appModeStore.zoneAlign[zone.id] ?? 'top') === 'top'
? t('linearMode.arrange.alignToBottom')
: t('linearMode.arrange.alignToTop')
"
class="flex size-5 cursor-pointer items-center justify-center border-0 bg-transparent p-0"
@click="appModeStore.toggleZoneAlign(zone.id)"
>
<i
class="icon-[lucide--triangle] size-4 text-muted-foreground/50 transition-transform"
:class="
(appModeStore.zoneAlign[zone.id] ?? 'top') === 'bottom'
? 'rotate-180'
: ''
"
/>
</button>
</div>
<!-- Unified item list outputs, inputs, run controls in any order -->
<template
v-for="itemKey in getOrderedItems(zone.id)"
:key="itemKey"
>
<!-- Output node -->
<div
v-if="
itemKey.startsWith('output:') && outputsByKey.get(itemKey)
"
v-output-draggable="{
nodeId: outputsByKey.get(itemKey)!.nodeId,
zone: zone.id
}"
v-zone-item-reorder-target="{
itemKey,
zone: zone.id,
order: getOrderedItems(zone.id)
}"
class="flex min-h-0 flex-1 cursor-grab items-center justify-center gap-2 rounded-lg border border-dashed border-warning-background/50 bg-warning-background/10 px-3 py-2 text-sm [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
>
<i
class="icon-[lucide--image] size-5 text-warning-background"
/>
<span>{{
outputsByKey.get(itemKey)!.node.title ||
outputsByKey.get(itemKey)!.node.type
}}</span>
</div>
<!-- Input widget -->
<div
v-else-if="
itemKey.startsWith('input:') && widgetsByKey.get(itemKey)
"
v-widget-draggable="{
nodeId: widgetsByKey.get(itemKey)!.nodeId,
widgetName: widgetsByKey.get(itemKey)!.widgetName,
zone: zone.id
}"
v-zone-item-reorder-target="{
itemKey,
zone: zone.id,
order: getOrderedItems(zone.id)
}"
class="shrink-0 cursor-grab overflow-hidden rounded-lg border border-dashed border-border-subtle p-2 [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
>
<div class="pointer-events-none" inert>
<WidgetItem
:widget="widgetsByKey.get(itemKey)!.widget"
:node="widgetsByKey.get(itemKey)!.node"
show-node-name
/>
</div>
</div>
<!-- Run controls -->
<div
v-else-if="itemKey === 'run-controls'"
v-run-controls-draggable="zone.id"
v-zone-item-reorder-target="{
itemKey,
zone: zone.id,
order: getOrderedItems(zone.id)
}"
class="flex cursor-grab flex-col items-center gap-2 rounded-lg border border-dashed border-primary-background/30 p-3 [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
>
<div class="flex w-full flex-col gap-1">
<span
class="text-xs text-muted-foreground"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="queueSettingsStore.batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-10 w-full"
/>
</div>
<Button
v-tooltip.top="t('linearMode.arrange.shiftClickPriority')"
variant="primary"
size="lg"
class="w-full"
@click="runPrompt"
>
<i class="icon-[lucide--play] size-4" />
{{ t('menu.run') }}
</Button>
</div>
</template>
<!-- Empty state -->
<div
v-if="getOrderedItems(zone.id).length === 0"
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
>
<i class="mr-2 icon-[lucide--plus] size-4" />
{{ t('linearMode.arrange.dropHere') }}
</div>
</div>
</div>
</template>
</LayoutZoneGrid>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { LayoutTemplateId } from '@/components/builder/layoutTemplates'
import { LAYOUT_TEMPLATES } from '@/components/builder/layoutTemplates'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const selected = defineModel<LayoutTemplateId>({ required: true })
</script>
<template>
<div
class="fixed top-1/2 left-4 z-1000 flex -translate-y-1/2 flex-col gap-1 rounded-2xl border border-border-default bg-base-background p-1.5 shadow-interface"
>
<button
v-for="template in LAYOUT_TEMPLATES"
:key="template.id"
:class="
cn(
'flex cursor-pointer flex-col items-center gap-0.5 rounded-lg border-2 px-2.5 py-1.5 transition-colors',
selected === template.id
? 'border-primary-background bg-primary-background/10'
: 'border-transparent bg-transparent hover:bg-secondary-background'
)
"
:aria-label="t(template.label)"
:aria-pressed="selected === template.id"
@click="selected = template.id"
>
<i :class="cn(template.icon, 'size-5')" />
<span class="text-[10px]">{{ t(template.label) }}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,281 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
GridOverride,
LayoutTemplate,
LayoutZone
} from '@/components/builder/layoutTemplates'
import { buildGridTemplate } from '@/components/builder/layoutTemplates'
import ZoneResizeHandle from '@/components/builder/ZoneResizeHandle.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
const appModeStore = useAppModeStore()
const {
template,
highlightedZone,
dashed = true,
gridOverrides,
resizable = false
} = defineProps<{
template: LayoutTemplate
highlightedZone?: string
dashed?: boolean
gridOverrides?: GridOverride
resizable?: boolean
/** Zone IDs that have content — empty zones get no border in app mode. */
filledZones?: Set<string>
}>()
defineSlots<{
zone(props: { zone: LayoutZone }): unknown
}>()
/** Local fractions for live resize feedback before persisting. */
const liveColumnFractions = ref<number[] | undefined>(undefined)
const liveRowFractions = ref<number[] | undefined>(undefined)
// Clear live fractions when template changes to avoid stale values
watch(
() => template.id,
() => {
liveColumnFractions.value = undefined
liveRowFractions.value = undefined
}
)
const effectiveOverrides = computed<GridOverride | undefined>(() => {
if (!liveColumnFractions.value && !liveRowFractions.value)
return gridOverrides
return {
...gridOverrides,
columnFractions:
liveColumnFractions.value ?? gridOverrides?.columnFractions,
rowFractions: liveRowFractions.value ?? gridOverrides?.rowFractions
}
})
const gridStyle = computed(() => {
if (isMobile.value) {
// Stack all zones vertically on mobile
const areas = template.zones.map((z) => `"${z.gridArea}"`).join(' ')
return {
gridTemplate: `${areas} / 1fr`,
gridAutoRows: 'minmax(200px, auto)'
}
}
return { gridTemplate: buildGridTemplate(template, effectiveOverrides.value) }
})
/** Parse column/row counts from the template. */
const columnCount = computed(() => {
const firstRow = template.gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('"'))
if (!firstRow) return 0
const match = firstRow.match(/"([^"]+)"/)
return match ? match[1].split(/\s+/).length : 0
})
/**
* Parse fractions from the grid template, handling minmax() expressions.
* For minmax(Xpx, Yfr), extracts the fr value Y.
*/
function parseFractions(
gridTemplate: string,
type: 'column' | 'row'
): number[] {
const lines = gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
if (type === 'column') {
const slashLine = lines.find((l) => l.startsWith('/'))
if (!slashLine) return Array.from({ length: columnCount.value }, () => 1)
// Split on spaces but keep minmax() together
const parts: string[] = []
let depth = 0
let current = ''
for (const ch of slashLine.substring(1).trim()) {
if (ch === '(') depth++
if (ch === ')') depth--
if (ch === ' ' && depth === 0) {
if (current) parts.push(current)
current = ''
} else {
current += ch
}
}
if (current) parts.push(current)
return parts.map((p) => {
const minmaxMatch = p.match(/minmax\([^,]+,\s*([\d.]+)fr\)/)
if (minmaxMatch) return parseFloat(minmaxMatch[1])
const n = parseFloat(p)
return isNaN(n) ? 1 : n
})
}
return lines
.filter((l) => l.startsWith('"'))
.map((line) => {
const match = line.match(/"[^"]+"\s+(.+)/)
if (!match) return 1
const n = parseFloat(match[1])
return isNaN(n) ? 1 : n
})
}
const defaultColumnFractions = computed(() =>
parseFractions(template.gridTemplate, 'column')
)
const defaultRowFractions = computed(() =>
parseFractions(template.gridTemplate, 'row')
)
const effectiveColumnFractions = computed(
() =>
effectiveOverrides.value?.columnFractions ?? defaultColumnFractions.value
)
const effectiveRowFractions = computed(
() => effectiveOverrides.value?.rowFractions ?? defaultRowFractions.value
)
/** Column handle positions as CSS calc() values accounting for padding and gaps. */
const columnHandles = computed(() => {
const fracs = effectiveColumnFractions.value
const total = fracs.reduce((a, b) => a + b, 0)
if (total === 0) return []
const handles: { index: number; cssLeft: string }[] = []
let cumulative = 0
const gapCount = fracs.length - 1
for (let i = 0; i < fracs.length - 1; i++) {
cumulative += fracs[i]
const pct = (cumulative / total) * 100
handles.push({
index: i,
cssLeft: `calc(12px + (100% - ${24 + gapCount * 12}px) * ${pct / 100} + ${i * 12 + 6}px)`
})
}
return handles
})
/** Row handle positions as CSS calc() values. */
const rowHandles = computed(() => {
const fracs = effectiveRowFractions.value
const total = fracs.reduce((a, b) => a + b, 0)
if (total === 0) return []
const handles: { index: number; cssTop: string }[] = []
let cumulative = 0
const gapCount = fracs.length - 1
for (let i = 0; i < fracs.length - 1; i++) {
cumulative += fracs[i]
const pct = (cumulative / total) * 100
handles.push({
index: i,
cssTop: `calc(12px + (100% - ${24 + gapCount * 12}px) * ${pct / 100} + ${i * 12 + 6}px)`
})
}
return handles
})
function onColumnResize(fractions: number[]) {
liveColumnFractions.value = fractions
}
function onRowResize(fractions: number[]) {
liveRowFractions.value = fractions
}
function onColumnResizeEnd(fractions: number[]) {
liveColumnFractions.value = undefined
const overrides = appModeStore.gridOverrides ?? {}
appModeStore.setGridOverrides({ ...overrides, columnFractions: fractions })
}
function onRowResizeEnd(fractions: number[]) {
liveRowFractions.value = undefined
const overrides = appModeStore.gridOverrides ?? {}
appModeStore.setGridOverrides({ ...overrides, rowFractions: fractions })
}
</script>
<template>
<!-- Wrapper so handles overlay above zone content (overflow-y-auto creates stacking contexts) -->
<div class="relative size-full overflow-hidden">
<!-- Grid with zones -->
<div class="grid size-full gap-3 overflow-hidden p-3" :style="gridStyle">
<div
v-for="zone in template.zones"
:key="zone.id"
:style="{ gridArea: zone.gridArea }"
:class="
cn(
'relative flex flex-col rounded-xl transition-colors',
'overflow-y-auto',
dashed
? 'border-2 border-dashed border-border-subtle'
: filledZones
? 'border-0'
: 'border-2 border-solid border-border-subtle',
highlightedZone === zone.id &&
'border-primary-background bg-primary-background/10'
)
"
:data-zone-id="zone.id"
>
<slot name="zone" :zone="zone">
<div
class="flex size-full flex-col items-center justify-center gap-2 p-4 text-sm text-muted-foreground"
>
<i class="icon-[lucide--plus] size-5" />
<span>{{ t('linearMode.arrange.dropHere') }}</span>
<span class="text-xs opacity-60">{{ t(zone.label) }}</span>
</div>
</slot>
</div>
</div>
<!-- Resize handle overlay sits above zones so stacking contexts don't block it -->
<template v-if="resizable && !isMobile">
<ZoneResizeHandle
v-for="handle in columnHandles"
:key="`col-${handle.index}`"
direction="column"
:index="handle.index"
:fractions="gridOverrides?.columnFractions ?? defaultColumnFractions"
:style="{
position: 'absolute',
top: '0',
bottom: '0',
left: handle.cssLeft
}"
@resize="onColumnResize"
@resize-end="onColumnResizeEnd"
/>
<ZoneResizeHandle
v-for="handle in rowHandles"
:key="`row-${handle.index}`"
direction="row"
:index="handle.index"
:fractions="gridOverrides?.rowFractions ?? defaultRowFractions"
:style="{
position: 'absolute',
left: '0',
right: '0',
top: handle.cssTop
}"
@resize="onRowResize"
@resize-end="onRowResizeEnd"
/>
</template>
</div>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { direction, index, fractions } = defineProps<{
direction: 'column' | 'row'
index: number
fractions: number[]
}>()
const emit = defineEmits<{
resize: [fractions: number[]]
resizeEnd: [fractions: number[]]
}>()
const dragging = ref(false)
let cleanupDrag: (() => void) | null = null
onBeforeUnmount(() => cleanupDrag?.())
function onPointerDown(e: PointerEvent) {
e.preventDefault()
const el = e.currentTarget as HTMLElement
const parent = el.parentElement
if (!parent) return
el.setPointerCapture(e.pointerId)
dragging.value = true
const startPos = direction === 'column' ? e.clientX : e.clientY
const totalSize =
direction === 'column' ? parent.clientWidth : parent.clientHeight
const startFractions = [...fractions]
const totalFr = startFractions[index] + startFractions[index + 1]
let latestFractions = startFractions
function onPointerMove(ev: PointerEvent) {
const currentPos = direction === 'column' ? ev.clientX : ev.clientY
const deltaPx = currentPos - startPos
const deltaFr =
(deltaPx / totalSize) * startFractions.reduce((a, b) => a + b, 0)
const MIN_FR = 0.25
let newLeft = Math.max(MIN_FR, startFractions[index] + deltaFr)
let newRight = totalFr - newLeft
if (newRight < MIN_FR) {
newRight = MIN_FR
newLeft = totalFr - MIN_FR
}
const newFractions = [...startFractions]
newFractions[index] = Math.round(newLeft * 100) / 100
newFractions[index + 1] = Math.round(newRight * 100) / 100
latestFractions = newFractions
emit('resize', newFractions)
}
function onPointerUp() {
dragging.value = false
cleanupDrag?.()
cleanupDrag = null
emit('resizeEnd', latestFractions)
}
el.addEventListener('pointermove', onPointerMove)
el.addEventListener('pointerup', onPointerUp)
cleanupDrag = () => {
el.removeEventListener('pointermove', onPointerMove)
el.removeEventListener('pointerup', onPointerUp)
}
}
</script>
<template>
<div
:class="
cn(
'absolute z-10 flex transition-opacity hover:opacity-100',
dragging ? 'opacity-100' : 'opacity-30',
direction === 'column'
? 'top-0 h-full w-4 -translate-x-1/2 cursor-col-resize justify-center'
: 'left-0 h-6 w-full -translate-y-1/2 cursor-row-resize items-center'
)
"
@pointerdown="onPointerDown"
>
<div
:class="
cn(
'rounded-full bg-primary-background transition-colors',
direction === 'column'
? 'mx-auto h-full w-0.5'
: 'my-auto h-0.5 w-full'
)
"
/>
</div>
</template>

View File

@@ -0,0 +1,200 @@
export type LayoutTemplateId = 'focus' | 'grid' | 'sidebar'
export interface LayoutZone {
id: string
/** i18n key for the zone label */
label: string
gridArea: string
isOutput?: boolean
}
export interface LayoutTemplate {
id: LayoutTemplateId
/** i18n key for the template label */
label: string
/** i18n key for the template description */
description: string
icon: string
gridTemplate: string
zones: LayoutZone[]
}
export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
{
id: 'focus',
label: 'linearMode.layout.templates.focus',
description: 'linearMode.layout.templates.focusDesc',
icon: 'icon-[lucide--layout-panel-left]',
gridTemplate: `
"main side1" 1fr
"main side2" 1fr
/ 2fr 1fr
`,
zones: [
{
id: 'main',
label: 'linearMode.layout.zones.main',
gridArea: 'main',
isOutput: true
},
{
id: 'side1',
label: 'linearMode.layout.zones.topRight',
gridArea: 'side1'
},
{
id: 'side2',
label: 'linearMode.layout.zones.bottomRight',
gridArea: 'side2'
}
]
},
{
id: 'grid',
label: 'linearMode.layout.templates.grid',
description: 'linearMode.layout.templates.gridDesc',
icon: 'icon-[lucide--grid-3x3]',
gridTemplate: `
"z1 z2 z3" 1fr
"z4 z5 z6" 1fr
/ 1fr 1fr 1fr
`,
zones: [
{ id: 'z1', label: 'linearMode.layout.zones.zone1', gridArea: 'z1' },
{ id: 'z2', label: 'linearMode.layout.zones.zone2', gridArea: 'z2' },
{ id: 'z3', label: 'linearMode.layout.zones.zone3', gridArea: 'z3' },
{ id: 'z4', label: 'linearMode.layout.zones.zone4', gridArea: 'z4' },
{
id: 'z5',
label: 'linearMode.layout.zones.zone5',
gridArea: 'z5',
isOutput: true
},
{ id: 'z6', label: 'linearMode.layout.zones.zone6', gridArea: 'z6' }
]
},
{
id: 'sidebar',
label: 'linearMode.layout.templates.sidebar',
description: 'linearMode.layout.templates.sidebarDesc',
icon: 'icon-[lucide--panel-right]',
gridTemplate: `
"z1 z2 sb" 1fr
"z3 z4 sb" 1fr
/ 1fr 1fr minmax(240px, 0.8fr)
`,
zones: [
{ id: 'z1', label: 'linearMode.layout.zones.zone1', gridArea: 'z1' },
{
id: 'z2',
label: 'linearMode.layout.zones.zone2',
gridArea: 'z2',
isOutput: true
},
{ id: 'z3', label: 'linearMode.layout.zones.zone3', gridArea: 'z3' },
{ id: 'z4', label: 'linearMode.layout.zones.zone4', gridArea: 'z4' },
{
id: 'sb',
label: 'linearMode.layout.zones.sidebar',
gridArea: 'sb'
}
]
}
]
export function getTemplate(id: LayoutTemplateId): LayoutTemplate | undefined {
return LAYOUT_TEMPLATES.find((t) => t.id === id)
}
export interface GridOverride {
zoneOrder?: string[]
columnFractions?: number[]
rowFractions?: number[]
}
/**
* Build a CSS grid-template string from a template and optional overrides.
* When overrides are provided, zone order and column/row fractions are adjusted.
* Returns the original gridTemplate if no overrides apply.
*/
export function buildGridTemplate(
template: LayoutTemplate,
overrides?: GridOverride
): string {
if (!overrides) return template.gridTemplate
const { zoneOrder, columnFractions, rowFractions } = overrides
// Parse the template's grid areas to determine row/column structure
const areaLines = template.gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('"'))
if (areaLines.length === 0) return template.gridTemplate
// Extract area names per row and row fractions
const rows = areaLines.map((line) => {
const match = line.match(/"([^"]+)"\s*(.*)/)
if (!match) return { areas: [] as string[], fraction: '1fr' }
const areas = match[1].split(/\s+/)
const fraction = match[2].trim() || '1fr'
return { areas, fraction }
})
// Determine unique column count from first row
const colCount = rows[0]?.areas.length ?? 0
// Apply zone order reordering if provided
let reorderedRows = rows
if (zoneOrder && zoneOrder.length > 0) {
const zoneToArea = new Map<string, string>()
for (const row of rows) {
for (const area of row.areas) {
zoneToArea.set(area, area)
}
}
// Build a mapping from old position to new position
const allAreas = rows.flatMap((r) => r.areas)
const uniqueAreas = [...new Set(allAreas)]
const reorderMap = new Map<string, string>()
for (let i = 0; i < Math.min(zoneOrder.length, uniqueAreas.length); i++) {
reorderMap.set(uniqueAreas[i], zoneOrder[i])
}
reorderedRows = rows.map((row) => ({
...row,
areas: row.areas.map((a) => reorderMap.get(a) ?? a)
}))
}
// Build row fraction strings
const rowFrStrs = reorderedRows.map((row, i) => {
if (rowFractions && i < rowFractions.length) {
return `${rowFractions[i]}fr`
}
return row.fraction
})
// Build column fraction string
let colStr: string
if (columnFractions && columnFractions.length === colCount) {
colStr = columnFractions.map((f) => `${f}fr`).join(' ')
} else {
// Extract original column definitions from the "/" line
const slashLine = template.gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('/'))
colStr = slashLine ? slashLine.substring(1).trim() : '1fr '.repeat(colCount)
}
// Assemble
const areaStrs = reorderedRows.map(
(row, i) => `"${row.areas.join(' ')}" ${rowFrStrs[i]}`
)
return `\n ${areaStrs.join('\n ')}\n / ${colStr}\n `
}

View File

@@ -0,0 +1,103 @@
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type { Directive } from 'vue'
import { useAppModeStore } from '@/stores/appModeStore'
/** Determine if cursor is in the top or bottom half of the element. */
function getEdge(el: HTMLElement, clientY: number): 'before' | 'after' {
const rect = el.getBoundingClientRect()
return clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
function clearIndicator(el: HTMLElement) {
el.classList.remove('reorder-before', 'reorder-after')
}
function setIndicator(el: HTMLElement, edge: 'before' | 'after') {
clearIndicator(el)
el.classList.add(`reorder-${edge}`)
}
/** Extract item key from drag data. */
function getDragKey(data: Record<string | symbol, unknown>): string | null {
if (data.type === 'zone-widget')
return `input:${data.nodeId}:${data.widgetName}`
if (data.type === 'zone-output') return `output:${data.nodeId}`
if (data.type === 'zone-run-controls') return 'run-controls'
return null
}
function getDragZone(data: Record<string | symbol, unknown>): string | null {
return (data.sourceZone as string) ?? null
}
// --- Unified reorder drop target ---
interface ZoneItemReorderBinding {
/** The item key for this drop target (e.g. "input:5:steps", "output:7", "run-controls"). */
itemKey: string
/** The zone this item belongs to. */
zone: string
/** The current ordered list of item keys for this zone. */
order: string[]
}
type ReorderEl = HTMLElement & {
__reorderCleanup?: () => void
__reorderValue?: ZoneItemReorderBinding
}
/**
* Unified reorder directive — any zone item (input, output, run controls)
* can be reordered relative to any other item in the same zone.
*/
export const vZoneItemReorderTarget: Directive<
HTMLElement,
ZoneItemReorderBinding
> = {
mounted(el, { value }) {
const typedEl = el as ReorderEl
typedEl.__reorderValue = value
const appModeStore = useAppModeStore()
typedEl.__reorderCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const dragKey = getDragKey(source.data)
const dragZone = getDragZone(source.data)
if (!dragKey || !dragZone) return false
// Same zone, different item
return (
dragZone === typedEl.__reorderValue!.zone &&
dragKey !== typedEl.__reorderValue!.itemKey
)
},
onDrag: ({ location }) => {
setIndicator(el, getEdge(el, location.current.input.clientY))
},
onDragEnter: ({ location }) => {
setIndicator(el, getEdge(el, location.current.input.clientY))
},
onDragLeave: () => clearIndicator(el),
onDrop: ({ source, location }) => {
clearIndicator(el)
const dragKey = getDragKey(source.data)
if (!dragKey) return
const edge = getEdge(el, location.current.input.clientY)
appModeStore.reorderZoneItem(
typedEl.__reorderValue!.zone,
dragKey,
typedEl.__reorderValue!.itemKey,
edge,
typedEl.__reorderValue!.order
)
}
})
},
updated(el, { value }) {
;(el as ReorderEl).__reorderValue = value
},
unmounted(el) {
;(el as ReorderEl).__reorderCleanup?.()
}
}

View File

@@ -0,0 +1,174 @@
import {
draggable,
dropTargetForElements
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type { Directive } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { OUTPUT_ZONE_KEY } from '@/components/builder/useZoneWidgets'
import { useAppModeStore } from '@/stores/appModeStore'
interface WidgetDragData {
type: 'zone-widget'
nodeId: NodeId
widgetName: string
sourceZone: string
}
interface OutputDragData {
type: 'zone-output'
nodeId: NodeId
sourceZone: string
}
interface RunControlsDragData {
type: 'zone-run-controls'
sourceZone: string
}
function isWidgetDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & WidgetDragData {
return data.type === 'zone-widget'
}
function isOutputDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & OutputDragData {
return data.type === 'zone-output'
}
function isRunControlsDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & RunControlsDragData {
return data.type === 'zone-run-controls'
}
interface DragBindingValue {
nodeId: NodeId
widgetName: string
zone: string
}
interface OutputDragBindingValue {
nodeId: NodeId
zone: string
}
type DragEl = HTMLElement & {
__dragCleanup?: () => void
__dragValue?: DragBindingValue
__zoneId?: string
}
type OutputDragEl = HTMLElement & {
__dragCleanup?: () => void
__dragValue?: OutputDragBindingValue
}
export const vWidgetDraggable: Directive<HTMLElement, DragBindingValue> = {
mounted(el, { value }) {
const typedEl = el as DragEl
typedEl.__dragValue = value
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => ({
type: 'zone-widget',
nodeId: typedEl.__dragValue!.nodeId,
widgetName: typedEl.__dragValue!.widgetName,
sourceZone: typedEl.__dragValue!.zone
})
})
},
updated(el, { value }) {
;(el as DragEl).__dragValue = value
},
unmounted(el) {
;(el as DragEl).__dragCleanup?.()
}
}
export const vOutputDraggable: Directive<HTMLElement, OutputDragBindingValue> =
{
mounted(el, { value }) {
const typedEl = el as OutputDragEl
typedEl.__dragValue = value
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => ({
type: 'zone-output',
nodeId: typedEl.__dragValue!.nodeId,
sourceZone: typedEl.__dragValue!.zone
})
})
},
updated(el, { value }) {
;(el as OutputDragEl).__dragValue = value
},
unmounted(el) {
;(el as OutputDragEl).__dragCleanup?.()
}
}
type RunControlsDragEl = HTMLElement & {
__dragCleanup?: () => void
__sourceZone?: string
}
export const vRunControlsDraggable: Directive<HTMLElement, string> = {
mounted(el, { value: sourceZone }) {
const typedEl = el as RunControlsDragEl
typedEl.__sourceZone = sourceZone
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => ({
type: 'zone-run-controls',
sourceZone: typedEl.__sourceZone!
})
})
},
updated(el, { value: sourceZone }) {
;(el as RunControlsDragEl).__sourceZone = sourceZone
},
unmounted(el) {
;(el as RunControlsDragEl).__dragCleanup?.()
}
}
export const vZoneDropTarget: Directive<HTMLElement, string> = {
mounted(el, { value: zoneId }) {
const typedEl = el as DragEl
typedEl.__zoneId = zoneId
const appModeStore = useAppModeStore()
typedEl.__dragCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const data = source.data
if (isWidgetDragData(data)) return data.sourceZone !== typedEl.__zoneId
if (isOutputDragData(data)) return data.sourceZone !== typedEl.__zoneId
if (isRunControlsDragData(data))
return data.sourceZone !== typedEl.__zoneId
return false
},
onDragEnter: () => el.classList.add('zone-drag-over'),
onDragLeave: () => el.classList.remove('zone-drag-over'),
onDrop: ({ source }) => {
el.classList.remove('zone-drag-over')
const data = source.data
if (isWidgetDragData(data)) {
appModeStore.setZone(data.nodeId, data.widgetName, typedEl.__zoneId!)
} else if (isOutputDragData(data)) {
appModeStore.setZone(data.nodeId, OUTPUT_ZONE_KEY, typedEl.__zoneId!)
} else if (isRunControlsDragData(data)) {
appModeStore.setRunControlsZone(typedEl.__zoneId!)
}
}
})
},
updated(el, { value: zoneId }) {
;(el as DragEl).__zoneId = zoneId
},
unmounted(el) {
;(el as DragEl).__dragCleanup?.()
}
}

View File

@@ -0,0 +1,93 @@
import {
draggable,
dropTargetForElements
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type { Directive } from 'vue'
import { getTemplate } from '@/components/builder/layoutTemplates'
import { useAppModeStore } from '@/stores/appModeStore'
interface ZoneReorderDragData {
type: 'zone-reorder'
zoneId: string
}
function isZoneReorderDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & ZoneReorderDragData {
return data.type === 'zone-reorder'
}
type ReorderEl = HTMLElement & {
__dragCleanup?: () => void
__zoneId?: string
}
/** Directive to make a zone header draggable for reordering. */
export const vZoneReorderDraggable: Directive<HTMLElement, string> = {
mounted(el, { value: zoneId }) {
const typedEl = el as ReorderEl
typedEl.__zoneId = zoneId
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => ({
type: 'zone-reorder',
zoneId: typedEl.__zoneId!
})
})
},
updated(el, { value: zoneId }) {
;(el as ReorderEl).__zoneId = zoneId
},
unmounted(el) {
;(el as ReorderEl).__dragCleanup?.()
}
}
/** Directive to make a zone a drop target for reordering. */
export const vZoneReorderDropTarget: Directive<HTMLElement, string> = {
mounted(el, { value: zoneId }) {
const typedEl = el as ReorderEl
typedEl.__zoneId = zoneId
const appModeStore = useAppModeStore()
typedEl.__dragCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const data = source.data
return isZoneReorderDragData(data) && data.zoneId !== typedEl.__zoneId
},
onDragEnter: () => el.classList.add('zone-reorder-over'),
onDragLeave: () => el.classList.remove('zone-reorder-over'),
onDrop: ({ source }) => {
el.classList.remove('zone-reorder-over')
const data = source.data
if (!isZoneReorderDragData(data)) return
const current = appModeStore.gridOverrides?.zoneOrder
const tmpl = getTemplate(appModeStore.layoutTemplateId)
if (!tmpl) return
const order = current ?? tmpl.zones.map((z) => z.id)
const fromIdx = order.indexOf(data.zoneId)
const toIdx = order.indexOf(typedEl.__zoneId!)
if (fromIdx === -1 || toIdx === -1) return
const newOrder = [...order]
newOrder.splice(fromIdx, 1)
newOrder.splice(toIdx, 0, data.zoneId)
appModeStore.setGridOverrides({
...appModeStore.gridOverrides,
zoneOrder: newOrder
})
}
})
},
updated(el, { value: zoneId }) {
;(el as ReorderEl).__zoneId = zoneId
},
unmounted(el) {
;(el as ReorderEl).__dragCleanup?.()
}
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
extractVueNodeData: vi.fn()
}))
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
isPromotedWidgetView: vi.fn()
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
LGraphEventMode: { ALWAYS: 0 }
}))
vi.mock('@/utils/litegraphUtil', () => ({
resolveNodeWidget: vi.fn()
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn()
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: vi.fn()
}))
import { inputsForZone } from './useZoneWidgets'
describe('useZoneWidgets', () => {
describe('inputsForZone', () => {
const inputs: [NodeId, string][] = [
[1, 'prompt'],
[2, 'width'],
[1, 'steps'],
[3, 'seed']
]
function makeGetZone(
assignments: Record<string, string>
): (nodeId: NodeId, widgetName: string) => string | undefined {
return (nodeId, widgetName) => assignments[`${nodeId}:${widgetName}`]
}
it('returns inputs matching the given zone', () => {
const getZone = makeGetZone({
'1:prompt': 'z1',
'2:width': 'z2',
'1:steps': 'z1',
'3:seed': 'z2'
})
const result = inputsForZone(inputs, getZone, 'z1')
expect(result).toEqual([
[1, 'prompt'],
[1, 'steps']
])
})
it('returns empty array when no inputs match', () => {
const getZone = makeGetZone({
'1:prompt': 'z1',
'2:width': 'z1'
})
const result = inputsForZone(inputs, getZone, 'z2')
expect(result).toEqual([])
})
it('handles empty inputs', () => {
const getZone = makeGetZone({})
expect(inputsForZone([], getZone, 'z1')).toEqual([])
})
it('handles unassigned inputs (getZone returns undefined)', () => {
const getZone = makeGetZone({ '1:prompt': 'z1' })
// Only 1:prompt is assigned to z1; rest are undefined
const result = inputsForZone(inputs, getZone, 'z1')
expect(result).toEqual([[1, 'prompt']])
})
it('filters non-contiguous inputs for the same node across zones', () => {
const getZone = makeGetZone({
'1:prompt': 'z1',
'2:width': 'z2',
'1:steps': 'z2', // same node 1, different zone
'3:seed': 'z1'
})
const z1 = inputsForZone(inputs, getZone, 'z1')
const z2 = inputsForZone(inputs, getZone, 'z2')
expect(z1).toEqual([
[1, 'prompt'],
[3, 'seed']
])
expect(z2).toEqual([
[2, 'width'],
[1, 'steps']
])
})
})
})

View File

@@ -0,0 +1,163 @@
import { computed } from 'vue'
import { getTemplate } from '@/components/builder/layoutTemplates'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
export const OUTPUT_ZONE_KEY = '__output__'
export interface ResolvedArrangeWidget {
nodeId: NodeId
widgetName: string
node: LGraphNode
widget: IBaseWidget
}
interface EnrichedNodeData extends VueNodeData {
hasErrors: boolean
onDragDrop?: LGraphNode['onDragDrop']
onDragOver?: LGraphNode['onDragOver']
}
export function inputsForZone(
selectedInputs: [NodeId, string][],
getZone: (nodeId: NodeId, widgetName: string) => string | undefined,
zoneId: string
): [NodeId, string][] {
return selectedInputs.filter(
([nodeId, widgetName]) => getZone(nodeId, widgetName) === zoneId
)
}
/**
* Composable for ArrangeLayout (builder/arrange mode).
* Returns a computed Map<zoneId, resolved widget items[]>.
*/
export function useArrangeZoneWidgets() {
const appModeStore = useAppModeStore()
const template = computed(
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
)
return computed(() => {
const map = new Map<string, ResolvedArrangeWidget[]>()
for (const zone of template.value.zones) {
const inputs = inputsForZone(
appModeStore.selectedInputs,
appModeStore.getZone,
zone.id
)
const resolved = inputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node && widget ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
map.set(zone.id, resolved)
}
return map
})
}
/**
* Composable for AppTemplateView (app runtime mode).
* Returns a computed Map<zoneId, enriched VueNodeData[]>.
*
* Uses Map<LGraphNode, Widget[]> grouping instead of takeWhile,
* so non-contiguous inputs for the same node are correctly merged.
*/
export function useAppZoneWidgets() {
const appModeStore = useAppModeStore()
const executionErrorStore = useExecutionErrorStore()
const template = computed(
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
)
return computed(() => {
const map = new Map<string, EnrichedNodeData[]>()
for (const zone of template.value.zones) {
const inputs = inputsForZone(
appModeStore.selectedInputs,
appModeStore.getZone,
zone.id
)
map.set(zone.id, resolveAppWidgets(inputs, executionErrorStore))
}
return map
})
}
/**
* Resolve inputs into enriched VueNodeData grouped by node.
* Uses Map-based grouping (fixes takeWhile non-contiguous bug).
* Filters out nodes with mode !== ALWAYS.
*/
function resolveAppWidgets(
inputs: [NodeId, string][],
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): EnrichedNodeData[] {
const nodeWidgetMap = new Map<LGraphNode, IBaseWidget[]>()
for (const [nodeId, widgetName] of inputs) {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) continue
if (!nodeWidgetMap.has(node)) nodeWidgetMap.set(node, [])
nodeWidgetMap.get(node)!.push(widget)
}
const result: EnrichedNodeData[] = []
for (const [node, inputGroup] of nodeWidgetMap) {
if (node.mode !== LGraphEventMode.ALWAYS) continue
const nodeData = extractVueNodeData(node)
const enriched: EnrichedNodeData = {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
const filteredWidgets = (enriched.widgets ?? []).filter(
(vueWidget: SafeWidgetData) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode())
return inputGroup.some((w) => w.name === vueWidget.name)
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return inputGroup.some(
(subWidget) =>
isPromotedWidgetView(subWidget) &&
subWidget.sourceNodeId === storeNodeId &&
subWidget.sourceWidgetName === vueWidget.storeName
)
}
)
const updatedWidgets = filteredWidgets.map((widget) => ({
...widget,
slotMetadata: undefined,
nodeId: String(node.id)
}))
result.push({ ...enriched, widgets: updatedWidgets })
}
return result
}

View File

@@ -3253,7 +3253,50 @@
"outputExamples": "Examples: 'Save Image' or 'Save Video'",
"switchToOutputsButton": "Switch to Outputs",
"outputs": "Outputs",
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app",
"layout": "Layout",
"dropHere": "Drop inputs here",
"outputZone": "Output",
"alignToBottom": "Align to bottom",
"alignToTop": "Align to top",
"mobileOrder": "Mobile: stacks top to bottom (#{order})",
"shiftClickPriority": "Shift+click to prioritize",
"queueFailed": "Failed to queue prompt"
},
"layout": {
"templates": {
"focus": "Focus",
"focusDesc": "1 large area with 2 smaller zones",
"grid": "Grid",
"gridDesc": "2x3 equal grid zones",
"sidebar": "Sidebar",
"sidebarDesc": "4 zones with a sidebar"
},
"zones": {
"main": "Main",
"topRight": "Top Right",
"bottomRight": "Bottom Right",
"zone1": "Zone 1",
"zone2": "Zone 2",
"zone3": "Zone 3",
"zone4": "Zone 4",
"zone5": "Zone 5",
"zone6": "Zone 6",
"sidebar": "Sidebar"
}
},
"error": {
"header": "Something went wrong",
"requiresGraph": "This error needs to be resolved in the node graph.",
"promptVisitGraph": "Switch to graph mode to inspect and fix the issue.",
"mobileFixable": "Some inputs need attention. Go to {0} to fix them.",
"promptShow": "Show error details",
"getHelp": "Need help? Check the {0}, report on {1}, or ask for {2}.",
"guide": "troubleshooting guide",
"github": "GitHub",
"support": "support",
"log": "Error log",
"goto": "Go to errors"
},
"builder": {
"title": "App builder mode",

View File

@@ -5,19 +5,47 @@ import type { ChangeTracker } from '@/scripts/changeTracker'
import type { AppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { UserFile } from '@/stores/userFileStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
ModelFile
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
export interface LinearData {
inputs: [NodeId, string][]
outputs: NodeId[]
layoutTemplateId?: string
/** @deprecated Use zoneAssignmentsPerTemplate instead */
zoneAssignments?: Record<string, string>
/** @deprecated Use gridOverridesPerTemplate instead */
gridOverrides?: {
zoneOrder?: string[]
columnFractions?: number[]
rowFractions?: number[]
}
/** @deprecated Use runControlsZoneIdPerTemplate instead */
runControlsZoneId?: string
zoneAssignmentsPerTemplate?: Record<string, Record<string, string>>
gridOverridesPerTemplate?: Record<
string,
{
zoneOrder?: string[]
columnFractions?: number[]
rowFractions?: number[]
}
>
runControlsZoneIdPerTemplate?: Record<string, string>
zoneItemOrderPerTemplate?: Record<string, Record<string, string[]>>
zoneAlignPerTemplate?: Record<string, Record<string, 'top' | 'bottom'>>
}
export interface PendingWarnings {
missingNodeTypes?: MissingNodeType[]
// TODO: Currently unused — missing models are surfaced directly on every
// graph load. Reserved for future per-workflow missing model state management.
missingModels?: {
missingModels: ModelFile[]
paths: Record<string, string[]>
}
missingModelCandidates?: MissingModelCandidate[]
}

View File

@@ -0,0 +1,391 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import LayoutZoneGrid from '@/components/builder/LayoutZoneGrid.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { getTemplate } from '@/components/builder/layoutTemplates'
import {
OUTPUT_ZONE_KEY,
useAppZoneWidgets
} from '@/components/builder/useZoneWidgets'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
const appModeStore = useAppModeStore()
const commandStore = useCommandStore()
const nodeOutputStore = useNodeOutputStore()
const settingStore = useSettingStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const template = computed(
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
)
const zoneWidgets = useAppZoneWidgets()
const runControlsZoneId = computed(() => {
if (appModeStore.runControlsZoneId) return appModeStore.runControlsZoneId
const zones = template.value.zones
const inputZones = zones.filter((z) => !z.isOutput)
return inputZones.at(-1)?.id ?? zones.at(-1)?.id ?? ''
})
/** Per-zone output results — each zone gets its own assigned outputs. */
const zoneOutputs = computed(() => {
const map = new Map<string, ReturnType<typeof flattenNodeOutput>>()
for (const zone of template.value.zones) {
const outputs: ReturnType<typeof flattenNodeOutput> = []
for (const nodeId of appModeStore.selectedOutputs) {
const assigned = appModeStore.getZone(nodeId, OUTPUT_ZONE_KEY)
if (!assigned && !zone.isOutput) continue
if (assigned && assigned !== zone.id) continue
const locatorId = nodeIdToNodeLocatorId(nodeId)
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
if (!nodeOutput) continue
outputs.push(...flattenNodeOutput([nodeId, nodeOutput]))
}
if (outputs.length > 0) map.set(zone.id, outputs)
}
return map
})
/** Per-zone output node count for placeholders (before results arrive). */
const zoneOutputNodeCount = computed(() => {
const counts = new Map<string, number>()
for (const nodeId of appModeStore.selectedOutputs) {
const assigned = appModeStore.getZone(nodeId, OUTPUT_ZONE_KEY)
if (assigned) {
counts.set(assigned, (counts.get(assigned) ?? 0) + 1)
}
}
// Fallback: if no explicit assignments, use default isOutput zones
if (counts.size === 0) {
for (const z of template.value.zones) {
if (z.isOutput) counts.set(z.id, appModeStore.selectedOutputs.length)
}
}
return counts
})
/** Build output items list for getZoneItems compatibility. */
function outputItemsForZone(zoneId: string) {
return appModeStore.selectedOutputs
.filter((nodeId) => {
const assigned = appModeStore.getZone(nodeId, OUTPUT_ZONE_KEY)
if (assigned) return assigned === zoneId
const zone = template.value.zones.find((z) => z.id === zoneId)
return zone?.isOutput ?? false
})
.map((nodeId) => ({ nodeId }))
}
/** Get ordered item keys for a zone respecting builder reorder. */
function getOrderedItems(zoneId: string) {
const outputs = outputItemsForZone(zoneId)
const widgets = (zoneWidgets.value.get(zoneId) ?? []).flatMap((nd) =>
(nd.widgets ?? []).map((w) => ({
nodeId: String(nd.id),
widgetName: w.name
}))
)
const hasRun = zoneId === runControlsZoneId.value
return appModeStore.getZoneItems(zoneId, outputs, widgets, hasRun)
}
/** Zones that have any content (inputs, outputs, or run controls). */
const filledZones = computed(() => {
const filled = new Set<string>()
for (const zone of template.value.zones) {
const hasWidgets = (zoneWidgets.value.get(zone.id)?.length ?? 0) > 0
const hasOutputs =
zoneOutputs.value.has(zone.id) || zoneOutputNodeCount.value.has(zone.id)
const hasRun = zone.id === runControlsZoneId.value
if (hasWidgets || hasOutputs || hasRun) filled.add(zone.id)
}
return filled
})
/** Zone IDs that appear in the bottom row of the grid template. */
const bottomRowZoneIds = computed(() => {
const lines = template.value.gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('"'))
if (lines.length === 0) return new Set<string>()
const lastRow = lines[lines.length - 1]
const match = lastRow.match(/"([^"]+)"/)
if (!match) return new Set<string>()
return new Set(match[1].split(/\s+/))
})
/** Border style for a zone using ring (inset, no overflow clipping). */
function zoneBorderClass(zoneId: string): string {
if (!filledZones.value.has(zoneId)) return ''
const isOutput =
zoneOutputs.value.has(zoneId) || zoneOutputNodeCount.value.has(zoneId)
const isBottom = bottomRowZoneIds.value.has(zoneId)
if (isOutput || isBottom)
return 'rounded-xl ring-2 ring-border-subtle ring-inset'
return 'rounded-t-xl ring-2 ring-border-subtle ring-inset'
}
async function runPrompt(e: Event) {
const commandId =
e instanceof MouseEvent && e.shiftKey
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
try {
await commandStore.execute(commandId, {
metadata: { subscribe_to_run: false, trigger_source: 'linear' }
})
} catch (err) {
useToastStore().addAlert(t('linearMode.arrange.queueFailed'))
}
}
</script>
<template>
<!-- Mobile: single stacked column -->
<div
v-if="isMobile"
class="flex size-full flex-col gap-4 overflow-y-auto p-4"
>
<template v-for="zone in template.zones" :key="zone.id">
<div
v-if="filledZones.has(zone.id)"
class="flex flex-col gap-2 rounded-xl border-2 border-solid border-border-subtle p-3"
>
<!-- Output preview -->
<div
v-if="zoneOutputs.has(zone.id) || zoneOutputNodeCount.has(zone.id)"
:class="
cn(
'overflow-hidden rounded-lg border border-warning-background/50',
(zoneOutputs.get(zone.id)?.length ?? 0) === 0 &&
'bg-warning-background/5 p-4'
)
"
>
<div
v-if="(zoneOutputs.get(zone.id)?.length ?? 0) > 0"
class="flex flex-col gap-2"
>
<MediaOutputPreview
v-for="(output, idx) in zoneOutputs.get(zone.id)"
:key="idx"
:output="output"
/>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground"
>
<i class="icon-[lucide--image] size-6 text-warning-background/50" />
<p class="text-xs">
{{ t('linearMode.arrange.resultsLabel') }}
</p>
</div>
</div>
<!-- Input widgets -->
<template
v-for="nodeData of zoneWidgets.get(zone.id) ?? []"
:key="nodeData.id"
>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
>
<NodeWidgets
:node-data
class="gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 **:[.h-7]:h-10"
/>
</DropZone>
</template>
<!-- Run controls -->
<div
v-if="zone.id === runControlsZoneId"
class="flex flex-col gap-2 border-t border-border-subtle pt-3"
>
<div class="flex w-full flex-col gap-1">
<span
class="text-xs text-muted-foreground"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-10 w-full"
/>
</div>
<Button
v-tooltip.top="t('linearMode.arrange.shiftClickPriority')"
variant="primary"
size="lg"
class="w-full"
@click="runPrompt"
>
<i class="icon-[lucide--play] size-4" />
{{ t('menu.run') }}
</Button>
</div>
</div>
</template>
</div>
<!-- Desktop: grid layout -->
<div v-else class="mx-auto flex size-full max-w-[90%] flex-col py-4">
<LayoutZoneGrid
:template="template"
:dashed="false"
:grid-overrides="appModeStore.gridOverrides"
:filled-zones="filledZones"
class="min-h-0 overflow-visible"
>
<template #zone="{ zone }">
<!-- Outer: full cell height, alignment controlled per zone -->
<div
:class="
cn(
'flex size-full min-h-0 flex-col',
(appModeStore.zoneAlign[zone.id] ?? 'top') === 'bottom'
? 'justify-end'
: 'justify-start'
)
"
>
<!-- Inner: border wraps only the content -->
<div
:class="
cn(
'flex min-h-0 flex-col gap-2 overflow-y-auto p-2',
zoneBorderClass(zone.id),
zoneOutputs.has(zone.id) || zoneOutputNodeCount.has(zone.id)
? 'flex-1'
: ''
)
"
>
<!-- Unified item order respects builder reorder -->
<template
v-for="itemKey in getOrderedItems(zone.id)"
:key="itemKey"
>
<!-- Output node -->
<div
v-if="itemKey.startsWith('output:')"
:class="
cn(
'min-h-0 flex-1 overflow-hidden rounded-lg border border-warning-background/50',
!(zoneOutputs.get(zone.id)?.length ?? 0) &&
'bg-warning-background/5 p-4'
)
"
>
<div
v-if="(zoneOutputs.get(zone.id)?.length ?? 0) > 0"
class="flex size-full flex-col gap-2"
>
<MediaOutputPreview
v-for="(output, idx) in zoneOutputs.get(zone.id)"
:key="idx"
:output="output"
class="min-h-0 flex-1"
/>
</div>
<div
v-else
class="flex size-full flex-col items-center justify-center gap-2 text-muted-foreground"
>
<i
class="icon-[lucide--image] size-6 text-warning-background/50"
/>
<p class="text-xs">
{{ t('linearMode.arrange.resultsLabel') }}
</p>
</div>
</div>
<!-- Input widget group -->
<template v-else-if="itemKey.startsWith('input:')">
<template
v-for="nodeData of zoneWidgets.get(zone.id) ?? []"
:key="nodeData.id"
>
<DropZone
v-if="
(nodeData.widgets ?? []).some(
(w) => itemKey === `input:${nodeData.id}:${w.name}`
)
"
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-3 *: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>
</template>
</template>
<!-- Run controls -->
<div
v-else-if="itemKey === 'run-controls'"
class="flex flex-col gap-2 border-t border-border-subtle pt-3"
>
<div class="flex w-full flex-col gap-1">
<span
class="text-xs text-muted-foreground"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-10 w-full"
/>
</div>
<Button
variant="primary"
size="lg"
class="w-full"
@click="runPrompt"
>
<i class="icon-[lucide--play] size-4" />
{{ t('menu.run') }}
</Button>
</div>
</template>
</div>
</div>
</template>
</LayoutZoneGrid>
</div>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, useTemplateRef } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
@@ -34,7 +34,7 @@ const { toastTo, mobile } = defineProps<{
mobile?: boolean
}>()
defineEmits<{ navigateOutputs: [] }>()
defineExpose({ runButtonClick, handleDragDrop })
defineExpose({ runButtonClick })
//NOTE: due to batching, will never be greater than 2
const pendingJobQueues = ref(0)
@@ -42,8 +42,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
8000,
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
//TODO: refactor out of this file.
//code length is small, but changes should propagate
async function runButtonClick(e: Event) {
@@ -71,9 +69,6 @@ async function runButtonClick(e: Event) {
pendingJobQueues.value -= 1
}
}
function handleDragDrop(e: DragEvent) {
return widgetListRef.value?.handleDragDrop(e)
}
</script>
<template>
<div
@@ -100,7 +95,7 @@ function handleDragDrop(e: DragEvent) {
data-testid="linear-widgets"
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
>
<AppModeWidgetList ref="widgetListRef" :mobile />
<AppModeWidgetList :mobile />
</section>
<Teleport
v-if="!jobToastTimeout || pendingJobQueues > 0"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -10,6 +10,7 @@ import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAsse
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import AppTemplateView from '@/renderer/extensions/linearMode/AppTemplateView.vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
@@ -20,11 +21,14 @@ import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { ResultItemImpl } from '@/stores/queueStore'
const { t } = useI18n()
const mediaActions = useMediaAssetActions()
const { isBuilderMode, isArrangeMode } = useAppMode()
const appModeStore = useAppModeStore()
const hasLayoutTemplate = computed(() => !!appModeStore.layoutTemplateId)
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
@@ -144,6 +148,7 @@ async function rerun(e: Event) {
/>
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearArrange v-else-if="isArrangeMode" />
<AppTemplateView v-else-if="hasLayoutTemplate" />
<LinearWelcome v-else />
<div
v-if="!mobile"

View File

@@ -12,9 +12,11 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AppTemplateView from '@/renderer/extensions/linearMode/AppTemplateView.vue'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import MobileError from '@/renderer/extensions/linearMode/MobileError.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore } from '@/stores/queueStore'
@@ -28,7 +30,11 @@ const tabs = [
['sideToolbar.assets', 'icon-[lucide--images]']
]
const appModeStore = useAppModeStore()
const canvasStore = useCanvasStore()
const useTemplateLayout = computed(
() => Object.keys(appModeStore.zoneAssignments).length > 0
)
const colorPaletteService = useColorPaletteService()
const colorPaletteStore = useColorPaletteStore()
const { isLoggedIn } = useCurrentUser()
@@ -192,7 +198,8 @@ const menuEntries = computed<MenuItem[]>(() => [
:style="{ translate }"
>
<div class="absolute h-full w-screen overflow-y-auto contain-size">
<LinearControls mobile @navigate-outputs="activeIndex = 1" />
<AppTemplateView v-if="useTemplateLayout" />
<LinearControls v-else mobile @navigate-outputs="activeIndex = 1" />
</div>
<div
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"

View File

@@ -25,7 +25,7 @@ const mockEmptyWorkflowDialog = vi.hoisted(() => {
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { extra: {}, nodes: [{ id: 1 }], events: new EventTarget() }
rootGraph: { extra: {}, nodes: [{ id: 1 }] }
}
}))
@@ -242,29 +242,6 @@ describe('appModeStore', () => {
expect(store.selectedOutputs).toEqual([1])
})
it('reloads selections on configured event', async () => {
const node1 = mockNode(1)
// Initially nodes are not resolvable — pruning removes them
mockResolveNode.mockReturnValue(undefined)
workflowStore.activeWorkflow = workflowWithLinearData([[1, 'seed']], [1])
await nextTick()
expect(store.selectedInputs).toEqual([])
expect(store.selectedOutputs).toEqual([])
// After graph configures, nodes become resolvable
mockResolveNode.mockImplementation((id) =>
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
;(app.rootGraph.events as EventTarget).dispatchEvent(
new Event('configured')
)
await nextTick()
expect(store.selectedInputs).toEqual([[1, 'seed']])
expect(store.selectedOutputs).toEqual([1])
})
it('hasOutputs is false when all output nodes are deleted', async () => {
mockResolveNode.mockReturnValue(undefined)
@@ -286,7 +263,13 @@ describe('appModeStore', () => {
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [],
outputs: [1]
outputs: [1],
layoutTemplateId: 'sidebar',
zoneAssignmentsPerTemplate: {},
gridOverridesPerTemplate: {},
runControlsZoneIdPerTemplate: {},
zoneItemOrderPerTemplate: {},
zoneAlignPerTemplate: {}
})
})
@@ -325,8 +308,170 @@ describe('appModeStore', () => {
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [[42, 'prompt']],
outputs: []
outputs: [],
layoutTemplateId: 'sidebar',
zoneAssignmentsPerTemplate: {},
gridOverridesPerTemplate: {},
runControlsZoneIdPerTemplate: {},
zoneItemOrderPerTemplate: {},
zoneAlignPerTemplate: {}
})
})
})
describe('autoAssignInputs', () => {
it('distributes inputs evenly across input zones', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([1, 'a'], [2, 'b'], [3, 'c'], [4, 'd'])
// sidebar template has 4 input zones: z1, z3, z4, sb
// and 1 output zone: z2
store.autoAssignInputs()
const zones = new Map<string, number>()
for (const [nodeId, widgetName] of store.selectedInputs) {
const z = store.getZone(nodeId, widgetName)
if (z) zones.set(z, (zones.get(z) ?? 0) + 1)
}
// Each input zone should get exactly 1 input (4 inputs / 4 input zones)
expect(zones.get('z2')).toBeUndefined() // z2 is output zone
for (const count of zones.values()) {
expect(count).toBe(1)
}
})
it('skips already-assigned inputs', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([1, 'a'], [2, 'b'])
store.setZone(1, 'a', 'z1')
store.autoAssignInputs()
expect(store.getZone(1, 'a')).toBe('z1')
// b should be assigned to some zone (not z1 since z1 already has one)
expect(store.getZone(2, 'b')).toBeDefined()
})
it('handles single input zone', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
// switch to focus template: 2 input zones (side1, side2) + 1 output (main)
store.switchTemplate('focus')
store.selectedInputs.push([1, 'a'], [2, 'b'])
store.autoAssignInputs()
expect(store.getZone(1, 'a')).toBeDefined()
expect(store.getZone(2, 'b')).toBeDefined()
})
it('does nothing with empty inputs', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.autoAssignInputs()
expect(Object.keys(store.zoneAssignments)).toHaveLength(0)
})
})
describe('switchTemplate', () => {
it('clears stale zone assignments from old template', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([1, 'a'])
store.setZone(1, 'a', 'sb') // sidebar zone
store.switchTemplate('focus')
// 'sb' is not a valid zone in focus template, so assignment should be cleared
// and autoAssign should have re-assigned it
const zone = store.getZone(1, 'a')
expect(zone).toBeDefined()
expect(zone).not.toBe('sb')
})
it('preserves valid zone assignments', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([1, 'a'])
// grid template has z1-z6
store.switchTemplate('grid')
store.setZone(1, 'a', 'z1')
// Switch to sidebar which also has z1
store.switchTemplate('sidebar')
expect(store.getZone(1, 'a')).toBe('z1')
})
it('calls autoAssign after clearing', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([1, 'a'])
store.switchTemplate('grid')
expect(store.getZone(1, 'a')).toBeDefined()
})
})
describe('outputZoneIds', () => {
it('returns default isOutput zones when no explicit assignments', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedOutputs.push(1)
// sidebar template: z2 is the default isOutput zone
expect(store.outputZoneIds.has('z2')).toBe(true)
expect(store.outputZoneIds.size).toBe(1)
})
it('returns explicit zones when outputs are assigned', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedOutputs.push(1, 2)
store.setZone(1, '__output__', 'z1')
store.setZone(2, '__output__', 'z3')
expect(store.outputZoneIds).toEqual(new Set(['z1', 'z3']))
})
it('returns empty set when no template', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.layoutTemplateId =
'nonexistent' as unknown as typeof store.layoutTemplateId
expect(store.outputZoneIds.size).toBe(0)
})
it('returns default zones when outputs exist but none are assigned', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
// focus template: main is isOutput
store.switchTemplate('focus')
store.selectedOutputs.push(1)
expect(store.outputZoneIds.has('main')).toBe(true)
expect(store.outputZoneIds.size).toBe(1)
})
})
describe('setZone', () => {
it('stores assignment and persists', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
store.setZone(1, 'prompt', 'z1')
expect(store.getZone(1, 'prompt')).toBe('z1')
const linearData = app.rootGraph.extra.linearData as Record<
string,
unknown
>
const perTemplate = linearData?.zoneAssignmentsPerTemplate as Record<
string,
Record<string, string>
>
expect(perTemplate?.[store.layoutTemplateId]).toHaveProperty(
'1:prompt',
'z1'
)
})
it('overwrites previous assignment', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
store.setZone(1, 'prompt', 'z1')
store.setZone(1, 'prompt', 'z2')
expect(store.getZone(1, 'prompt')).toBe('z2')
})
})
})

View File

@@ -1,8 +1,13 @@
import { defineStore } from 'pinia'
import { reactive, computed, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import { reactive, computed, ref, watch } from 'vue'
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
import { getTemplate } from '@/components/builder/layoutTemplates'
import type {
GridOverride,
LayoutTemplateId
} from '@/components/builder/layoutTemplates'
import { OUTPUT_ZONE_KEY } from '@/components/builder/useZoneWidgets'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
@@ -11,8 +16,6 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { resolveNode } from '@/utils/litegraphUtil'
export function nodeTypeValidForApp(type: string) {
@@ -22,11 +25,41 @@ export function nodeTypeValidForApp(type: string) {
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
const { mode, setMode, isBuilderMode, isAppMode, isSelectMode } = useAppMode()
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const selectedInputs = reactive<[NodeId, string][]>([])
const selectedOutputs = reactive<NodeId[]>([])
const layoutTemplateId = ref<LayoutTemplateId>('sidebar')
const zoneAssignmentsPerTemplate = reactive<
Record<string, Record<string, string>>
>({})
const gridOverridesPerTemplate = reactive<Record<string, GridOverride>>({})
const runControlsZoneIdPerTemplate = reactive<Record<string, string>>({})
/** Per-zone item order — unified list of item keys per zone, per template. */
const zoneItemOrderPerTemplate = reactive<
Record<string, Record<string, string[]>>
>({})
/** Per-zone stacking direction: 'top' (default) or 'bottom'. */
const zoneAlignPerTemplate = reactive<
Record<string, Record<string, 'top' | 'bottom'>>
>({})
const zoneAssignments = computed(
() => zoneAssignmentsPerTemplate[layoutTemplateId.value] ?? {}
)
const gridOverrides = computed<GridOverride | undefined>(
() => gridOverridesPerTemplate[layoutTemplateId.value]
)
const runControlsZoneId = computed(
() => runControlsZoneIdPerTemplate[layoutTemplateId.value]
)
const zoneItemOrder = computed(
() => zoneItemOrderPerTemplate[layoutTemplateId.value] ?? {}
)
const zoneAlign = computed(
() => zoneAlignPerTemplate[layoutTemplateId.value] ?? {}
)
const hasOutputs = computed(() => !!selectedOutputs.length)
const hasNodes = computed(() => {
// Nodes are not reactive, so trigger recomputation when workflow changes
@@ -38,6 +71,112 @@ export const useAppModeStore = defineStore('appMode', () => {
// Prune entries referencing nodes deleted in workflow mode.
// Only check node existence, not widgets — dynamic widgets can
// hide/show other widgets so a missing widget does not mean stale data.
function zoneKey(nodeId: NodeId, widgetName: string): string {
return `${nodeId}:${widgetName}`
}
function getZone(nodeId: NodeId, widgetName: string): string | undefined {
return zoneAssignments.value[zoneKey(nodeId, widgetName)]
}
function ensureTemplateAssignments(): Record<string, string> {
const tid = layoutTemplateId.value
if (!zoneAssignmentsPerTemplate[tid]) {
zoneAssignmentsPerTemplate[tid] = {}
}
return zoneAssignmentsPerTemplate[tid]
}
function setZone(nodeId: NodeId, widgetName: string, zoneId: string) {
ensureTemplateAssignments()[zoneKey(nodeId, widgetName)] = zoneId
persistLinearData()
}
function setGridOverrides(overrides: GridOverride | undefined) {
const tid = layoutTemplateId.value
if (overrides) {
gridOverridesPerTemplate[tid] = overrides
} else {
delete gridOverridesPerTemplate[tid]
}
persistLinearData()
}
function setRunControlsZone(zoneId: string) {
runControlsZoneIdPerTemplate[layoutTemplateId.value] = zoneId
persistLinearData()
}
/** O(n+m) computation of which zones should show outputs. */
const outputZoneIds = computed(() => {
const tmpl = getTemplate(layoutTemplateId.value)
if (!tmpl) return new Set<string>()
const explicitZones = new Set<string>()
for (const nodeId of selectedOutputs) {
const zone = getZone(nodeId, OUTPUT_ZONE_KEY)
if (zone) explicitZones.add(zone)
}
if (explicitZones.size > 0) return explicitZones
return new Set(tmpl.zones.filter((z) => z.isOutput).map((z) => z.id))
})
/** Assign unassigned inputs to the least-full input zone. Does NOT persist. */
function autoAssignInputs() {
const tmpl = getTemplate(layoutTemplateId.value)
if (!tmpl) return
const inputZones = tmpl.zones.filter((z) => !z.isOutput)
if (inputZones.length === 0) return
const assignments = ensureTemplateAssignments()
for (const [nodeId, widgetName] of selectedInputs) {
const key = zoneKey(nodeId, widgetName)
if (assignments[key]) continue
const counts = new Map<string, number>()
for (const z of inputZones) counts.set(z.id, 0)
for (const [nId, wName] of selectedInputs) {
const assigned = assignments[zoneKey(nId, wName)]
if (assigned && counts.has(assigned)) {
counts.set(assigned, (counts.get(assigned) ?? 0) + 1)
}
}
const leastFull = [...counts.entries()].sort((a, b) => a[1] - b[1])[0]
if (leastFull) assignments[key] = leastFull[0]
}
}
/** Switch to a new template, redistributing inputs/outputs for the new layout. */
function switchTemplate(newId: LayoutTemplateId) {
layoutTemplateId.value = newId
const tmpl = getTemplate(newId)
if (!tmpl) return
const assignments = ensureTemplateAssignments()
const validZoneIds = new Set(tmpl.zones.map((z) => z.id))
// Clear stale zone assignments for this template
for (const key of Object.keys(assignments)) {
if (!validZoneIds.has(assignments[key])) {
delete assignments[key]
}
}
// Re-assign unassigned outputs to default output zones
const defaultOutputZone = tmpl.zones.find((z) => z.isOutput)
if (defaultOutputZone) {
for (const nodeId of selectedOutputs) {
const key = zoneKey(nodeId, OUTPUT_ZONE_KEY)
if (!assignments[key] || !validZoneIds.has(assignments[key])) {
assignments[key] = defaultOutputZone.id
}
}
}
autoAssignInputs()
persistLinearData()
}
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
@@ -48,14 +187,74 @@ export const useAppModeStore = defineStore('appMode', () => {
: rawInputs,
outputs: app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
: rawOutputs
: rawOutputs,
layoutTemplateId: data?.layoutTemplateId,
zoneAssignmentsPerTemplate:
data?.zoneAssignmentsPerTemplate ??
(data?.zoneAssignments && data?.layoutTemplateId
? { [data.layoutTemplateId]: data.zoneAssignments }
: undefined),
gridOverridesPerTemplate:
data?.gridOverridesPerTemplate ??
(data?.gridOverrides && data?.layoutTemplateId
? { [data.layoutTemplateId]: data.gridOverrides }
: undefined),
runControlsZoneIdPerTemplate:
data?.runControlsZoneIdPerTemplate ??
(data?.runControlsZoneId && data?.layoutTemplateId
? { [data.layoutTemplateId]: data.runControlsZoneId }
: undefined),
zoneItemOrderPerTemplate: data?.zoneItemOrderPerTemplate,
zoneAlignPerTemplate: data?.zoneAlignPerTemplate
}
}
function loadSelections(data: Partial<LinearData> | undefined) {
const { inputs, outputs } = pruneLinearData(data)
selectedInputs.splice(0, selectedInputs.length, ...inputs)
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
const pruned = pruneLinearData(data)
selectedInputs.splice(0, selectedInputs.length, ...pruned.inputs)
selectedOutputs.splice(0, selectedOutputs.length, ...pruned.outputs)
const VALID_TEMPLATE_IDS: Set<string> = new Set([
'focus',
'grid',
'sidebar'
])
layoutTemplateId.value = VALID_TEMPLATE_IDS.has(
pruned.layoutTemplateId ?? ''
)
? (pruned.layoutTemplateId as LayoutTemplateId)
: 'sidebar'
Object.keys(zoneAssignmentsPerTemplate).forEach(
(k) => delete zoneAssignmentsPerTemplate[k]
)
Object.assign(
zoneAssignmentsPerTemplate,
pruned.zoneAssignmentsPerTemplate ?? {}
)
Object.keys(gridOverridesPerTemplate).forEach(
(k) => delete gridOverridesPerTemplate[k]
)
Object.assign(
gridOverridesPerTemplate,
pruned.gridOverridesPerTemplate ?? {}
)
Object.keys(runControlsZoneIdPerTemplate).forEach(
(k) => delete runControlsZoneIdPerTemplate[k]
)
Object.assign(
runControlsZoneIdPerTemplate,
pruned.runControlsZoneIdPerTemplate ?? {}
)
Object.keys(zoneItemOrderPerTemplate).forEach(
(k) => delete zoneItemOrderPerTemplate[k]
)
Object.assign(
zoneItemOrderPerTemplate,
pruned.zoneItemOrderPerTemplate ?? {}
)
Object.keys(zoneAlignPerTemplate).forEach(
(k) => delete zoneAlignPerTemplate[k]
)
Object.assign(zoneAlignPerTemplate, pruned.zoneAlignPerTemplate ?? {})
}
function resetSelectedToWorkflow() {
@@ -79,31 +278,45 @@ export const useAppModeStore = defineStore('appMode', () => {
{ immediate: true }
)
useEventListener(
() => app.rootGraph?.events,
'configured',
resetSelectedToWorkflow
)
function persistLinearData() {
if (
(!isBuilderMode.value && !isAppMode.value) ||
ChangeTracker.isLoadingGraph
)
return
const graph = app.rootGraph
if (!graph) return
const extra = (graph.extra ??= {})
extra.linearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs],
layoutTemplateId: layoutTemplateId.value,
zoneAssignmentsPerTemplate: JSON.parse(
JSON.stringify(zoneAssignmentsPerTemplate)
),
gridOverridesPerTemplate: JSON.parse(
JSON.stringify(gridOverridesPerTemplate)
),
runControlsZoneIdPerTemplate: { ...runControlsZoneIdPerTemplate },
zoneItemOrderPerTemplate: JSON.parse(
JSON.stringify(zoneItemOrderPerTemplate)
),
zoneAlignPerTemplate: JSON.parse(JSON.stringify(zoneAlignPerTemplate))
}
}
watch(
() =>
isBuilderMode.value
? { inputs: selectedInputs, outputs: selectedOutputs }
? { inputs: [...selectedInputs], outputs: [...selectedOutputs] }
: null,
(data) => {
if (!data || ChangeTracker.isLoadingGraph) return
const graph = app.rootGraph
if (!graph) return
const extra = (graph.extra ??= {})
extra.linearData = {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
if (data) persistLinearData()
},
{ deep: true }
)
let unwatch: () => void | undefined
let unwatch: (() => void) | undefined
watch(isSelectMode, (inSelect) => {
const { state } = getCanvas()
if (!state) return
@@ -139,26 +352,94 @@ export const useAppModeStore = defineStore('appMode', () => {
setMode('graph')
}
function removeSelectedInput(widget: IBaseWidget, node: { id: NodeId }) {
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = selectedInputs.findIndex(
([id, name]) => storeId == id && storeName === name
)
if (index !== -1) selectedInputs.splice(index, 1)
/** Get the ordered item keys for a zone, auto-populating from current items if empty. */
function getZoneItems(
zoneId: string,
outputs: { nodeId: NodeId }[],
widgets: { nodeId: NodeId; widgetName: string }[],
hasRunControls: boolean
): string[] {
const existing = zoneItemOrder.value[zoneId]
if (existing && existing.length > 0) {
// Filter out stale keys that no longer exist, append new ones
const validKeys = new Set<string>()
for (const o of outputs) validKeys.add(`output:${o.nodeId}`)
for (const w of widgets)
validKeys.add(`input:${w.nodeId}:${w.widgetName}`)
if (hasRunControls) validKeys.add('run-controls')
const kept = existing.filter((k) => validKeys.has(k))
const keptSet = new Set(kept)
for (const k of validKeys) {
if (!keptSet.has(k)) kept.push(k)
}
return kept
}
// Default order: outputs, inputs, run controls
const keys: string[] = []
for (const o of outputs) keys.push(`output:${o.nodeId}`)
for (const w of widgets) keys.push(`input:${w.nodeId}:${w.widgetName}`)
if (hasRunControls) keys.push('run-controls')
return keys
}
/** Reorder any item relative to any other item within the same zone. */
function reorderZoneItem(
zoneId: string,
fromKey: string,
toKey: string,
position: 'before' | 'after',
currentOrder: string[]
) {
const order = [...currentOrder]
const fromIdx = order.indexOf(fromKey)
const toIdx = order.indexOf(toKey)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
order.splice(fromIdx, 1)
const insertIdx =
position === 'before' ? order.indexOf(toKey) : order.indexOf(toKey) + 1
order.splice(insertIdx, 0, fromKey)
const tid = layoutTemplateId.value
if (!zoneItemOrderPerTemplate[tid]) {
zoneItemOrderPerTemplate[tid] = {}
}
zoneItemOrderPerTemplate[tid][zoneId] = order
persistLinearData()
}
function toggleZoneAlign(zoneId: string) {
const tid = layoutTemplateId.value
if (!zoneAlignPerTemplate[tid]) zoneAlignPerTemplate[tid] = {}
const current = zoneAlignPerTemplate[tid][zoneId] ?? 'top'
zoneAlignPerTemplate[tid][zoneId] = current === 'top' ? 'bottom' : 'top'
persistLinearData()
}
return {
autoAssignInputs,
enterBuilder,
exitBuilder,
getZone,
gridOverrides,
hasNodes,
hasOutputs,
layoutTemplateId,
getZoneItems,
outputZoneIds,
pruneLinearData,
removeSelectedInput,
reorderZoneItem,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs
selectedOutputs,
runControlsZoneId,
setGridOverrides,
setRunControlsZone,
setZone,
switchTemplate,
toggleZoneAlign,
zoneAlign,
zoneAssignments
}
})

View File

@@ -323,8 +323,9 @@ export function resolveNode(
export function resolveNodeWidget(
nodeId: NodeId,
widgetName?: string,
graph: LGraph = app.rootGraph
graph: LGraph | null | undefined = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
if (!graph) return []
const node = graph.getNodeById(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {

View File

@@ -16,6 +16,10 @@
<template v-if="isBuilderMode">
<BuilderToolbar />
<BuilderMenu />
<LayoutTemplateSelector
:model-value="appModeStore.layoutTemplateId"
@update:model-value="appModeStore.switchTemplate"
/>
<BuilderFooterToolbar />
</template>
</div>
@@ -93,6 +97,8 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { electronAPI } from '@/utils/envUtil'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
import BuilderMenu from '@/components/builder/BuilderMenu.vue'
import LayoutTemplateSelector from '@/components/builder/LayoutTemplateSelector.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
@@ -110,6 +116,7 @@ const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const appModeStore = useAppModeStore()
const { isBuilderMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
@@ -142,18 +149,11 @@ watch(
{ immediate: true }
)
/**
* Reports task completion telemetry to Electron analytics when tasks
* transition from running to history.
*
* No `deep: true` needed — `queueStore.tasks` is a computed that spreads
* three `shallowRef` arrays into a new array on every change, and
* `TaskItemImpl` instances are immutable (replaced, never mutated).
*/
if (isDesktop) {
watch(
() => queueStore.tasks,
(newTasks, oldTasks) => {
// Report tasks that previously running but are now completed (i.e. in history)
const oldRunningTaskIds = new Set(
oldTasks.filter((task) => task.isRunning).map((task) => task.jobId)
)
@@ -168,7 +168,8 @@ if (isDesktop) {
status: task.displayStatus.toLowerCase()
})
})
}
},
{ deep: true }
)
}

View File

@@ -1,78 +1,51 @@
<script setup lang="ts">
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import type { MaybeElement } from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import ArrangeLayout from '@/components/builder/ArrangeLayout.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import AppTemplateView from '@/renderer/extensions/linearMode/AppTemplateView.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useStablePrimeVueSplitterSizer } from '@/composables/useStablePrimeVueSplitterSizer'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useAppModeStore } from '@/stores/appModeStore'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { isBuilderMode } = useAppMode()
const useTemplateLayout = computed(
() => Object.keys(appModeStore.zoneAssignments).length > 0
)
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
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)
)
const hasLeftPanel = computed(() => sidebarOnLeft.value && activeTab.value)
const hasRightPanel = computed(() => !sidebarOnLeft.value && activeTab.value)
function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
if (isBuilder) return BUILDER_MIN_SIZE
if (isHidden) return undefined
return SIDEBAR_MIN_SIZE
}
// Remount splitter when panel structure changes so initializePanels()
// properly sets flexBasis for the current set of panels.
const splitterKey = computed(() => {
const left = hasLeftPanel.value ? 'L' : ''
const right = hasRightPanel.value ? 'R' : ''
return isArrangeMode.value ? 'arrange' : `app-${left}${right}`
return `app-${left}${right}`
})
const leftPanelRef = useTemplateRef<MaybeElement>('leftPanel')
@@ -85,23 +58,10 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
],
[activeTab, splitterKey]
)
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
function dragDrop(e: DragEvent) {
const { dataTransfer } = e
if (!dataTransfer) return
linearWorkflowRef.value?.handleDragDrop(e)
}
</script>
<template>
<MobileDisplay v-if="mobileDisplay" />
<div v-else class="absolute size-full" @dragover.prevent>
<div v-else class="absolute size-full">
<div
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
>
@@ -121,78 +81,37 @@ function dragDrop(e: DragEvent) {
v-if="hasLeftPanel"
ref="leftPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
:style="
showRightBuilder && !activeTab ? { display: 'none' } : undefined
"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
:min-size="SIDEBAR_MIN_SIZE"
class="min-w-78 overflow-hidden outline-none"
>
<AppBuilder v-if="showLeftBuilder" />
<div
v-else-if="sidebarOnLeft && activeTab"
class="size-full overflow-x-hidden border-r border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
<div class="size-full overflow-x-hidden border-r border-border-subtle">
<ExtensionSlot v-if="activeTab" :extension="activeTab" />
</div>
<LinearControls
v-else-if="!isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
/>
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
@drop="dragDrop"
>
<LinearProgressBar
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<LinearPreview
:run-button-click="linearWorkflowRef?.runButtonClick"
:typeform-widget-id="TYPEFORM_WIDGET_ID"
/>
<ArrangeLayout v-if="isBuilderMode" />
<AppTemplateView v-else-if="useTemplateLayout" />
<LinearPreview v-else />
<div class="absolute top-2 left-4.5 z-21">
<AppModeToolbar v-if="!isBuilderMode" />
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"
ref="rightPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
:min-size="SIDEBAR_MIN_SIZE"
class="min-w-78 overflow-hidden outline-none"
>
<AppBuilder v-if="showRightBuilder" />
<LinearControls
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomRightRef) ?? undefined"
/>
<div
v-else-if="activeTab"
class="h-full overflow-x-hidden border-l border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
<div class="h-full overflow-x-hidden border-l border-border-subtle">
<ExtensionSlot v-if="activeTab" :extension="activeTab" />
</div>
</SplitterPanel>
</Splitter>