feat: builder layout system — templates, zones, drag-and-drop

Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Koshi
2026-03-27 17:31:43 +01:00
parent 6c3f4a6d99
commit 07e3b92266
19 changed files with 1796 additions and 689 deletions

View File

@@ -264,41 +264,6 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
/>
</DraggableList>
</PropertiesAccordionItem>
<!-- Presets toggle — listed alongside inputs -->
<div
v-if="isSelectInputsMode"
:class="
cn(
'my-2 flex items-center gap-2 rounded-lg p-2',
appModeStore.presetsEnabled
? 'bg-primary-background/30'
: 'bg-primary-background/10 opacity-50'
)
"
>
<i class="icon-[lucide--layers] size-4 shrink-0" />
<span class="flex-1 truncate text-sm">
{{ t('linearMode.presets.label') }}
</span>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
@click="
() => {
appModeStore.presetsEnabled = !appModeStore.presetsEnabled
appModeStore.persistLinearData()
}
"
>
<i
:class="
appModeStore.presetsEnabled
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
"
class="size-4"
/>
</button>
</div>
<div
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"

View File

@@ -3,8 +3,6 @@ import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
@@ -13,6 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { app } from '@/scripts/app'
@@ -21,7 +20,6 @@ import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
@@ -31,9 +29,18 @@ interface WidgetEntry {
action: { widget: IBaseWidget; node: LGraphNode }
}
const { mobile = false, builderMode = false } = defineProps<{
const {
mobile = false,
builderMode = false,
zoneId,
itemKeys
} = defineProps<{
mobile?: boolean
builderMode?: boolean
/** When set, only show inputs assigned to this zone. */
zoneId?: string
/** When set, only render these specific input keys in the given order. */
itemKeys?: string[]
}>()
const { t } = useI18n()
@@ -44,13 +51,61 @@ const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph?.nodes ?? [])
useEventListener(
app.rootGraph.events,
() => app.rootGraph?.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
() => (graphNodes.value = app.rootGraph?.nodes ?? [])
)
const groupedItemKeys = computed(() => {
const keys = new Set<string>()
for (const group of appModeStore.inputGroups) {
for (const item of group.items) keys.add(item.key)
}
return keys
})
function resolveInputEntry(
nodeId: string | number,
widgetName: string,
nodeDataByNode: Map<LGraphNode, ReturnType<typeof nodeToNodeData>>
): WidgetEntry | null {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return null
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
})
if (!matchingWidget) return null
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
return {
key: `${nodeId}:${widgetName}`,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
}
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
@@ -58,43 +113,34 @@ const mappedSelections = computed((): WidgetEntry[] => {
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
if (itemKeys) {
const results: WidgetEntry[] = []
for (const key of itemKeys) {
if (!key.startsWith('input:')) continue
const parts = key.split(':')
const nodeId = parts[1]
const widgetName = parts.slice(2).join(':')
const entry = resolveInputEntry(nodeId, widgetName, nodeDataByNode)
if (entry) results.push(entry)
}
const fullNodeData = nodeDataByNode.get(node)!
return results
}
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
const inputs = zoneId
? appModeStore.selectedInputs.filter(
([nId, wName]) => appModeStore.getZone(nId, wName) === zoneId
)
: appModeStore.selectedInputs
return inputs
.filter(
([nodeId, widgetName]) =>
!groupedItemKeys.value.has(`input:${nodeId}:${widgetName}`)
)
.flatMap(([nodeId, widgetName]) => {
const entry = resolveInputEntry(nodeId, widgetName, nodeDataByNode)
return entry ? [entry] : []
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
return [
{
key: `${nodeId}:${widgetName}`,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
@@ -105,6 +151,13 @@ function getDropIndicator(node: LGraphNode) {
})
}
function inputColorBg(key: string): string | undefined {
const [nodeId, widgetName] = key.split(':')
const colorName = appModeStore.getInputColor(Number(nodeId), widgetName)
if (!colorName) return undefined
return LGraphCanvas.node_colors[colorName]?.bgcolor
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
@@ -125,9 +178,15 @@ function nodeToNodeData(node: LGraphNode) {
:class="
cn(
builderMode &&
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing',
!builderMode && inputColorBg(key) && 'rounded-sm border-l-2'
)
"
:style="
!builderMode && inputColorBg(key)
? { borderLeftColor: inputColorBg(key) }
: undefined
"
:aria-label="
builderMode
? `${action.widget.label ?? action.widget.name} ${action.node.title}`
@@ -137,12 +196,13 @@ function nodeToNodeData(node: LGraphNode) {
<div
:class="
cn(
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
'flex min-h-8 items-center gap-1 px-3 pt-1.5',
builderMode && 'drag-handle'
)
"
>
<span
v-tooltip.top="action.widget.label || action.widget.name"
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
>
{{ action.widget.label || action.widget.name }}
@@ -154,36 +214,6 @@ function nodeToNodeData(node: LGraphNode) {
{{ action.node.title }}
</span>
<div v-else class="flex-1" />
<Popover
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
:entries="[
{
label: t('g.rename'),
icon: 'icon-[lucide--pencil]',
command: () => promptRenameWidget(action.widget, action.node, t)
},
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () => {
const idx = appModeStore.selectedInputs.findIndex(
([nId, wName]) => `${nId}:${wName}` === key
)
if (idx !== -1) appModeStore.selectedInputs.splice(idx, 1)
}
}
]"
>
<template #button>
<Button
variant="textonly"
size="icon"
data-testid="widget-actions-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
<div
:class="builderMode && 'pointer-events-none'"
@@ -206,5 +236,14 @@ function nodeToNodeData(node: LGraphNode) {
/>
</DropZone>
</div>
<div
v-if="!builderMode"
:class="
cn(
'mx-3 border-b border-border-subtle/30',
key === mappedSelections.at(-1)?.key && 'hidden'
)
"
/>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const open = defineModel<boolean>({ required: true })
const {
title,
description,
confirmLabel,
confirmVariant = 'secondary'
} = defineProps<{
title: string
description: string
confirmLabel: string
confirmVariant?: 'secondary' | 'destructive'
}>()
const emit = defineEmits<{
confirm: []
}>()
const { t } = useI18n()
function handleConfirm() {
emit('confirm')
open.value = false
}
</script>
<template>
<DialogRoot v-model:open="open">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-1800 bg-black/50" />
<DialogContent
class="fixed top-1/2 left-1/2 z-1800 w-80 -translate-1/2 rounded-xl border border-border-subtle bg-base-background p-5 shadow-lg"
>
<div class="flex items-center justify-between">
<DialogTitle class="text-sm font-medium">
{{ title }}
</DialogTitle>
<DialogClose
class="flex size-6 items-center justify-center rounded-sm border-0 bg-transparent text-muted-foreground outline-none hover:text-base-foreground"
>
<i class="icon-[lucide--x] size-4" />
</DialogClose>
</div>
<div
class="mt-3 border-t border-border-subtle pt-3 text-sm text-muted-foreground"
>
{{ description }}
</div>
<div class="mt-5 flex items-center justify-end gap-3">
<DialogClose as-child>
<Button variant="muted-textonly" size="sm">
{{ t('g.cancel') }}
</Button>
</DialogClose>
<Button :variant="confirmVariant" size="lg" @click="handleConfirm">
{{ confirmLabel }}
</Button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -1,10 +1,21 @@
<template>
<nav
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
ref="toolbarEl"
:class="
cn(
'fixed z-1000 origin-top-left select-none',
isDragging && 'cursor-grabbing'
)
"
:style="{
left: `${position.x}px`,
top: `${position.y}px`,
transform: `scale(${toolbarScale})`
}"
:aria-label="t('builderToolbar.label')"
>
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
class="group inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template v-for="(step, index) in steps" :key="step.id">
<button
@@ -60,12 +71,22 @@
/>
<StepLabel :step="defaultViewStep" />
</button>
<!-- Resize handle -->
<div
class="ml-1 flex cursor-se-resize items-center opacity-0 transition-opacity group-hover:opacity-40"
@pointerdown.stop="startResize"
>
<i class="icon-[lucide--grip] size-3.5" />
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { useDraggable } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppModeStore } from '@/stores/appModeStore'
@@ -83,6 +104,45 @@ const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
// ── Draggable positioning ──────────────────────────────────────────
const toolbarEl = ref<HTMLElement | null>(null)
const toolbarScale = ref(1)
const { position, isDragging } = useDraggable(toolbarEl, {
initialValue: { x: 0, y: 50 },
preventDefault: true
})
onMounted(() => {
if (toolbarEl.value) {
const rect = toolbarEl.value.getBoundingClientRect()
position.value = {
x: Math.round((window.innerWidth - rect.width) / 2),
y: 50
}
}
})
// ── Corner resize (scale) ──────────────────────────────────────────
function startResize(e: PointerEvent) {
const startX = e.clientX
const startScale = toolbarScale.value
const el = e.currentTarget as HTMLElement
el.setPointerCapture(e.pointerId)
function onMove(ev: PointerEvent) {
const delta = ev.clientX - startX
toolbarScale.value = Math.max(0.5, Math.min(1.2, startScale + delta / 400))
}
function onUp() {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
}
// ── Step definitions ───────────────────────────────────────────────
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'

View File

@@ -0,0 +1,366 @@
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger,
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle
} from 'reka-ui'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import {
vGroupDropTarget,
vGroupItemDraggable,
vGroupItemReorderTarget
} from '@/components/builder/useGroupDrop'
import {
autoGroupName,
groupedByPair,
resolveGroupItems
} from '@/components/builder/useInputGroups'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import type { WidgetValue } from '@/utils/widgetUtil'
import { cn } from '@/utils/tailwindUtil'
const {
group,
zoneId,
builderMode = false,
position = 'middle'
} = defineProps<{
group: InputGroup
zoneId: string
builderMode?: boolean
position?: 'first' | 'middle' | 'last' | 'only'
}>()
const { t } = useI18n()
const appModeStore = useAppModeStore()
const canvasStore = useCanvasStore()
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const isOpen = ref(builderMode)
const isRenaming = ref(false)
const showUngroupDialog = ref(false)
const renameValue = ref('')
let renameStartedAt = 0
const displayName = computed(() => group.name ?? autoGroupName(group))
const resolvedItems = computed(() => resolveGroupItems(group))
const rows = computed(() => groupedByPair(resolvedItems.value))
function startRename() {
if (!builderMode) return
renameValue.value = displayName.value
renameStartedAt = Date.now()
isRenaming.value = true
}
function confirmRename() {
if (Date.now() - renameStartedAt < 150) return
const trimmed = renameValue.value.trim()
appModeStore.renameGroup(group.id, trimmed || null)
isRenaming.value = false
}
function cancelRename() {
isRenaming.value = false
}
function startRenameDeferred() {
setTimeout(startRename, 50)
}
function handleDissolve() {
appModeStore.dissolveGroup(group.id, zoneId)
}
function handleWidgetValueUpdate(widget: IBaseWidget, value: WidgetValue) {
if (value === undefined) return
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
}
</script>
<template>
<CollapsibleRoot
v-model:open="isOpen"
:class="
cn(
'flex flex-col',
builderMode &&
'rounded-lg border border-dashed border-primary-background/40',
!builderMode && 'border-border-subtle/40',
!builderMode &&
position !== 'first' &&
position !== 'only' &&
'border-t',
!builderMode &&
(position === 'last' || position === 'only') &&
'border-b'
)
"
>
<!-- Header row draggable in builder mode -->
<div
:class="
cn(
'flex items-center gap-1',
builderMode ? 'drag-handle cursor-grab py-1 pr-1.5 pl-1' : 'px-4 py-2'
)
"
>
<!-- Rename input (outside CollapsibleTrigger to avoid focus conflicts) -->
<div v-if="isRenaming" class="flex flex-1 items-center gap-1.5 px-3 py-2">
<input
v-model="renameValue"
type="text"
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none"
@click.stop
@keydown.enter.stop="confirmRename"
@keydown.escape.stop="cancelRename"
@blur="confirmRename"
@vue:mounted="
($event: any) => {
$event.el?.focus()
$event.el?.select()
}
"
/>
</div>
<!-- Name + chevron -->
<CollapsibleTrigger v-else as-child>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-1.5 border border-transparent bg-transparent px-3 py-2 text-left outline-none"
>
<Tooltip :text="displayName" side="left" :side-offset="20">
<span
class="flex-1 truncate text-sm font-bold text-base-foreground"
@dblclick.stop="startRename"
>
{{ displayName }}
</span>
</Tooltip>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)
"
/>
</button>
</CollapsibleTrigger>
<!-- Builder actions on the right -->
<Popover v-if="builderMode" class="-mr-2 shrink-0">
<template #button>
<Button variant="textonly" size="icon">
<i class="icon-[lucide--ellipsis-vertical]" />
</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-1 p-1">
<div
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
@click="
() => {
close()
startRenameDeferred()
}
"
>
<i class="icon-[lucide--pencil]" />
{{ t('g.rename') }}
</div>
<div
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
@click="
() => {
close()
showUngroupDialog = true
}
"
>
<i class="icon-[lucide--ungroup]" />
{{ t('linearMode.layout.ungroup') }}
</div>
</div>
</template>
</Popover>
<!-- Ungroup confirmation dialog -->
<DialogRoot v-model:open="showUngroupDialog">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-1800 bg-black/50" />
<DialogContent
class="fixed top-1/2 left-1/2 z-1800 w-80 -translate-1/2 rounded-xl border border-border-subtle bg-base-background p-5 shadow-lg"
>
<div class="flex items-center justify-between">
<DialogTitle class="text-sm font-medium">
{{ t('linearMode.groups.confirmUngroup') }}
</DialogTitle>
<DialogClose
class="flex size-6 items-center justify-center rounded-sm border-0 bg-transparent text-muted-foreground outline-none hover:text-base-foreground"
>
<i class="icon-[lucide--x] size-4" />
</DialogClose>
</div>
<div
class="mt-3 border-t border-border-subtle pt-3 text-sm text-muted-foreground"
>
{{ t('linearMode.groups.ungroupDescription') }}
</div>
<div class="mt-5 flex items-center justify-end gap-3">
<DialogClose as-child>
<Button variant="muted-textonly" size="sm">
{{ t('g.cancel') }}
</Button>
</DialogClose>
<Button
variant="secondary"
size="lg"
@click="
() => {
handleDissolve()
showUngroupDialog = false
}
"
>
{{ t('linearMode.layout.ungroup') }}
</Button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
<CollapsibleContent>
<!-- Builder mode: drop zone -->
<div
v-if="builderMode"
v-group-drop-target="{ groupId: group.id, zoneId }"
:class="
cn(
'flex min-h-10 flex-col gap-3 px-2 pb-2',
'[&.group-drag-over]:bg-primary-background/5'
)
"
>
<template
v-for="row in rows"
:key="row.type === 'single' ? row.item.key : row.items[0].key"
>
<div
v-if="row.type === 'single'"
v-group-item-draggable="{
itemKey: row.item.key,
groupId: group.id
}"
v-group-item-reorder-target="{
itemKey: row.item.key,
groupId: group.id
}"
class="cursor-grab overflow-hidden rounded-lg p-1.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.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="row.item.widget"
:node="row.item.node"
hidden-label
hidden-widget-actions
hidden-favorite-indicator
/>
</div>
</div>
<div v-else class="flex items-stretch gap-2">
<div
v-for="item in row.items"
:key="item.key"
v-group-item-draggable="{
itemKey: item.key,
groupId: group.id
}"
v-group-item-reorder-target="{
itemKey: item.key,
groupId: group.id
}"
class="min-w-0 flex-1 cursor-grab overflow-hidden rounded-lg p-0.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.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="item.widget"
:node="item.node"
hidden-label
hidden-widget-actions
hidden-favorite-indicator
/>
</div>
</div>
</div>
</template>
<div
v-if="group.items.length === 0"
class="flex items-center justify-center py-3 text-xs text-muted-foreground"
>
{{ t('linearMode.arrange.dropHere') }}
</div>
</div>
<!-- App mode: clean read-only -->
<div v-else class="flex flex-col gap-4 px-4 pt-2 pb-4">
<template
v-for="row in rows"
:key="row.type === 'single' ? row.item.key : row.items[0].key"
>
<div v-if="row.type === 'single'">
<WidgetItem
:widget="row.item.widget"
:node="row.item.node"
hidden-label
hidden-widget-actions
@update:widget-value="
handleWidgetValueUpdate(row.item.widget, $event)
"
/>
</div>
<div v-else class="flex items-stretch gap-2">
<div
v-for="item in row.items"
:key="item.key"
class="min-w-0 flex-1 overflow-hidden"
>
<WidgetItem
:widget="item.widget"
:node="item.node"
hidden-label
hidden-widget-actions
class="w-full"
@update:widget-value="
handleWidgetValueUpdate(item.widget, $event)
"
/>
</div>
</div>
</template>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -16,9 +16,10 @@ const selected = defineModel<LayoutTemplateId>({ required: true })
<button
v-for="template in LAYOUT_TEMPLATES"
:key="template.id"
v-tooltip.right="t(template.description)"
:class="
cn(
'flex cursor-pointer flex-col items-center gap-0.5 rounded-lg border-2 px-2.5 py-1.5 transition-colors',
'flex cursor-pointer items-center justify-center rounded-lg border-2 p-2 transition-colors',
selected === template.id
? 'border-primary-background bg-primary-background/10'
: 'border-transparent bg-transparent hover:bg-secondary-background'
@@ -29,7 +30,6 @@ const selected = defineModel<LayoutTemplateId>({ required: true })
@click="selected = template.id"
>
<i :class="cn(template.icon, 'size-5')" />
<span class="text-[10px]">{{ t(template.label) }}</span>
</button>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
@@ -9,28 +9,21 @@ import type {
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
gridOverrides
} = 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>
/** Extra CSS classes per zone ID, applied to the grid cell div. */
zoneClasses?: Record<string, string>
}>()
@@ -39,30 +32,6 @@ 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
@@ -72,149 +41,8 @@ const gridStyle = computed(() => {
gridAutoRows: 'minmax(200px, auto)'
}
}
return { gridTemplate: buildGridTemplate(template, effectiveOverrides.value) }
return { gridTemplate: buildGridTemplate(template, gridOverrides) }
})
/** 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
)
// Matches p-3 and gap-3 on the grid container
const GRID_PADDING_PX = 12
const GRID_GAP_PX = 12
/** 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 totalPadding = GRID_PADDING_PX * 2
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(${GRID_PADDING_PX}px + (100% - ${totalPadding + gapCount * GRID_GAP_PX}px) * ${pct / 100} + ${i * GRID_GAP_PX + GRID_GAP_PX / 2}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 totalPadding = GRID_PADDING_PX * 2
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(${GRID_PADDING_PX}px + (100% - ${totalPadding + gapCount * GRID_GAP_PX}px) * ${pct / 100} + ${i * GRID_GAP_PX + GRID_GAP_PX / 2}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>
@@ -230,16 +58,15 @@ function onRowResizeEnd(fractions: number[]) {
cn(
'relative flex flex-col overflow-y-auto rounded-xl transition-colors',
dashed
? 'border-2 border-dashed border-border-subtle'
: filledZones?.has(zone.id)
? 'border-0'
: 'border-2 border-solid border-border-subtle',
? 'border border-dashed border-border-subtle/40'
: 'border border-border-subtle/40',
highlightedZone === zone.id &&
'border-primary-background bg-primary-background/10',
zoneClasses?.[zone.id]
)
"
:data-zone-id="zone.id"
:aria-label="t(zone.label)"
>
<slot name="zone" :zone="zone">
<div
@@ -252,38 +79,5 @@ function onRowResizeEnd(fractions: number[]) {
</slot>
</div>
</div>
<!-- Resize handle overlay sits above zones so stacking contexts don't block it -->
<template v-if="resizable && !isMobile">
<ZoneResizeHandle
v-for="handle in columnHandles"
:key="`col-${handle.index}`"
direction="column"
:index="handle.index"
:fractions="gridOverrides?.columnFractions ?? defaultColumnFractions"
:style="{
position: 'absolute',
top: '0',
bottom: '0',
left: handle.cssLeft
}"
@resize="onColumnResize"
@resize-end="onColumnResizeEnd"
/>
<ZoneResizeHandle
v-for="handle in rowHandles"
:key="`row-${handle.index}`"
direction="row"
:index="handle.index"
:fractions="gridOverrides?.rowFractions ?? defaultRowFractions"
:style="{
position: 'absolute',
left: '0',
right: '0',
top: handle.cssTop
}"
@resize="onRowResize"
@resize-end="onRowResizeEnd"
/>
</template>
</div>
</template>

View File

@@ -7,49 +7,37 @@ const meta = {
component: PresetMenu,
tags: ['autodocs'],
parameters: {
layout: 'centered'
layout: 'centered',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#1a1a1b' },
{ name: 'light', value: '#ffffff' },
{ name: 'sidebar', value: '#232326' }
]
}
}
} satisfies Meta<typeof PresetMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Empty: Story = {
/** Default rendering — click to see built-in quick presets (Min/Mid/Max) and saved presets. */
export const Default: Story = {
render: () => ({
components: { PresetMenu },
setup() {
return {}
},
template: `
<div class="p-8">
<p class="mb-4 text-sm text-muted-foreground">No presets saved — shows empty state:</p>
<PresetMenu />
</div>
`
})
}
export const WithPresets: Story = {
render: () => ({
components: { PresetMenu },
setup() {
return {}
},
template: `
<div class="p-8">
<p class="mb-4 text-sm text-muted-foreground">Click to see built-in quick presets (Min/Mid/Max) and saved presets:</p>
<PresetMenu />
</div>
`
})
}
/** In a toolbar context alongside a workflow title. */
export const InToolbar: Story = {
render: () => ({
components: { PresetMenu },
setup() {
return {}
},
template: `
<div class="flex h-12 items-center gap-2 rounded-lg border border-border-subtle bg-comfy-menu-bg px-4 py-2 min-w-80">
<span class="truncate font-bold">my_workflow.json</span>
@@ -59,3 +47,32 @@ export const InToolbar: Story = {
`
})
}
/** On sidebar background — verify contrast against dark sidebar. */
export const OnSidebarBackground: Story = {
parameters: {
backgrounds: { default: 'sidebar' }
},
render: () => ({
components: { PresetMenu },
template: `
<div class="p-8">
<PresetMenu />
</div>
`
})
}
/** Narrow container — verify truncation of long preset names. */
export const Compact: Story = {
render: () => ({
components: { PresetMenu },
template: `
<div class="flex h-10 w-48 items-center rounded-lg border border-border-subtle bg-comfy-menu-bg px-2">
<span class="truncate text-sm font-bold">long_workflow_name.json</span>
<div class="flex-1" />
<PresetMenu />
</div>
`
})
}

View File

@@ -0,0 +1,407 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import BuilderConfirmDialog from '@/components/builder/BuilderConfirmDialog.vue'
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
import {
inputItemKey,
parseGroupItemKey
} from '@/components/builder/itemKeyHelper'
import LayoutZoneGrid from '@/components/builder/LayoutZoneGrid.vue'
import { getTemplate } from '@/components/builder/layoutTemplates'
import { useBuilderRename } from '@/components/builder/useBuilderRename'
import { vGroupDraggable } from '@/components/builder/useGroupDrop'
import { useLinearRunPrompt } from '@/components/builder/useLinearRunPrompt'
import {
vWidgetDraggable,
vZoneDropTarget
} from '@/components/builder/useZoneDrop'
import { vZoneItemReorderTarget } from '@/components/builder/useWidgetReorder'
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
import { useArrangeZoneWidgets } from '@/components/builder/useZoneWidgets'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
provide(HideLayoutFieldKey, true)
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { runPrompt } = useLinearRunPrompt()
const settingStore = useSettingStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const { isBuilderMode } = useAppMode()
const activeTemplate = computed(
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('single')!
)
/** The zone where run controls should render (last zone = right column in dual). */
const runZoneId = computed(() => {
const zones = activeTemplate.value.zones
return zones.at(-1)?.id ?? zones[0]?.id ?? ''
})
// Builder mode: draggable zone widgets
const zoneWidgets = useArrangeZoneWidgets()
onMounted(() => {
if (isBuilderMode.value) appModeStore.autoAssignInputs()
})
const widgetsByKey = computed(() => {
const map = new Map<string, ResolvedArrangeWidget>()
for (const [, widgets] of zoneWidgets.value) {
for (const w of widgets) map.set(inputItemKey(w.nodeId, w.widgetName), w)
}
return map
})
function getOrderedItems(zoneId: string) {
const widgets = zoneWidgets.value.get(zoneId) ?? []
const hasRun = zoneId === appModeStore.runControlsZoneId
return appModeStore.getZoneItems(zoneId, [], widgets, hasRun, false)
}
const {
renamingKey,
renameValue,
startRename: startRenameInput,
confirmRename: confirmRenameInput,
cancelRename: cancelRenameInput,
startRenameDeferred: startRenameInputDeferred
} = useBuilderRename((key) => widgetsByKey.value.get(key))
const showRemoveDialog = ref(false)
const pendingRemove = ref<{ nodeId: NodeId; widgetName: string } | null>(null)
function confirmRemoveInput(nodeId: NodeId, widgetName: string) {
pendingRemove.value = { nodeId, widgetName }
showRemoveDialog.value = true
}
function removeInput() {
if (!pendingRemove.value) return
const { nodeId, widgetName } = pendingRemove.value
const idx = appModeStore.selectedInputs.findIndex(
([nId, wName]) => nId === nodeId && wName === widgetName
)
if (idx !== -1) appModeStore.selectedInputs.splice(idx, 1)
showRemoveDialog.value = false
pendingRemove.value = null
}
function findGroupById(itemKey: string) {
const groupId = parseGroupItemKey(itemKey)
if (!groupId) return undefined
return appModeStore.inputGroups.find((g) => g.id === groupId)
}
type ZoneSegment =
| { type: 'inputs'; keys: string[] }
| { type: 'group'; group: InputGroup }
function getZoneSegments(zoneId: string): ZoneSegment[] {
const items = getOrderedItems(zoneId)
const segments: ZoneSegment[] = []
let currentInputKeys: string[] = []
function flushInputs() {
if (currentInputKeys.length > 0) {
segments.push({ type: 'inputs', keys: [...currentInputKeys] })
currentInputKeys = []
}
}
for (const key of items) {
if (key.startsWith('input:')) {
currentInputKeys.push(key)
} else if (key.startsWith('group:')) {
const group = findGroupById(key)
if (group && (isBuilderMode.value || group.items.length >= 1)) {
flushInputs()
segments.push({ type: 'group', group })
}
}
}
flushInputs()
return segments
}
function groupPosition(
group: InputGroup,
segments: ZoneSegment[]
): 'first' | 'middle' | 'last' | 'only' {
const groupSegments = segments.filter(
(s): s is ZoneSegment & { type: 'group' } => s.type === 'group'
)
const idx = groupSegments.findIndex((s) => s.group.id === group.id)
const total = groupSegments.length
const isFirst = idx === 0 && !segments.some((s) => s.type === 'inputs')
if (total === 1) return isFirst ? 'only' : 'last'
if (isFirst) return 'first'
if (idx === total - 1) return 'last'
return 'middle'
}
</script>
<template>
<div class="flex h-full flex-col">
<!-- Inputs area -->
<div class="flex min-h-0 flex-1 flex-col bg-comfy-menu-bg px-2">
<!-- === ZONE GRID (always single or dual) === -->
<LayoutZoneGrid
:template="activeTemplate"
:grid-overrides="appModeStore.gridOverrides"
:dashed="isBuilderMode"
class="min-h-0 flex-1"
>
<template #zone="{ zone }">
<div class="flex size-full flex-col" :data-zone-id="zone.id">
<!-- Inputs (scrollable, order matches builder mode) -->
<div
v-if="!isBuilderMode"
class="flex min-h-0 flex-1 flex-col overflow-y-auto"
>
<div>
<template
v-for="(segment, sIdx) in getZoneSegments(zone.id)"
:key="
segment.type === 'inputs'
? `inputs-${sIdx}`
: `group-${segment.group.id}`
"
>
<AppModeWidgetList
v-if="segment.type === 'inputs'"
:item-keys="segment.keys"
/>
<InputGroupAccordion
v-else
:group="segment.group"
:zone-id="zone.id"
:position="
groupPosition(segment.group, getZoneSegments(zone.id))
"
/>
</template>
</div>
</div>
<!-- Builder mode: draggable zone content (scrollable, short content hugs bottom) -->
<div
v-else
v-zone-drop-target="zone.id"
class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto 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"
>
<template
v-for="itemKey in getOrderedItems(zone.id)"
:key="itemKey"
>
<!-- Input widget -->
<div
v-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
}"
class="shrink-0 cursor-grab overflow-hidden rounded-lg border border-dashed border-border-subtle p-2 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
>
<!-- Builder menu -->
<div class="mb-1 flex items-center gap-1">
<div
v-if="renamingKey === itemKey"
class="flex flex-1 items-center"
>
<input
v-model="renameValue"
type="text"
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none"
@click.stop
@keydown.enter.stop="confirmRenameInput"
@keydown.escape.stop="cancelRenameInput"
@blur="confirmRenameInput"
@vue:mounted="
($event: any) => {
$event.el?.focus()
$event.el?.select()
}
"
/>
</div>
<span
v-else
class="flex-1 truncate text-sm text-muted-foreground"
@dblclick.stop="startRenameInput(itemKey)"
>
{{
widgetsByKey.get(itemKey)!.widget.label ||
widgetsByKey.get(itemKey)!.widget.name
}}
{{ widgetsByKey.get(itemKey)!.node.title }}
</span>
<Popover class="pointer-events-auto shrink-0">
<template #button>
<Button variant="textonly" size="icon">
<i class="icon-[lucide--ellipsis-vertical]" />
</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-1 p-1">
<div
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
@click="
() => {
close()
startRenameInputDeferred(itemKey)
}
"
>
<i class="icon-[lucide--pencil]" />
{{ t('g.rename') }}
</div>
<div
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
@click="
() => {
confirmRemoveInput(
widgetsByKey.get(itemKey)!.nodeId,
widgetsByKey.get(itemKey)!.widgetName
)
close()
}
"
>
<i class="icon-[lucide--x]" />
{{ t('g.remove') }}
</div>
</div>
</template>
</Popover>
</div>
<div class="pointer-events-none" inert>
<WidgetItem
:widget="widgetsByKey.get(itemKey)!.widget"
:node="widgetsByKey.get(itemKey)!.node"
hidden-label
/>
</div>
</div>
<!-- Group accordion -->
<div
v-else-if="
itemKey.startsWith('group:') && findGroupById(itemKey)
"
v-group-draggable="{
groupId: findGroupById(itemKey)!.id,
zone: zone.id
}"
v-zone-item-reorder-target="{
itemKey,
zone: zone.id
}"
class="shrink-0 [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
>
<InputGroupAccordion
:group="findGroupById(itemKey)!"
:zone-id="zone.id"
builder-mode
/>
</div>
<!-- Run controls handled below, pinned to zone bottom -->
</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>
<!-- Create group (pinned below scroll, builder only) -->
<button
v-if="isBuilderMode"
type="button"
class="group/cg flex w-full shrink-0 items-center justify-between border-0 border-t border-border-subtle/40 bg-transparent py-4 pr-5 pl-4 text-sm text-base-foreground outline-none"
@click="appModeStore.createGroup(zone.id)"
>
{{ t('linearMode.groups.createGroup') }}
<i
class="icon-[lucide--plus] size-5 text-muted-foreground group-hover/cg:text-base-foreground"
/>
</button>
<!-- Run controls (pinned to bottom of last zone, both modes) -->
<section
v-if="zone.id === runZoneId"
data-testid="linear-run-controls"
:class="[
'mt-auto shrink-0 border-t p-4 pb-6',
isBuilderMode
? 'border-border-subtle/40'
: 'mx-3 border-border-subtle'
]"
>
<div class="flex items-center justify-between gap-4">
<span
class="shrink-0 text-sm text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 max-w-[35%] min-w-fit flex-1"
/>
</div>
<Button
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
data-testid="linear-run-button"
@click="runPrompt"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>
</div>
</template>
</LayoutZoneGrid>
<PartnerNodesList />
</div>
<BuilderConfirmDialog
v-model="showRemoveDialog"
:title="t('linearMode.groups.confirmRemove')"
:description="t('linearMode.groups.removeDescription')"
:confirm-label="t('g.remove')"
confirm-variant="destructive"
@confirm="removeInput"
/>
</div>
</template>

View File

@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { buildDropIndicator } from './dropIndicatorUtil'
vi.mock('@/scripts/api', () => ({
api: { apiURL: (path: string) => `http://localhost:8188${path}` }
}))
vi.mock('@/scripts/app', () => ({
app: { getPreviewFormatParam: () => '&format=webp' }
}))
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
appendCloudResParam: vi.fn()
}))
function makeNode(type: string, widgetValue?: unknown): LGraphNode {
return {
type,
widgets:
widgetValue !== undefined
? [{ value: widgetValue }, { callback: vi.fn() }]
: undefined
} as unknown as LGraphNode
}
describe('buildDropIndicator', () => {
it('returns undefined for unsupported node types', () => {
expect(buildDropIndicator(makeNode('KSampler'), {})).toBeUndefined()
expect(buildDropIndicator(makeNode('CLIPTextEncode'), {})).toBeUndefined()
})
it('returns image indicator for LoadImage node with filename', () => {
const result = buildDropIndicator(makeNode('LoadImage', 'photo.png'), {
imageLabel: 'Upload'
})
expect(result).toBeDefined()
expect(result!.iconClass).toBe('icon-[lucide--image]')
expect(result!.imageUrl).toContain('/view?')
expect(result!.imageUrl).toContain('filename=photo.png')
expect(result!.label).toBe('Upload')
})
it('returns image indicator with no imageUrl when widget has no value', () => {
const result = buildDropIndicator(makeNode('LoadImage', ''), {})
expect(result).toBeDefined()
expect(result!.imageUrl).toBeUndefined()
})
it('returns image indicator with no imageUrl when widgets are missing', () => {
const node = { type: 'LoadImage' } as unknown as LGraphNode
const result = buildDropIndicator(node, {})
expect(result).toBeDefined()
expect(result!.imageUrl).toBeUndefined()
})
it('includes onMaskEdit when imageUrl exists and openMaskEditor is provided', () => {
const openMaskEditor = vi.fn()
const node = makeNode('LoadImage', 'photo.png')
const result = buildDropIndicator(node, { openMaskEditor })
expect(result!.onMaskEdit).toBeDefined()
result!.onMaskEdit!()
expect(openMaskEditor).toHaveBeenCalledWith(node)
})
it('omits onMaskEdit when no imageUrl', () => {
const openMaskEditor = vi.fn()
const result = buildDropIndicator(makeNode('LoadImage', ''), {
openMaskEditor
})
expect(result!.onMaskEdit).toBeUndefined()
})
it('returns video indicator for LoadVideo node with filename', () => {
const result = buildDropIndicator(makeNode('LoadVideo', 'clip.mp4'), {
videoLabel: 'Upload Video'
})
expect(result).toBeDefined()
expect(result!.iconClass).toBe('icon-[lucide--video]')
expect(result!.videoUrl).toContain('/view?')
expect(result!.videoUrl).toContain('filename=clip.mp4')
expect(result!.label).toBe('Upload Video')
expect(result!.onMaskEdit).toBeUndefined()
})
it('returns video indicator with no videoUrl when widget has no value', () => {
const result = buildDropIndicator(makeNode('LoadVideo', ''), {})
expect(result).toBeDefined()
expect(result!.videoUrl).toBeUndefined()
})
it('parses subfolder and type from widget value', () => {
const result = buildDropIndicator(
makeNode('LoadImage', 'sub/folder/image.png [output]'),
{}
)
expect(result!.imageUrl).toContain('filename=image.png')
expect(result!.imageUrl).toContain('subfolder=sub%2Ffolder')
expect(result!.imageUrl).toContain('type=output')
})
it('invokes widget callback on onClick', () => {
const node = makeNode('LoadImage', 'photo.png')
const result = buildDropIndicator(node, {})
result!.onClick!({} as MouseEvent)
expect(node.widgets![1].callback).toHaveBeenCalledWith(undefined)
})
})

View File

@@ -1,3 +1,4 @@
import { downloadFile } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -5,13 +6,15 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
export interface DropIndicatorData {
interface DropIndicatorData {
iconClass: string
imageUrl?: string
videoUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
onDownload?: () => void
onRemove?: () => void
}
/**
@@ -66,7 +69,17 @@ function buildImageDropIndicator(
onMaskEdit:
imageUrl && options.openMaskEditor
? () => options.openMaskEditor!(node)
: undefined
: undefined,
onDownload: imageUrl ? () => downloadFile(imageUrl) : undefined,
onRemove: imageUrl
? () => {
const imageWidget = node.widgets?.find((w) => w.name === 'image')
if (imageWidget) {
imageWidget.value = ''
imageWidget.callback?.(undefined)
}
}
: undefined
}
}

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest'
import type { LayoutTemplateId } from './layoutTemplates'
import {
buildGridTemplate,
getTemplate,
LAYOUT_TEMPLATES
} from './layoutTemplates'
/** Extract area rows from a grid template string. */
function parseAreaRows(gridStr: string) {
return gridStr
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('"'))
.map((l) => {
const match = l.match(/"([^"]+)"\s*(.*)/)
return {
areas: match?.[1].split(/\s+/) ?? [],
fraction: match?.[2]?.trim() || '1fr'
}
})
}
describe('buildGridTemplate', () => {
const dualTemplate = getTemplate('dual')!
it('returns original gridTemplate when no overrides', () => {
const result = buildGridTemplate(dualTemplate)
expect(result).toBe(dualTemplate.gridTemplate)
})
it('applies column fraction overrides', () => {
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
const colCount = originalRows[0].areas.length
const fractions = Array.from({ length: colCount }, (_, i) => i + 1)
const result = buildGridTemplate(dualTemplate, {
columnFractions: fractions
})
const colLine = result
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('/'))
expect(colLine).toBe(`/ ${fractions.map((f) => `${f}fr`).join(' ')}`)
})
it('applies row fraction overrides in correct positions', () => {
const result = buildGridTemplate(dualTemplate, {
rowFractions: [2]
})
const rows = parseAreaRows(result)
expect(rows[0].fraction).toBe('2fr')
})
it('reorders zone areas in output', () => {
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
const uniqueAreas = [...new Set(originalRows.flatMap((r) => r.areas))]
const swapped = [uniqueAreas[1], uniqueAreas[0]]
const result = buildGridTemplate(dualTemplate, {
zoneOrder: swapped
})
const resultRows = parseAreaRows(result)
expect(resultRows[0].areas[0]).toBe(originalRows[0].areas[1])
expect(resultRows[0].areas[1]).toBe(originalRows[0].areas[0])
})
it('preserves row count when applying overrides', () => {
const result = buildGridTemplate(dualTemplate, {
rowFractions: [1]
})
const resultRows = parseAreaRows(result)
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
expect(resultRows).toHaveLength(originalRows.length)
})
it('falls back to original columns when fractions length mismatches', () => {
const originalColLine = dualTemplate.gridTemplate
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('/'))
const result = buildGridTemplate(dualTemplate, {
columnFractions: [1] // wrong count — should be ignored
})
const resultColLine = result
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('/'))
expect(resultColLine).toBe(originalColLine)
})
it('applies combined overrides together', () => {
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
const uniqueAreas = [...new Set(originalRows.flatMap((r) => r.areas))]
const swapped = [uniqueAreas[1], uniqueAreas[0]]
const colCount = originalRows[0].areas.length
const result = buildGridTemplate(dualTemplate, {
zoneOrder: swapped,
rowFractions: [5],
columnFractions: Array.from({ length: colCount }, () => 2)
})
const resultRows = parseAreaRows(result)
expect(resultRows[0].areas[0]).toBe(originalRows[0].areas[1])
expect(resultRows[0].fraction).toBe('5fr')
const colLine = result
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('/'))
expect(colLine).toContain('2fr')
})
it('empty overrides produce same structure as original', () => {
const result = buildGridTemplate(dualTemplate, {})
const resultRows = parseAreaRows(result)
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
expect(resultRows.map((r) => r.areas)).toEqual(
originalRows.map((r) => r.areas)
)
})
})
describe('getTemplate', () => {
it('returns undefined for invalid ID', () => {
expect(
getTemplate('nonexistent' as unknown as LayoutTemplateId)
).toBeUndefined()
})
it('returns matching template for each known ID', () => {
for (const template of LAYOUT_TEMPLATES) {
expect(getTemplate(template.id)).toBe(template)
}
})
})
describe('LAYOUT_TEMPLATES', () => {
it('has unique IDs', () => {
const ids = LAYOUT_TEMPLATES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('every template has at least one zone', () => {
for (const template of LAYOUT_TEMPLATES) {
expect(template.zones.length).toBeGreaterThan(0)
}
})
it('every template has valid default zone references', () => {
for (const template of LAYOUT_TEMPLATES) {
const zoneIds = template.zones.map((z) => z.id)
expect(zoneIds).toContain(template.defaultRunControlsZone)
expect(zoneIds).toContain(template.defaultPresetStripZone)
}
})
})

View File

@@ -1,11 +1,10 @@
export type LayoutTemplateId = 'focus' | 'grid' | 'sidebar'
export type LayoutTemplateId = 'single' | 'dual'
export interface LayoutZone {
id: string
/** i18n key for the zone label */
label: string
gridArea: string
isOutput?: boolean
}
export interface LayoutTemplate {
@@ -21,99 +20,51 @@ export interface LayoutTemplate {
defaultRunControlsZone: string
/** Zone ID where preset strip goes by default */
defaultPresetStripZone: string
/** Zone IDs that default to bottom alignment */
defaultBottomAlignZones?: string[]
}
export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
{
id: 'focus',
label: 'linearMode.layout.templates.focus',
description: 'linearMode.layout.templates.focusDesc',
icon: 'icon-[lucide--layout-panel-left]',
id: 'single',
label: 'linearMode.layout.templates.single',
description: 'linearMode.layout.templates.singleDesc',
icon: 'icon-[lucide--panel-right]',
gridTemplate: `
"main side1" 1fr
"main side2" 1fr
/ 2fr 1fr
"main" 1fr
/ 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'
gridArea: 'main'
}
],
defaultRunControlsZone: 'side2',
defaultPresetStripZone: 'side1',
defaultBottomAlignZones: ['side2']
defaultRunControlsZone: 'main',
defaultPresetStripZone: 'main'
},
{
id: 'grid',
label: 'linearMode.layout.templates.grid',
description: 'linearMode.layout.templates.gridDesc',
icon: 'icon-[lucide--grid-3x3]',
id: 'dual',
label: 'linearMode.layout.templates.dual',
description: 'linearMode.layout.templates.dualDesc',
icon: 'icon-[lucide--columns-2]',
gridTemplate: `
"z1 z2 z3" 1fr
"z4 z5 z6" 1fr
/ 1fr 1fr 1fr
"left right" 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: 'left',
label: 'linearMode.layout.zones.left',
gridArea: 'left'
},
{ id: 'z6', label: 'linearMode.layout.zones.zone6', gridArea: 'z6' }
],
defaultRunControlsZone: 'z6',
defaultPresetStripZone: 'z3',
defaultBottomAlignZones: ['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'
id: 'right',
label: 'linearMode.layout.zones.right',
gridArea: 'right'
}
],
defaultRunControlsZone: 'sb',
defaultPresetStripZone: 'sb',
defaultBottomAlignZones: ['sb']
defaultRunControlsZone: 'right',
defaultPresetStripZone: 'left'
}
]
@@ -163,13 +114,6 @@ export function buildGridTemplate(
// 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)]

View File

@@ -0,0 +1,50 @@
import { ref } from 'vue'
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { renameWidget } from '@/utils/widgetUtil'
export function useBuilderRename(
getWidget: (key: string) => ResolvedArrangeWidget | undefined
) {
const renamingKey = ref<string | null>(null)
const renameValue = ref('')
const canvasStore = useCanvasStore()
function startRename(itemKey: string) {
const w = getWidget(itemKey)
if (!w) return
renameValue.value = w.widget.label || w.widget.name
renamingKey.value = itemKey
}
function confirmRename() {
if (!renamingKey.value) return
const w = getWidget(renamingKey.value)
if (w) {
const trimmed = renameValue.value.trim()
if (trimmed) {
renameWidget(w.widget, w.node, trimmed)
canvasStore.canvas?.setDirty(true)
}
}
renamingKey.value = null
}
function cancelRename() {
renamingKey.value = null
}
function startRenameDeferred(itemKey: string) {
setTimeout(() => startRename(itemKey), 50)
}
return {
renamingKey,
renameValue,
startRename,
confirmRename,
cancelRename,
startRenameDeferred
}
}

View File

@@ -0,0 +1,234 @@
import {
draggable,
dropTargetForElements
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type { Directive } from 'vue'
import {
inputItemKey,
parseInputItemKey
} from '@/components/builder/itemKeyHelper'
import { getEdgeTriZone } from '@/components/builder/useWidgetReorder'
import { useAppModeStore } from '@/stores/appModeStore'
function getDragItemKey(data: Record<string | symbol, unknown>): string | null {
if (data.type === 'zone-widget')
return inputItemKey(data.nodeId as string, data.widgetName as string)
return null
}
// --- Group body drop target ---
interface GroupDropBinding {
groupId: string
zoneId: string
}
type GroupDropEl = HTMLElement & {
__groupDropCleanup?: () => void
__groupDropValue?: GroupDropBinding
}
/** Drop zone for the group body — accepts zone-widget drags. */
export const vGroupDropTarget: Directive<HTMLElement, GroupDropBinding> = {
mounted(el, { value }) {
const typedEl = el as GroupDropEl
typedEl.__groupDropValue = value
const appModeStore = useAppModeStore()
typedEl.__groupDropCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const itemKey = getDragItemKey(source.data)
if (!itemKey) return false
const group = appModeStore.inputGroups.find(
(g) => g.id === typedEl.__groupDropValue!.groupId
)
return !group?.items.some((i) => i.key === itemKey)
},
onDragEnter: () => el.classList.add('group-drag-over'),
onDragLeave: () => el.classList.remove('group-drag-over'),
onDrop: ({ source, location }) => {
el.classList.remove('group-drag-over')
// Skip if the innermost drop target is a child (item reorder handled it)
if (location.current.dropTargets[0]?.element !== el) return
const itemKey = getDragItemKey(source.data)
if (!itemKey) return
const { groupId, zoneId } = typedEl.__groupDropValue!
appModeStore.moveWidgetItem(itemKey, {
kind: 'group',
zoneId,
groupId
})
}
})
},
updated(el, { value }) {
;(el as GroupDropEl).__groupDropValue = value
},
unmounted(el) {
;(el as GroupDropEl).__groupDropCleanup?.()
}
}
// --- Group item reorder (with center detection for pairing) ---
interface GroupItemReorderBinding {
itemKey: string
groupId: string
}
type GroupItemReorderEl = HTMLElement & {
__groupReorderCleanup?: () => void
__groupReorderValue?: GroupItemReorderBinding
}
function clearGroupIndicator(el: HTMLElement) {
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
}
function setGroupIndicator(
el: HTMLElement,
edge: 'before' | 'center' | 'after'
) {
clearGroupIndicator(el)
if (edge === 'center') {
el.classList.add('pair-indicator')
} else {
el.classList.add(`reorder-${edge}`)
}
}
/** Reorder within a group with three-zone detection for side-by-side pairing. */
export const vGroupItemReorderTarget: Directive<
HTMLElement,
GroupItemReorderBinding
> = {
mounted(el, { value }) {
const typedEl = el as GroupItemReorderEl
typedEl.__groupReorderValue = value
const appModeStore = useAppModeStore()
typedEl.__groupReorderCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const dragKey = getDragItemKey(source.data)
return !!dragKey && dragKey !== typedEl.__groupReorderValue!.itemKey
},
onDrag: ({ location }) => {
setGroupIndicator(
el,
getEdgeTriZone(el, location.current.input.clientY)
)
},
onDragEnter: ({ location }) => {
setGroupIndicator(
el,
getEdgeTriZone(el, location.current.input.clientY)
)
},
onDragLeave: () => clearGroupIndicator(el),
onDrop: ({ source, location }) => {
clearGroupIndicator(el)
const dragKey = getDragItemKey(source.data)
if (!dragKey) return
const { groupId, itemKey } = typedEl.__groupReorderValue!
const edge = getEdgeTriZone(el, location.current.input.clientY)
appModeStore.moveWidgetItem(dragKey, {
kind: 'group-relative',
zoneId: '',
groupId,
targetKey: itemKey,
edge
})
}
})
},
updated(el, { value }) {
;(el as GroupItemReorderEl).__groupReorderValue = value
},
unmounted(el) {
;(el as GroupItemReorderEl).__groupReorderCleanup?.()
}
}
// --- Draggable for items inside a group ---
interface GroupItemDragBinding {
itemKey: string
groupId: string
}
type GroupItemDragEl = HTMLElement & {
__groupItemDragCleanup?: () => void
__groupItemDragValue?: GroupItemDragBinding
}
/** Makes an item inside a group draggable. */
export const vGroupItemDraggable: Directive<HTMLElement, GroupItemDragBinding> =
{
mounted(el, { value }) {
const typedEl = el as GroupItemDragEl
typedEl.__groupItemDragValue = value
typedEl.__groupItemDragCleanup = draggable({
element: el,
getInitialData: () => {
const parsed = parseInputItemKey(
typedEl.__groupItemDragValue!.itemKey
)
return {
type: 'zone-widget',
nodeId: parsed?.nodeId ?? '',
widgetName: parsed?.widgetName ?? '',
sourceZone: '__group__',
sourceGroupId: typedEl.__groupItemDragValue!.groupId
}
}
})
},
updated(el, { value }) {
;(el as GroupItemDragEl).__groupItemDragValue = value
},
unmounted(el) {
;(el as GroupItemDragEl).__groupItemDragCleanup?.()
}
}
// --- Draggable for entire group (reorder within zone) ---
interface GroupDragBinding {
groupId: string
zone: string
}
type GroupDragEl = HTMLElement & {
__groupDragCleanup?: () => void
__groupDragValue?: GroupDragBinding
}
/** Makes a group draggable within the zone order. Uses drag-handle class. */
export const vGroupDraggable: Directive<HTMLElement, GroupDragBinding> = {
mounted(el, { value }) {
const typedEl = el as GroupDragEl
typedEl.__groupDragValue = value
typedEl.__groupDragCleanup = draggable({
element: el,
dragHandle: el.querySelector('.drag-handle') ?? undefined,
getInitialData: () => ({
type: 'zone-group',
groupId: typedEl.__groupDragValue!.groupId,
sourceZone: typedEl.__groupDragValue!.zone
})
})
},
updated(el, { value }) {
;(el as GroupDragEl).__groupDragValue = value
},
unmounted(el) {
;(el as GroupDragEl).__groupDragCleanup?.()
}
}

View File

@@ -0,0 +1,17 @@
import { useCommandStore } from '@/stores/commandStore'
export function useLinearRunPrompt() {
const commandStore = useCommandStore()
async function runPrompt(e: Event) {
const isShiftPressed = 'shiftKey' in e && e.shiftKey
const commandId = isShiftPressed
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
await commandStore.execute(commandId, {
metadata: { subscribe_to_run: false, trigger_source: 'linear' }
})
}
return { runPrompt }
}

View File

@@ -1,6 +1,7 @@
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type { Directive } from 'vue'
import { groupItemKey, inputItemKey } from '@/components/builder/itemKeyHelper'
import { useAppModeStore } from '@/stores/appModeStore'
/** Determine if cursor is in the top or bottom half of the element. */
@@ -9,22 +10,36 @@ function getEdge(el: HTMLElement, clientY: number): 'before' | 'after' {
return clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
function clearIndicator(el: HTMLElement) {
el.classList.remove('reorder-before', 'reorder-after')
/** Three-zone detection: top third = before, center = pair, bottom third = after. */
export function getEdgeTriZone(
el: HTMLElement,
clientY: number
): 'before' | 'center' | 'after' {
const rect = el.getBoundingClientRect()
const third = rect.height / 3
if (clientY < rect.top + third) return 'before'
if (clientY > rect.top + third * 2) return 'after'
return 'center'
}
function setIndicator(el: HTMLElement, edge: 'before' | 'after') {
function clearIndicator(el: HTMLElement) {
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
}
function setIndicator(el: HTMLElement, edge: 'before' | 'after' | 'center') {
clearIndicator(el)
el.classList.add(`reorder-${edge}`)
if (edge === 'center') el.classList.add('pair-indicator')
else 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}`
return inputItemKey(data.nodeId as string, data.widgetName as string)
if (data.type === 'zone-output') return `output:${data.nodeId}`
if (data.type === 'zone-run-controls') return 'run-controls'
if (data.type === 'zone-preset-strip') return 'preset-strip'
if (data.type === 'zone-group') return groupItemKey(data.groupId as string)
return null
}
@@ -32,6 +47,11 @@ function getDragZone(data: Record<string | symbol, unknown>): string | null {
return (data.sourceZone as string) ?? null
}
/** Both keys are input widgets — eligible for center-drop pairing. */
function canPairKeys(a: string, b: string): boolean {
return a.startsWith('input:') && b.startsWith('input:')
}
// --- Unified reorder drop target ---
interface ZoneItemReorderBinding {
@@ -39,8 +59,6 @@ interface ZoneItemReorderBinding {
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 & {
@@ -51,6 +69,7 @@ type ReorderEl = HTMLElement & {
/**
* Unified reorder directive — any zone item (input, output, run controls)
* can be reordered relative to any other item in the same zone.
* When two input widgets are involved, center-drop creates a paired group.
*/
export const vZoneItemReorderTarget: Directive<
HTMLElement,
@@ -67,31 +86,61 @@ export const vZoneItemReorderTarget: Directive<
const dragKey = getDragKey(source.data)
const dragZone = getDragZone(source.data)
if (!dragKey || !dragZone) return false
// Same zone, different item
// Same zone or from a group, different item
return (
dragZone === typedEl.__reorderValue!.zone &&
(dragZone === typedEl.__reorderValue!.zone ||
dragZone === '__group__') &&
dragKey !== typedEl.__reorderValue!.itemKey
)
},
onDrag: ({ location }) => {
setIndicator(el, getEdge(el, location.current.input.clientY))
onDrag: ({ location, source }) => {
const dragKey = getDragKey(source.data)
const targetKey = typedEl.__reorderValue!.itemKey
const pairingAllowed = dragKey && canPairKeys(dragKey, targetKey)
const edge = pairingAllowed
? getEdgeTriZone(el, location.current.input.clientY)
: getEdge(el, location.current.input.clientY)
setIndicator(el, edge)
},
onDragEnter: ({ location }) => {
setIndicator(el, getEdge(el, location.current.input.clientY))
onDragEnter: ({ location, source }) => {
const dragKey = getDragKey(source.data)
const targetKey = typedEl.__reorderValue!.itemKey
const pairingAllowed = dragKey && canPairKeys(dragKey, targetKey)
const edge = pairingAllowed
? getEdgeTriZone(el, location.current.input.clientY)
: getEdge(el, location.current.input.clientY)
setIndicator(el, edge)
},
onDragLeave: () => clearIndicator(el),
onDrop: ({ source, location }) => {
onDrop: ({ source, location, self }) => {
clearIndicator(el)
// Skip if a nested drop target (e.g. group body) is the innermost target
const innermost = location.current.dropTargets[0]
if (innermost && innermost.element !== self.element) return
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
)
const { zone, itemKey } = typedEl.__reorderValue!
const pairingAllowed = canPairKeys(dragKey, itemKey)
const edge = pairingAllowed
? getEdgeTriZone(el, location.current.input.clientY)
: getEdge(el, location.current.input.clientY)
if (edge === 'center') {
appModeStore.moveWidgetItem(dragKey, {
kind: 'zone-pair',
zoneId: zone,
targetKey: itemKey
})
} else {
appModeStore.moveWidgetItem(dragKey, {
kind: 'zone-relative',
zoneId: zone,
targetKey: itemKey,
edge
})
}
}
})
},

View File

@@ -4,8 +4,8 @@ import {
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type { Directive } from 'vue'
import { inputItemKey } from '@/components/builder/itemKeyHelper'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { OUTPUT_ZONE_KEY } from '@/components/builder/useZoneWidgets'
import { useAppModeStore } from '@/stores/appModeStore'
interface WidgetDragData {
@@ -15,12 +15,6 @@ interface WidgetDragData {
sourceZone: string
}
interface OutputDragData {
type: 'zone-output'
nodeId: NodeId
sourceZone: string
}
interface RunControlsDragData {
type: 'zone-run-controls'
sourceZone: string
@@ -37,12 +31,6 @@ function isWidgetDragData(
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 {
@@ -55,28 +43,30 @@ function isPresetStripDragData(
return data.type === 'zone-preset-strip'
}
interface GroupDragData {
type: 'zone-group'
groupId: string
sourceZone: string
}
function isGroupDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & GroupDragData {
return data.type === 'zone-group'
}
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
@@ -99,78 +89,6 @@ export const vWidgetDraggable: Directive<HTMLElement, DragBindingValue> = {
}
}
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?.()
}
}
type PresetStripDragEl = HTMLElement & {
__dragCleanup?: () => void
__sourceZone?: string
}
export const vPresetStripDraggable: Directive<HTMLElement, string> = {
mounted(el, { value: sourceZone }) {
const typedEl = el as PresetStripDragEl
typedEl.__sourceZone = sourceZone
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => ({
type: 'zone-preset-strip',
sourceZone: typedEl.__sourceZone!
})
})
},
updated(el, { value: sourceZone }) {
;(el as PresetStripDragEl).__sourceZone = sourceZone
},
unmounted(el) {
;(el as PresetStripDragEl).__dragCleanup?.()
}
}
export const vZoneDropTarget: Directive<HTMLElement, string> = {
mounted(el, { value: zoneId }) {
const typedEl = el as DragEl
@@ -181,26 +99,39 @@ export const vZoneDropTarget: Directive<HTMLElement, string> = {
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
if (isPresetStripDragData(data))
return data.sourceZone !== typedEl.__zoneId
if (isGroupDragData(data)) return data.sourceZone !== typedEl.__zoneId
return false
},
onDragEnter: () => el.classList.add('zone-drag-over'),
onDragLeave: () => el.classList.remove('zone-drag-over'),
onDrop: ({ source }) => {
onDrop: ({ source, location, self }) => {
el.classList.remove('zone-drag-over')
// Skip if a nested drop target (e.g. group body) is the innermost target
const innermost = location.current.dropTargets[0]
if (innermost && innermost.element !== self.element) return
const data = source.data
if (isWidgetDragData(data)) {
const itemKey = inputItemKey(data.nodeId, data.widgetName)
appModeStore.moveWidgetItem(itemKey, {
kind: 'zone',
zoneId: typedEl.__zoneId!
})
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!)
} else if (isPresetStripDragData(data)) {
appModeStore.setPresetStripZone(typedEl.__zoneId!)
} else if (isGroupDragData(data)) {
appModeStore.moveGroupToZone(
data.groupId,
data.sourceZone,
typedEl.__zoneId!
)
}
}
})

View File

@@ -1,25 +1,11 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DropIndicatorData } from '@/components/builder/dropIndicatorUtil'
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
import { getTemplate } from '@/components/builder/layoutTemplates'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
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
@@ -27,13 +13,6 @@ export interface ResolvedArrangeWidget {
widget: IBaseWidget
}
export interface EnrichedNodeData extends VueNodeData {
hasErrors: boolean
dropIndicator?: DropIndicatorData
onDragDrop?: LGraphNode['onDragDrop']
onDragOver?: LGraphNode['onDragOver']
}
export function inputsForZone(
selectedInputs: [NodeId, string][],
getZone: (nodeId: NodeId, widgetName: string) => string | undefined,
@@ -48,21 +27,19 @@ export function inputsForZone(
}
/**
* Composable for ArrangeLayout (builder/arrange mode).
* Composable for 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')!
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('single')!
)
return computed(() => {
const map = new Map<string, ResolvedArrangeWidget[]>()
const defaultZoneId =
template.value.zones.find((z) => !z.isOutput)?.id ??
template.value.zones[0]?.id
const defaultZoneId = template.value.zones[0]?.id
for (const zone of template.value.zones) {
const inputs = inputsForZone(
@@ -83,116 +60,3 @@ export function useArrangeZoneWidgets() {
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 maskEditor = useMaskEditor()
const { t } = useI18n()
const template = computed(
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
)
const dropIndicatorOptions = computed(() => ({
imageLabel: t('linearMode.dragAndDropImage'),
videoLabel: t('linearMode.dragAndDropVideo'),
openMaskEditor: maskEditor.openMaskEditor
}))
return computed(() => {
const map = new Map<string, EnrichedNodeData[]>()
const defaultZoneId =
template.value.zones.find((z) => !z.isOutput)?.id ??
template.value.zones[0]?.id
for (const zone of template.value.zones) {
const inputs = inputsForZone(
appModeStore.selectedInputs,
appModeStore.getZone,
zone.id,
defaultZoneId
)
map.set(
zone.id,
resolveAppWidgets(
inputs,
executionErrorStore,
dropIndicatorOptions.value
)
)
}
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>,
dropIndicatorOptions?: Parameters<typeof buildDropIndicator>[1]
): 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],
dropIndicator: dropIndicatorOptions
? buildDropIndicator(node, dropIndicatorOptions)
: undefined,
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
}