mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
]"
|
||||
>
|
||||
|
||||
296
src/components/builder/ArrangeLayout.vue
Normal file
296
src/components/builder/ArrangeLayout.vue
Normal 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>
|
||||
35
src/components/builder/LayoutTemplateSelector.vue
Normal file
35
src/components/builder/LayoutTemplateSelector.vue
Normal 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>
|
||||
281
src/components/builder/LayoutZoneGrid.vue
Normal file
281
src/components/builder/LayoutZoneGrid.vue
Normal 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>
|
||||
101
src/components/builder/ZoneResizeHandle.vue
Normal file
101
src/components/builder/ZoneResizeHandle.vue
Normal 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>
|
||||
200
src/components/builder/layoutTemplates.ts
Normal file
200
src/components/builder/layoutTemplates.ts
Normal 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 `
|
||||
}
|
||||
103
src/components/builder/useWidgetReorder.ts
Normal file
103
src/components/builder/useWidgetReorder.ts
Normal 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?.()
|
||||
}
|
||||
}
|
||||
174
src/components/builder/useZoneDrop.ts
Normal file
174
src/components/builder/useZoneDrop.ts
Normal 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?.()
|
||||
}
|
||||
}
|
||||
93
src/components/builder/useZoneReorder.ts
Normal file
93
src/components/builder/useZoneReorder.ts
Normal 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?.()
|
||||
}
|
||||
}
|
||||
100
src/components/builder/useZoneWidgets.test.ts
Normal file
100
src/components/builder/useZoneWidgets.test.ts
Normal 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']
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
163
src/components/builder/useZoneWidgets.ts
Normal file
163
src/components/builder/useZoneWidgets.ts
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
391
src/renderer/extensions/linearMode/AppTemplateView.vue
Normal file
391
src/renderer/extensions/linearMode/AppTemplateView.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user