Compare commits

...

2 Commits

Author SHA1 Message Date
Koshi
c246e23a2f fix: use ref for renameOpenedAt for proper Vue reactivity 2026-04-06 03:06:00 +02:00
Koshi
64b895028a feat: add collapsible input groups for app builder mode
Add input groups to the app builder, allowing users to organize widgets
into named collapsible groups with drag-and-drop reordering, side-by-side
pairing, and cross-group item movement.

- InputGroupAccordion: Reka UI Collapsible component with rename,
  ungroup confirmation dialog, builder/app mode rendering
- inputGroupStore: Pinia store with full CRUD, persistence to
  linearData, auto-delete empty groups, pair management
- useGroupDrop: pragmatic-drag-and-drop directives for within-group
  reorder, cross-group moves, and DraggableList mouseup bridge
- useInputGroups: pure functions for resolving items, auto-naming,
  pair grouping
- itemKeyHelper: key format utilities for input/group item keys
- UngroupConfirmDialog: extracted Reka UI Dialog component
- PopoverMenuItem: local type replacing PrimeVue MenuItem import
- "Add to group" / "New group" in widget context menu
- "Create group" button pinned at bottom (builder mode)
- Drag ungrouped inputs into groups, drag out to ungroup
- 56 unit tests across 4 test files

Follow-up: Unified ordering of ungrouped inputs and groups (reorder/swap
positions between them) is scoped for a separate PR to keep LOC manageable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 02:42:48 +02:00
17 changed files with 1860 additions and 22 deletions

View File

@@ -5,6 +5,7 @@ import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
@@ -28,12 +29,14 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { useInputGroupStore } from '@/stores/inputGroupStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
type BoundStyle = { top: string; left: string; width: string; height: string }
const appModeStore = useAppModeStore()
const inputGroupStore = useInputGroupStore()
const canvasInteractions = useCanvasInteractions()
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
@@ -207,13 +210,43 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
}}
</div>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<AppModeWidgetList builder-mode />
</DraggableList>
<template v-if="isArrangeMode">
<DraggableList
:key="inputGroupStore.groupedItemKeys.size"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<AppModeWidgetList builder-mode />
</DraggableList>
<div v-if="inputGroupStore.inputGroups.length" class="px-2 pb-2">
<InputGroupAccordion
v-for="(group, idx) in inputGroupStore.inputGroups"
:key="group.id"
:group
builder-mode
:position="
inputGroupStore.inputGroups.length === 1
? 'only'
: idx === 0
? 'first'
: idx === inputGroupStore.inputGroups.length - 1
? 'last'
: 'middle'
"
/>
</div>
<div class="flex-1" />
<button
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="inputGroupStore.createGroup()"
>
{{ t('linearMode.groups.createGroup') }}
<i
class="icon-[lucide--plus] size-5 text-muted-foreground group-hover/cg:text-base-foreground"
/>
</button>
</template>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"

View File

@@ -3,6 +3,9 @@ import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { inputItemKey } from '@/components/builder/itemKeyHelper'
import { autoGroupName } from '@/components/builder/useInputGroups'
import type { PopoverMenuItem } from '@/components/ui/Popover.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
@@ -20,6 +23,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useInputGroupStore } from '@/stores/inputGroupStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
@@ -42,6 +46,7 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const inputGroupStore = useInputGroupStore()
const maskEditor = useMaskEditor()
provide(HideLayoutFieldKey, true)
@@ -62,6 +67,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
if (inputGroupStore.isGrouped(nodeId, widgetName)) return []
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
@@ -153,6 +159,53 @@ async function handleDragDrop(e: DragEvent) {
}
}
function buildMenuEntries(action: WidgetEntry['action']): PopoverMenuItem[] {
const entries: PopoverMenuItem[] = [
{
label: t('g.rename'),
icon: 'icon-[lucide--pencil]',
command: () => promptRenameWidget(action.widget, action.node, t)
},
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]
if (!builderMode) return entries
const itemKey = inputItemKey(action.node.id, action.widget.name)
const groups = inputGroupStore.inputGroups
if (groups.length > 0) {
entries.push({ separator: true })
for (const group of groups) {
const name = group.name || autoGroupName(group)
entries.push({
label: `${t('linearMode.groups.addToGroup')}: ${name}`,
icon: 'icon-[lucide--group]',
command: () => inputGroupStore.addItemToGroup(group.id, itemKey)
})
}
}
entries.push(
...(groups.length === 0 ? [{ separator: true } as PopoverMenuItem] : []),
{
label: t('linearMode.groups.newGroup'),
icon: 'icon-[lucide--plus]',
command: () => {
const id = inputGroupStore.createGroup()
inputGroupStore.addItemToGroup(id, itemKey)
}
}
)
return entries
}
defineExpose({ handleDragDrop })
</script>
<template>
@@ -165,6 +218,9 @@ defineExpose({ handleDragDrop })
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
)
"
:data-item-key="
builderMode ? inputItemKey(action.node.id, action.widget.name) : undefined
"
:aria-label="
builderMode
? `${action.widget.label ?? action.widget.name} ${action.node.title}`
@@ -193,19 +249,7 @@ defineExpose({ handleDragDrop })
<div v-else class="flex-1" />
<Popover
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
:entries="[
{
label: t('g.rename'),
icon: 'icon-[lucide--pencil]',
command: () => promptRenameWidget(action.widget, action.node, t)
},
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]"
:entries="buildMenuEntries(action)"
>
<template #button>
<Button

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, nextTick, provide, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import UngroupConfirmDialog from '@/components/builder/UngroupConfirmDialog.vue'
import {
vGroupDropTarget,
vGroupItemDrag,
vGroupItemReorder
} 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 { useInputGroupStore } from '@/stores/inputGroupStore'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import type { WidgetValue } from '@/utils/widgetUtil'
import { cn } from '@/utils/tailwindUtil'
const {
group,
builderMode = false,
position = 'middle'
} = defineProps<{
group: InputGroup
builderMode?: boolean
position?: 'first' | 'middle' | 'last' | 'only'
}>()
const { t } = useI18n()
const inputGroupStore = useInputGroupStore()
const canvasStore = useCanvasStore()
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const isOpen = ref(builderMode)
const isRenaming = ref(false)
const renameInputRef = useTemplateRef<HTMLInputElement>('renameInput')
watch(isRenaming, (val) => {
if (val) {
nextTick(() => {
renameInputRef.value?.focus()
renameInputRef.value?.select()
})
}
})
const showUngroupDialog = ref(false)
const renameValue = ref('')
const RENAME_SETTLE_MS = 150
const renameOpenedAt = ref(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
renameOpenedAt.value = Date.now()
isRenaming.value = true
}
function confirmRename() {
// Guard: blur fires immediately on some browsers before the user can type
if (Date.now() - renameOpenedAt.value < RENAME_SETTLE_MS) return
const trimmed = renameValue.value.trim()
inputGroupStore.renameGroup(group.id, trimmed || null)
isRenaming.value = false
}
function cancelRename() {
isRenaming.value = false
}
function startRenameDeferred() {
setTimeout(startRename, 50)
}
function handleDissolve() {
inputGroupStore.deleteGroup(group.id)
}
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 -->
<div
:class="
cn(
'flex items-center gap-1',
builderMode ? 'py-1 pr-1.5 pl-1' : 'px-4 py-2'
)
"
>
<!-- Rename input -->
<div v-if="isRenaming" class="flex flex-1 items-center gap-1.5 px-3 py-2">
<input
ref="renameInput"
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"
/>
</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"
>
<span
:title="displayName"
class="flex-1 truncate text-sm font-bold text-base-foreground"
@dblclick.stop="startRename"
>
{{ displayName }}
</span>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)
"
/>
</button>
</CollapsibleTrigger>
<!-- Builder actions -->
<Popover v-if="builderMode" class="-mr-2 shrink-0">
<template #button>
<Button variant="textonly" size="icon">
<i class="icon-[lucide--ellipsis]" />
</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.groups.ungroup') }}
</div>
</div>
</template>
</Popover>
<UngroupConfirmDialog
v-model:open="showUngroupDialog"
@confirm="handleDissolve"
/>
</div>
<CollapsibleContent>
<!-- Builder mode: editable list -->
<div
v-if="builderMode"
v-group-drop-target="{ groupId: group.id }"
: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-drag="{ itemKey: row.item.key, groupId: group.id }"
v-group-item-reorder="{
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-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-drag="{ itemKey: item.key, groupId: group.id }"
v-group-item-reorder="{
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-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.groups.emptyGroup') }}
</div>
</div>
<!-- App mode: interactive widgets -->
<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-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-widget-actions
class="w-full"
@update:widget-value="
handleWidgetValueUpdate(item.widget, $event)
"
/>
</div>
</div>
</template>
</div>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -0,0 +1,63 @@
<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>('open', { required: true })
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">
{{ 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="handleConfirm">
{{ t('linearMode.groups.ungroup') }}
</Button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import {
groupItemKey,
inputItemKey,
parseGroupItemKey,
parseInputItemKey
} from './itemKeyHelper'
describe('inputItemKey', () => {
it('builds key from string nodeId', () => {
expect(inputItemKey('42', 'steps')).toBe('input:42:steps')
})
it('builds key from numeric nodeId', () => {
expect(inputItemKey(7, 'cfg')).toBe('input:7:cfg')
})
})
describe('groupItemKey', () => {
it('builds key from groupId', () => {
expect(groupItemKey('abc-123')).toBe('group:abc-123')
})
})
describe('parseInputItemKey', () => {
it('parses a valid input key', () => {
expect(parseInputItemKey('input:42:steps')).toEqual({
nodeId: '42',
widgetName: 'steps'
})
})
it('handles widget names containing colons', () => {
expect(parseInputItemKey('input:5:a:b:c')).toEqual({
nodeId: '5',
widgetName: 'a:b:c'
})
})
it('returns null for non-input keys', () => {
expect(parseInputItemKey('group:abc')).toBeNull()
expect(parseInputItemKey('output:1')).toBeNull()
})
})
describe('parseGroupItemKey', () => {
it('parses a valid group key', () => {
expect(parseGroupItemKey('group:abc-123')).toBe('abc-123')
})
it('returns null for non-group keys', () => {
expect(parseGroupItemKey('input:1:steps')).toBeNull()
})
})

View File

@@ -0,0 +1,27 @@
/** Build an input item key from nodeId and widgetName. */
export function inputItemKey(
nodeId: string | number,
widgetName: string
): string {
return `input:${nodeId}:${widgetName}`
}
/** Build a group item key from groupId. */
export function groupItemKey(groupId: string): string {
return `group:${groupId}`
}
/** Parse an input item key into its nodeId and widgetName parts. Returns null if not an input key. */
export function parseInputItemKey(
key: string
): { nodeId: string; widgetName: string } | null {
if (!key.startsWith('input:')) return null
const parts = key.split(':')
return { nodeId: parts[1], widgetName: parts.slice(2).join(':') }
}
/** Parse a group item key into its groupId. Returns null if not a group key. */
export function parseGroupItemKey(key: string): string | null {
if (!key.startsWith('group:')) return null
return key.slice('group:'.length)
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { getDragItemKey, getEdgeTriZone } from './useGroupDrop'
function mockElement(top: number, height: number): HTMLElement {
return {
getBoundingClientRect: () => ({
top,
height,
bottom: top + height,
left: 0,
right: 0,
width: 0,
x: 0,
y: top,
toJSON: () => ({})
})
} as unknown as HTMLElement
}
describe('getEdgeTriZone', () => {
it('returns "before" for top third', () => {
expect(getEdgeTriZone(mockElement(100, 90), 110)).toBe('before')
})
it('returns "center" for middle third', () => {
expect(getEdgeTriZone(mockElement(100, 90), 145)).toBe('center')
})
it('returns "after" for bottom third', () => {
expect(getEdgeTriZone(mockElement(100, 90), 180)).toBe('after')
})
it('returns "before" at exact top boundary', () => {
expect(getEdgeTriZone(mockElement(100, 90), 100)).toBe('before')
})
it('returns "after" at exact bottom boundary', () => {
expect(getEdgeTriZone(mockElement(100, 90), 190)).toBe('after')
})
})
describe('getDragItemKey', () => {
it('returns itemKey for group-item type', () => {
expect(
getDragItemKey({ type: 'group-item', itemKey: 'input:1:steps' })
).toBe('input:1:steps')
})
it('returns null for non-group-item type', () => {
expect(
getDragItemKey({ type: 'other', itemKey: 'input:1:steps' })
).toBeNull()
})
it('returns null when itemKey is not a string', () => {
expect(getDragItemKey({ type: 'group-item', itemKey: 123 })).toBeNull()
})
it('returns null for empty data', () => {
expect(getDragItemKey({})).toBeNull()
})
})

View File

@@ -0,0 +1,267 @@
import {
draggable,
dropTargetForElements
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import type { Directive } from 'vue'
import { parseInputItemKey } from '@/components/builder/itemKeyHelper'
import { useInputGroupStore } from '@/stores/inputGroupStore'
/** Divide an element into three vertical zones for drop detection. */
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'
}
export function getDragItemKey(
data: Record<string | symbol, unknown>
): string | null {
if (data.type === 'group-item' && typeof data.itemKey === 'string')
return data.itemKey
return null
}
function clearIndicator(el: HTMLElement) {
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
}
function setIndicator(el: HTMLElement, edge: 'before' | 'center' | 'after') {
clearIndicator(el)
if (edge === 'center') el.classList.add('pair-indicator')
else el.classList.add(`reorder-${edge}`)
}
// ── Item reorder + pair drop target ──────────────────────────────────
interface ItemReorderBinding {
itemKey: string
groupId: string
}
type ItemReorderEl = HTMLElement & {
__reorderCleanup?: () => void
__reorderValue?: ItemReorderBinding
}
export const vGroupItemReorder: Directive<HTMLElement, ItemReorderBinding> = {
mounted(el, { value }) {
const typedEl = el as ItemReorderEl
typedEl.__reorderValue = value
const store = useInputGroupStore()
typedEl.__reorderCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const dragKey = getDragItemKey(source.data)
return !!dragKey && dragKey !== typedEl.__reorderValue!.itemKey
},
onDrag: ({ location }) => {
setIndicator(el, getEdgeTriZone(el, location.current.input.clientY))
},
onDragEnter: ({ location }) => {
setIndicator(el, getEdgeTriZone(el, location.current.input.clientY))
},
onDragLeave: () => clearIndicator(el),
onDrop: ({ source, location }) => {
clearIndicator(el)
const dragKey = getDragItemKey(source.data)
if (!dragKey) return
const { groupId, itemKey } = typedEl.__reorderValue!
const edge = getEdgeTriZone(el, location.current.input.clientY)
const sameGroup = !!store
.findGroup(groupId)
?.items.some((i) => i.key === dragKey)
if (!sameGroup) {
store.moveItemToGroupAt(groupId, dragKey, itemKey, edge)
return
}
if (edge === 'center') {
const targetItem = store
.findGroup(groupId)
?.items.find((i) => i.key === itemKey)
if (targetItem?.pairId) {
store.replaceInPair(groupId, itemKey, dragKey)
} else {
store.pairItemsInGroup(groupId, itemKey, dragKey)
}
} else {
store.unpairItem(groupId, dragKey)
store.reorderWithinGroup(groupId, dragKey, itemKey, edge)
}
}
})
},
updated(el, { value }) {
;(el as ItemReorderEl).__reorderValue = value
},
unmounted(el) {
;(el as ItemReorderEl).__reorderCleanup?.()
}
}
// ── Draggable item ───────────────────────────────────────────────────
interface ItemDragBinding {
itemKey: string
groupId: string
}
type ItemDragEl = HTMLElement & {
__dragCleanup?: () => void
__dragValue?: ItemDragBinding
}
export const vGroupItemDrag: Directive<HTMLElement, ItemDragBinding> = {
mounted(el, { value }) {
const typedEl = el as ItemDragEl
typedEl.__dragValue = value
const store = useInputGroupStore()
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => {
const parsed = parseInputItemKey(typedEl.__dragValue!.itemKey)
return {
type: 'group-item',
itemKey: typedEl.__dragValue!.itemKey,
nodeId: parsed?.nodeId ?? '',
widgetName: parsed?.widgetName ?? '',
sourceGroupId: typedEl.__dragValue!.groupId
}
},
onDrop: ({ location }) => {
if (location.current.dropTargets.length > 0) return
const { groupId, itemKey } = typedEl.__dragValue!
// Still over own group body → don't ungroup
const overGroup = findGroupDropUnderPointer(
location.current.input.clientX,
location.current.input.clientY
)
if (overGroup?.groupId === groupId) return
store.removeItemFromGroup(groupId, itemKey)
}
})
},
updated(el, { value }) {
;(el as ItemDragEl).__dragValue = value
},
unmounted(el) {
;(el as ItemDragEl).__dragCleanup?.()
}
}
// ── Group body drop target (for items dragged from outside) ──────────
interface GroupDropBinding {
groupId: string
}
type GroupDropEl = HTMLElement & {
__groupDropCleanup?: () => void
__groupDropValue?: GroupDropBinding
}
const GROUP_DROP_ATTR = 'data-group-drop-id'
/** Find the group drop target under the pointer, ignoring the dragged element. */
function findGroupDropUnderPointer(
x: number,
y: number
): { el: HTMLElement; groupId: string } | null {
for (const el of document.elementsFromPoint(x, y)) {
const groupId = (el as HTMLElement).getAttribute?.(GROUP_DROP_ATTR)
if (groupId) return { el: el as HTMLElement, groupId }
}
return null
}
/**
* Document-level mouseup bridge: when a DraggableList drag ends over a group
* drop target, add the item to that group. Captures the item key on mousedown
* to avoid racing with DraggableList's cleanup (which removes .is-draggable).
*/
let pendingDragKey: string | null = null
let bridgeRefCount = 0
let removeBridge: (() => void) | null = null
function setupListToGroupBridge() {
function onMouseDown(e: MouseEvent) {
const target = (e.target as HTMLElement)?.closest?.('.draggable-item')
pendingDragKey = (target as HTMLElement)?.dataset?.itemKey ?? null
}
function onMouseUp(e: MouseEvent) {
const itemKey = pendingDragKey
pendingDragKey = null
if (!itemKey) return
const target = findGroupDropUnderPointer(e.clientX, e.clientY)
if (!target) return
const store = useInputGroupStore()
const group = store.findGroup(target.groupId)
if (group?.items.some((i) => i.key === itemKey)) return
store.addItemToGroup(target.groupId, itemKey)
}
document.addEventListener('mousedown', onMouseDown)
document.addEventListener('mouseup', onMouseUp)
removeBridge = () => {
document.removeEventListener('mousedown', onMouseDown)
document.removeEventListener('mouseup', onMouseUp)
}
}
export const vGroupDropTarget: Directive<HTMLElement, GroupDropBinding> = {
mounted(el, { value }) {
const typedEl = el as GroupDropEl
typedEl.__groupDropValue = value
const store = useInputGroupStore()
el.setAttribute(GROUP_DROP_ATTR, value.groupId)
bridgeRefCount++
if (bridgeRefCount === 1) setupListToGroupBridge()
// Pragmatic DnD drop target (for items dragged within/between groups)
typedEl.__groupDropCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const itemKey = getDragItemKey(source.data)
if (!itemKey) return false
const group = store.findGroup(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')
if (location.current.dropTargets[0]?.element !== el) return
const itemKey = getDragItemKey(source.data)
if (!itemKey) return
store.addItemToGroup(typedEl.__groupDropValue!.groupId, itemKey)
}
})
},
updated(el, { value }) {
;(el as GroupDropEl).__groupDropValue = value
el.setAttribute(GROUP_DROP_ATTR, value.groupId)
},
unmounted(el) {
;(el as GroupDropEl).__groupDropCleanup?.()
el.removeAttribute(GROUP_DROP_ATTR)
bridgeRefCount--
if (bridgeRefCount === 0) {
removeBridge?.()
removeBridge = null
}
}
}

View File

@@ -0,0 +1,201 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
const mockResolveNodeWidget =
vi.fn<(...args: unknown[]) => [LGraphNode, IBaseWidget] | [LGraphNode] | []>()
vi.mock('@/utils/litegraphUtil', () => ({
resolveNodeWidget: (...args: unknown[]) => mockResolveNodeWidget(...args)
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
import {
autoGroupName,
groupedByPair,
resolveGroupItems
} from './useInputGroups'
beforeEach(() => {
vi.clearAllMocks()
})
function makeNode(id: string): LGraphNode {
return { id } as unknown as LGraphNode
}
function makeWidget(name: string, label?: string): IBaseWidget {
return { name, label } as unknown as IBaseWidget
}
function makeGroup(items: { key: string; pairId?: string }[]): InputGroup {
return { id: 'g1', name: null, items }
}
function makeResolvedItem(key: string, opts: { pairId?: string } = {}) {
return {
key,
pairId: opts.pairId,
node: makeNode('1'),
widget: makeWidget('w'),
nodeId: '1',
widgetName: 'w'
}
}
describe('groupedByPair', () => {
it('returns empty for empty input', () => {
expect(groupedByPair([])).toEqual([])
})
it('treats all items without pairId as singles', () => {
const items = [makeResolvedItem('a'), makeResolvedItem('b')]
const rows = groupedByPair(items)
expect(rows).toHaveLength(2)
expect(rows[0]).toMatchObject({ type: 'single' })
expect(rows[1]).toMatchObject({ type: 'single' })
})
it('pairs two items with matching pairId', () => {
const items = [
makeResolvedItem('a', { pairId: 'p1' }),
makeResolvedItem('b', { pairId: 'p1' })
]
const rows = groupedByPair(items)
expect(rows).toHaveLength(1)
expect(rows[0].type).toBe('pair')
if (rows[0].type === 'pair') {
expect(rows[0].items[0].key).toBe('a')
expect(rows[0].items[1].key).toBe('b')
}
})
it('renders orphaned pairId (no partner) as single', () => {
const items = [makeResolvedItem('a', { pairId: 'lonely' })]
const rows = groupedByPair(items)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ type: 'single' })
})
it('handles mixed singles and pairs', () => {
const items = [
makeResolvedItem('a'),
makeResolvedItem('b', { pairId: 'p1' }),
makeResolvedItem('c', { pairId: 'p1' }),
makeResolvedItem('d')
]
const rows = groupedByPair(items)
expect(rows).toHaveLength(3)
expect(rows[0]).toMatchObject({ type: 'single' })
expect(rows[1]).toMatchObject({ type: 'pair' })
expect(rows[2]).toMatchObject({ type: 'single' })
})
it('pairs first two of three items with same pairId, third becomes single', () => {
const items = [
makeResolvedItem('a', { pairId: 'p1' }),
makeResolvedItem('b', { pairId: 'p1' }),
makeResolvedItem('c', { pairId: 'p1' })
]
const rows = groupedByPair(items)
expect(rows).toHaveLength(2)
expect(rows[0].type).toBe('pair')
expect(rows[1]).toMatchObject({ type: 'single' })
})
})
describe('autoGroupName', () => {
it('joins widget labels with comma', () => {
mockResolveNodeWidget
.mockReturnValueOnce([makeNode('1'), makeWidget('w1', 'Width')])
.mockReturnValueOnce([makeNode('2'), makeWidget('w2', 'Height')])
const group = makeGroup([{ key: 'input:1:w1' }, { key: 'input:2:w2' }])
expect(autoGroupName(group)).toBe('Width, Height')
})
it('falls back to widget name when label is absent', () => {
mockResolveNodeWidget.mockReturnValueOnce([
makeNode('1'),
makeWidget('steps')
])
const group = makeGroup([{ key: 'input:1:steps' }])
expect(autoGroupName(group)).toBe('steps')
})
it('returns untitled key when no widgets resolve', () => {
mockResolveNodeWidget.mockReturnValue([])
const group = makeGroup([{ key: 'input:1:w' }])
expect(autoGroupName(group)).toBe('linearMode.groups.untitled')
})
it('skips non-input keys', () => {
mockResolveNodeWidget.mockReturnValueOnce([
makeNode('1'),
makeWidget('w', 'OK')
])
const group = makeGroup([{ key: 'output:1:w' }, { key: 'input:1:w' }])
expect(autoGroupName(group)).toBe('OK')
expect(mockResolveNodeWidget).toHaveBeenCalledTimes(1)
})
})
describe('resolveGroupItems', () => {
it('filters out items where resolveNodeWidget returns empty', () => {
mockResolveNodeWidget
.mockReturnValueOnce([makeNode('1'), makeWidget('w1')])
.mockReturnValueOnce([])
const group = makeGroup([{ key: 'input:1:w1' }, { key: 'input:2:missing' }])
const resolved = resolveGroupItems(group)
expect(resolved).toHaveLength(1)
expect(resolved[0].widgetName).toBe('w1')
})
it('handles widget names containing colons', () => {
mockResolveNodeWidget.mockReturnValueOnce([
makeNode('5'),
makeWidget('a:b:c')
])
const group = makeGroup([{ key: 'input:5:a:b:c' }])
const resolved = resolveGroupItems(group)
expect(resolved).toHaveLength(1)
expect(resolved[0].nodeId).toBe('5')
expect(resolved[0].widgetName).toBe('a:b:c')
})
it('skips non-input keys', () => {
const group = makeGroup([{ key: 'other:1:w' }])
const resolved = resolveGroupItems(group)
expect(resolved).toHaveLength(0)
expect(mockResolveNodeWidget).not.toHaveBeenCalled()
})
it('preserves pairId on resolved items', () => {
mockResolveNodeWidget.mockReturnValueOnce([makeNode('1'), makeWidget('w')])
const group = makeGroup([{ key: 'input:1:w', pairId: 'p1' }])
const resolved = resolveGroupItems(group)
expect(resolved[0].pairId).toBe('p1')
})
})

View File

@@ -0,0 +1,86 @@
import { parseInputItemKey } from '@/components/builder/itemKeyHelper'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
export interface ResolvedGroupItem {
key: string
pairId?: string
node: LGraphNode
widget: IBaseWidget
nodeId: string
widgetName: string
}
/** Row of items to render — single or side-by-side pair. */
export type GroupRow =
| { type: 'single'; item: ResolvedGroupItem }
| { type: 'pair'; items: [ResolvedGroupItem, ResolvedGroupItem] }
/** Derive a group name from the labels of its contained widgets. */
export function autoGroupName(group: InputGroup): string {
const labels: string[] = []
for (const item of group.items) {
const parsed = parseInputItemKey(item.key)
if (!parsed) continue
const [, widget] = resolveNodeWidget(parsed.nodeId, parsed.widgetName)
if (widget) labels.push(widget.label || widget.name)
}
return labels.join(', ') || t('linearMode.groups.untitled')
}
/**
* Resolve item keys to widget/node data.
* Items whose node or widget cannot be resolved are silently omitted.
*/
export function resolveGroupItems(group: InputGroup): ResolvedGroupItem[] {
const resolved: ResolvedGroupItem[] = []
for (const item of group.items) {
const parsed = parseInputItemKey(item.key)
if (!parsed) continue
const { nodeId, widgetName } = parsed
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (node && widget) {
resolved.push({
key: item.key,
pairId: item.pairId,
node,
widget,
nodeId,
widgetName
})
}
}
return resolved
}
/** Group resolved items into rows, pairing items with matching pairId. */
export function groupedByPair(items: ResolvedGroupItem[]): GroupRow[] {
const rows: GroupRow[] = []
const paired = new Set<string>()
for (const item of items) {
if (paired.has(item.key)) continue
if (item.pairId) {
const partner = items.find(
(other) =>
other.key !== item.key &&
other.pairId === item.pairId &&
!paired.has(other.key)
)
if (partner) {
paired.add(item.key)
paired.add(partner.key)
rows.push({ type: 'pair', items: [item, partner] })
continue
}
}
rows.push({ type: 'single', item })
}
return rows
}

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import {
PopoverArrow,
PopoverContent,
@@ -11,6 +10,14 @@ import {
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
export interface PopoverMenuItem {
label?: string
icon?: string
separator?: boolean
disabled?: boolean
command?: (...args: unknown[]) => void
}
defineOptions({
inheritAttrs: false
})
@@ -21,7 +28,7 @@ const {
to,
showArrow = true
} = defineProps<{
entries?: MenuItem[]
entries?: PopoverMenuItem[]
icon?: string
to?: string | HTMLElement
showArrow?: boolean

View File

@@ -3357,6 +3357,16 @@
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"
},
"groups": {
"untitled": "Untitled group",
"createGroup": "Create group",
"ungroup": "Ungroup",
"confirmUngroup": "Ungroup inputs?",
"ungroupDescription": "The inputs in this group will be moved back to the main list.",
"emptyGroup": "Add inputs to this group",
"addToGroup": "Add to group",
"newGroup": "New group"
}
},
"missingNodes": {

View File

@@ -9,9 +9,23 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
/** An item within an input group. */
export interface InputGroupItem {
key: string
pairId?: string
}
/** A named group of inputs that renders as a collapsible accordion. */
export interface InputGroup {
id: string
name: string | null
items: InputGroupItem[]
}
export interface LinearData {
inputs: [NodeId, string][]
outputs: NodeId[]
inputGroups?: InputGroup[]
}
export interface PendingWarnings {

View File

@@ -5,6 +5,7 @@ import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -19,6 +20,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useInputGroupStore } from '@/stores/inputGroupStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -27,6 +29,7 @@ const { isActiveSubscription } = useBillingContext()
const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const inputGroupStore = useInputGroupStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { toastTo, mobile } = defineProps<{
@@ -101,6 +104,20 @@ function handleDragDrop(e: DragEvent) {
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
>
<AppModeWidgetList ref="widgetListRef" :mobile />
<InputGroupAccordion
v-for="(group, idx) in inputGroupStore.inputGroups"
:key="group.id"
:group
:position="
inputGroupStore.inputGroups.length === 1
? 'only'
: idx === 0
? 'first'
: idx === inputGroupStore.inputGroups.length - 1
? 'last'
: 'middle'
"
/>
</section>
<Teleport
v-if="!jobToastTimeout || pendingJobQueues > 0"

View File

@@ -86,6 +86,7 @@ export const useAppModeStore = defineStore('appMode', () => {
if (!graph) return
const extra = (graph.extra ??= {})
extra.linearData = {
...(extra.linearData as Partial<LinearData>),
inputs: [...data.inputs],
outputs: [...data.outputs]
}

View File

@@ -0,0 +1,360 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
LinearData,
LoadedComfyWorkflow
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { extra: {}, nodes: [{ id: 1 }], events: new EventTarget() }
}
}))
vi.mock('@/utils/litegraphUtil', async (importOriginal) => ({
...(await importOriginal()),
resolveNode: vi.fn<(id: NodeId) => LGraphNode | undefined>(() => undefined)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => ({ read_only: false })
})
}))
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => ({ show: vi.fn() })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(() => false),
set: vi.fn()
})
}))
import { useInputGroupStore } from './inputGroupStore'
function createWorkflow(
activeMode: string = 'builder:inputs'
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflowClass({
path: 'workflows/test.json',
modified: Date.now(),
size: 100
})
workflow.changeTracker = createMockChangeTracker()
workflow.content = '{}'
workflow.originalContent = '{}'
workflow.activeMode = activeMode as LoadedComfyWorkflow['activeMode']
return workflow as LoadedComfyWorkflow
}
describe('inputGroupStore', () => {
let store: ReturnType<typeof useInputGroupStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.mocked(app.rootGraph).extra = {}
// Set active workflow in builder mode so persistence works
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createWorkflow()
store = useInputGroupStore()
vi.clearAllMocks()
})
describe('createGroup', () => {
it('creates an empty group with generated id', () => {
const id = store.createGroup()
expect(store.inputGroups).toHaveLength(1)
expect(store.inputGroups[0]).toMatchObject({
id,
name: null,
items: []
})
})
it('creates a group with a name', () => {
store.createGroup('Dimensions')
expect(store.inputGroups[0].name).toBe('Dimensions')
})
})
describe('deleteGroup', () => {
it('removes a group by id', () => {
const id = store.createGroup()
store.deleteGroup(id)
expect(store.inputGroups).toHaveLength(0)
})
it('does nothing for unknown id', () => {
store.createGroup()
store.deleteGroup('nonexistent')
expect(store.inputGroups).toHaveLength(1)
})
})
describe('addItemToGroup', () => {
it('adds an item to a group', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:steps')
expect(store.inputGroups[0].items).toHaveLength(1)
expect(store.inputGroups[0].items[0].key).toBe('input:1:steps')
})
it('does not add duplicate items', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:steps')
store.addItemToGroup(id, 'input:1:steps')
expect(store.inputGroups[0].items).toHaveLength(1)
})
it('moves item from one group to another', () => {
const g1 = store.createGroup()
const g2 = store.createGroup()
store.addItemToGroup(g1, 'input:1:steps')
store.addItemToGroup(g2, 'input:1:steps')
// g1 was auto-deleted (became empty)
expect(store.inputGroups).toHaveLength(1)
expect(store.inputGroups[0].id).toBe(g2)
expect(store.inputGroups[0].items[0].key).toBe('input:1:steps')
})
})
describe('removeItemFromGroup', () => {
it('removes an item from a group', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:steps')
store.addItemToGroup(id, 'input:1:cfg')
store.removeItemFromGroup(id, 'input:1:steps')
expect(store.inputGroups[0].items).toHaveLength(1)
expect(store.inputGroups[0].items[0].key).toBe('input:1:cfg')
})
it('deletes group when last item is removed', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:steps')
store.removeItemFromGroup(id, 'input:1:steps')
expect(store.inputGroups).toHaveLength(0)
})
it('clears pair when paired item is removed', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:width')
store.addItemToGroup(id, 'input:1:height')
store.addItemToGroup(id, 'input:1:steps')
store.pairItemsInGroup(id, 'input:1:width', 'input:1:height')
store.removeItemFromGroup(id, 'input:1:width')
const remaining = store.inputGroups[0].items
expect(remaining.every((i) => i.pairId === undefined)).toBe(true)
})
})
describe('renameGroup', () => {
it('renames a group', () => {
const id = store.createGroup()
store.renameGroup(id, 'Dimensions')
expect(store.inputGroups[0].name).toBe('Dimensions')
})
it('sets name to null to use auto-name', () => {
const id = store.createGroup('Old Name')
store.renameGroup(id, null)
expect(store.inputGroups[0].name).toBeNull()
})
})
describe('reorderWithinGroup', () => {
it('moves an item before another', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:a')
store.addItemToGroup(id, 'input:1:b')
store.addItemToGroup(id, 'input:1:c')
store.reorderWithinGroup(id, 'input:1:c', 'input:1:a', 'before')
const keys = store.inputGroups[0].items.map((i) => i.key)
expect(keys).toEqual(['input:1:c', 'input:1:a', 'input:1:b'])
})
it('moves an item after another', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:a')
store.addItemToGroup(id, 'input:1:b')
store.addItemToGroup(id, 'input:1:c')
store.reorderWithinGroup(id, 'input:1:a', 'input:1:c', 'after')
const keys = store.inputGroups[0].items.map((i) => i.key)
expect(keys).toEqual(['input:1:b', 'input:1:c', 'input:1:a'])
})
})
describe('pairItemsInGroup / unpairItem', () => {
it('pairs two items with a shared pairId', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:width')
store.addItemToGroup(id, 'input:1:height')
store.pairItemsInGroup(id, 'input:1:width', 'input:1:height')
const items = store.inputGroups[0].items
expect(items[0].pairId).toBeDefined()
expect(items[0].pairId).toBe(items[1].pairId)
})
it('unpairs an item and clears partner pairId', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:width')
store.addItemToGroup(id, 'input:1:height')
store.pairItemsInGroup(id, 'input:1:width', 'input:1:height')
store.unpairItem(id, 'input:1:width')
const items = store.inputGroups[0].items
expect(items.every((i) => i.pairId === undefined)).toBe(true)
})
})
describe('replaceInPair', () => {
it('swaps a dragged item into an existing pair slot', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:a')
store.addItemToGroup(id, 'input:1:b')
store.addItemToGroup(id, 'input:1:c')
store.pairItemsInGroup(id, 'input:1:a', 'input:1:b')
store.replaceInPair(id, 'input:1:b', 'input:1:c')
const items = store.inputGroups[0].items
const aItem = items.find((i) => i.key === 'input:1:a')!
const cItem = items.find((i) => i.key === 'input:1:c')!
const bItem = items.find((i) => i.key === 'input:1:b')!
// c took b's pair slot
expect(cItem.pairId).toBe(aItem.pairId)
// b is now unpaired
expect(bItem.pairId).toBeUndefined()
})
it('does nothing when target has no pairId', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:a')
store.addItemToGroup(id, 'input:1:b')
store.replaceInPair(id, 'input:1:a', 'input:1:b')
const items = store.inputGroups[0].items
expect(items.every((i) => i.pairId === undefined)).toBe(true)
})
})
describe('moveItemToGroupAt', () => {
it('moves item from one group to another before target', () => {
const g1 = store.createGroup()
const g2 = store.createGroup()
store.addItemToGroup(g1, 'input:1:x')
store.addItemToGroup(g2, 'input:1:a')
store.addItemToGroup(g2, 'input:1:b')
store.moveItemToGroupAt(g2, 'input:1:x', 'input:1:a', 'before')
expect(store.inputGroups).toHaveLength(1)
const keys = store.inputGroups[0].items.map((i) => i.key)
expect(keys).toEqual(['input:1:x', 'input:1:a', 'input:1:b'])
})
it('moves item after target', () => {
const g1 = store.createGroup()
const g2 = store.createGroup()
store.addItemToGroup(g1, 'input:1:x')
store.addItemToGroup(g2, 'input:1:a')
store.addItemToGroup(g2, 'input:1:b')
store.moveItemToGroupAt(g2, 'input:1:x', 'input:1:a', 'after')
const keys = store.inputGroups[0].items.map((i) => i.key)
expect(keys).toEqual(['input:1:a', 'input:1:x', 'input:1:b'])
})
it('pairs items when position is center', () => {
const g1 = store.createGroup()
const g2 = store.createGroup()
store.addItemToGroup(g1, 'input:1:x')
store.addItemToGroup(g2, 'input:1:a')
store.moveItemToGroupAt(g2, 'input:1:x', 'input:1:a', 'center')
const items = store.inputGroups[0].items
expect(items[0].pairId).toBeDefined()
expect(items[0].pairId).toBe(items[1].pairId)
})
it('deletes empty source group', () => {
const g1 = store.createGroup()
const g2 = store.createGroup()
store.addItemToGroup(g1, 'input:1:x')
store.addItemToGroup(g2, 'input:1:a')
store.moveItemToGroupAt(g2, 'input:1:x', 'input:1:a', 'before')
expect(store.inputGroups).toHaveLength(1)
expect(store.inputGroups[0].id).toBe(g2)
})
})
describe('groupedItemKeys / isGrouped', () => {
it('tracks which items are in groups', () => {
const id = store.createGroup()
store.addItemToGroup(id, 'input:1:steps')
expect(store.groupedItemKeys.has('input:1:steps')).toBe(true)
expect(store.isGrouped(1, 'steps')).toBe(true)
expect(store.isGrouped(1, 'cfg')).toBe(false)
})
})
describe('persistence', () => {
it('persists groups to linearData on graph extra', () => {
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createWorkflow()
const id = store.createGroup('Test')
store.addItemToGroup(id, 'input:1:steps')
const linearData = app.rootGraph.extra.linearData as LinearData
expect(linearData?.inputGroups).toBeDefined()
expect(linearData.inputGroups).toHaveLength(1)
expect(linearData.inputGroups![0].name).toBe('Test')
})
it('clears inputGroups from linearData when empty', () => {
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createWorkflow()
const id = store.createGroup()
store.deleteGroup(id)
const linearData = app.rootGraph.extra.linearData as LinearData
expect(linearData?.inputGroups).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,277 @@
import { useEventListener } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { inputItemKey } from '@/components/builder/itemKeyHelper'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
InputGroup,
LinearData
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
export const useInputGroupStore = defineStore('inputGroup', () => {
const workflowStore = useWorkflowStore()
const { isBuilderMode, isAppMode } = useAppMode()
const inputGroups = ref<InputGroup[]>([])
const groupedItemKeys = computed(() => {
const keys = new Set<string>()
for (const group of inputGroups.value) {
for (const item of group.items) keys.add(item.key)
}
return keys
})
function isGrouped(nodeId: NodeId, widgetName: string): boolean {
return groupedItemKeys.value.has(inputItemKey(nodeId, widgetName))
}
// ── Persistence ────────────────────────────────────────────────────
function loadGroups(groups: InputGroup[] | undefined) {
inputGroups.value = groups
? groups.map((g) => ({ ...g, items: [...g.items] }))
: []
}
function persistGroups() {
if (
(!isBuilderMode.value && !isAppMode.value) ||
ChangeTracker.isLoadingGraph
)
return
const graph = app.rootGraph
if (!graph) return
const extra = (graph.extra ??= {})
const linearData = ((extra.linearData as LinearData | undefined) ??= {
inputs: [],
outputs: []
})
linearData.inputGroups = inputGroups.value.length
? JSON.parse(JSON.stringify(inputGroups.value))
: undefined
workflowStore.activeWorkflow?.changeTracker?.checkState()
}
function reloadFromGraph() {
const linearData = app.rootGraph?.extra?.linearData as
| LinearData
| undefined
loadGroups(linearData?.inputGroups)
}
watch(
() => workflowStore.activeWorkflow,
(workflow) => {
const linearData = workflow?.changeTracker?.activeState?.extra
?.linearData as LinearData | undefined
loadGroups(linearData?.inputGroups)
},
{ immediate: true }
)
useEventListener(() => app.rootGraph?.events, 'configured', reloadFromGraph)
// ── Helpers ────────────────────────────────────────────────────────
function findGroup(groupId: string): InputGroup | undefined {
return inputGroups.value.find((g) => g.id === groupId)
}
// ── Actions ────────────────────────────────────────────────────────
function createGroup(name?: string): string {
const id = crypto.randomUUID()
inputGroups.value.push({ id, name: name ?? null, items: [] })
persistGroups()
return id
}
function deleteGroupInternal(groupId: string) {
const idx = inputGroups.value.findIndex((g) => g.id === groupId)
if (idx !== -1) inputGroups.value.splice(idx, 1)
}
function deleteGroup(groupId: string) {
deleteGroupInternal(groupId)
persistGroups()
}
function renameGroup(groupId: string, name: string | null) {
const group = findGroup(groupId)
if (!group) return
group.name = name
persistGroups()
}
function addItemToGroup(groupId: string, itemKey: string) {
const group = findGroup(groupId)
if (!group) return
if (group.items.some((i) => i.key === itemKey)) return
// Remove from any other group first
removeItemFromAllGroups(itemKey)
group.items.push({ key: itemKey })
persistGroups()
}
function removeItemFromGroup(groupId: string, itemKey: string) {
const group = findGroup(groupId)
if (!group) return
const idx = group.items.findIndex((i) => i.key === itemKey)
if (idx === -1) return
const pairId = group.items[idx].pairId
if (pairId) clearPair(group, pairId)
group.items.splice(idx, 1)
if (group.items.length === 0) deleteGroupInternal(groupId)
persistGroups()
}
function removeItemFromAllGroups(itemKey: string) {
const emptied: string[] = []
for (const group of inputGroups.value) {
const idx = group.items.findIndex((i) => i.key === itemKey)
if (idx !== -1) {
const pairId = group.items[idx].pairId
if (pairId) clearPair(group, pairId)
group.items.splice(idx, 1)
if (group.items.length === 0) emptied.push(group.id)
}
}
for (const id of emptied) deleteGroupInternal(id)
}
function reorderWithinGroup(
groupId: string,
fromKey: string,
toKey: string,
position: 'before' | 'after'
) {
const group = findGroup(groupId)
if (!group) return
const fromIdx = group.items.findIndex((i) => i.key === fromKey)
const toIdx = group.items.findIndex((i) => i.key === toKey)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
const [moved] = group.items.splice(fromIdx, 1)
const insertIdx =
position === 'before'
? group.items.findIndex((i) => i.key === toKey)
: group.items.findIndex((i) => i.key === toKey) + 1
group.items.splice(insertIdx, 0, moved)
persistGroups()
}
function pairItemsInGroup(groupId: string, keyA: string, keyB: string) {
const group = findGroup(groupId)
if (!group) return
const itemA = group.items.find((i) => i.key === keyA)
const itemB = group.items.find((i) => i.key === keyB)
if (itemA?.pairId) clearPair(group, itemA.pairId)
if (itemB?.pairId) clearPair(group, itemB.pairId)
const pairId = crypto.randomUUID()
for (const item of group.items) {
if (item.key === keyA || item.key === keyB) item.pairId = pairId
}
persistGroups()
}
/**
* Swap a dragged item into an existing pair slot, evicting the target.
* 1. Detach replacement from its current position (and dissolve its old pair)
* 2. Insert replacement next to the target, inheriting the target's pairId
* 3. Unpair the evicted target
*/
function replaceInPair(
groupId: string,
targetKey: string,
replacementKey: string
) {
const group = findGroup(groupId)
if (!group) return
const targetItem = group.items.find((i) => i.key === targetKey)
if (!targetItem?.pairId) return
const pairId = targetItem.pairId
const repIdx = group.items.findIndex((i) => i.key === replacementKey)
if (repIdx === -1) return
const [moved] = group.items.splice(repIdx, 1)
if (moved.pairId) {
for (const i of group.items) {
if (i.pairId === moved.pairId) i.pairId = undefined
}
}
const targetIdx = group.items.findIndex((i) => i.key === targetKey)
moved.pairId = pairId
group.items.splice(targetIdx, 0, moved)
targetItem.pairId = undefined
persistGroups()
}
/** Move an item from any group into a target group near a specific item. */
function moveItemToGroupAt(
groupId: string,
itemKey: string,
targetKey: string,
position: 'before' | 'center' | 'after'
) {
const group = findGroup(groupId)
if (!group) return
removeItemFromAllGroups(itemKey)
if (!group.items.some((i) => i.key === itemKey)) {
group.items.push({ key: itemKey })
}
const dragIdx = group.items.findIndex((i) => i.key === itemKey)
const targetIdx = group.items.findIndex((i) => i.key === targetKey)
if (dragIdx !== -1 && targetIdx !== -1 && dragIdx !== targetIdx) {
const [moved] = group.items.splice(dragIdx, 1)
const newTargetIdx = group.items.findIndex((i) => i.key === targetKey)
const insertIdx = position === 'after' ? newTargetIdx + 1 : newTargetIdx
group.items.splice(insertIdx, 0, moved)
}
if (position === 'center') {
const itemA = group.items.find((i) => i.key === itemKey)
const itemB = group.items.find((i) => i.key === targetKey)
if (itemA?.pairId) clearPair(group, itemA.pairId)
if (itemB?.pairId) clearPair(group, itemB.pairId)
const pairId = crypto.randomUUID()
if (itemA) itemA.pairId = pairId
if (itemB) itemB.pairId = pairId
}
persistGroups()
}
function unpairItem(groupId: string, itemKey: string) {
const group = findGroup(groupId)
if (!group) return
const item = group.items.find((i) => i.key === itemKey)
if (!item?.pairId) return
clearPair(group, item.pairId)
persistGroups()
}
function clearPair(group: InputGroup, pairId: string) {
for (const item of group.items) {
if (item.pairId === pairId) item.pairId = undefined
}
}
return {
inputGroups,
groupedItemKeys,
isGrouped,
findGroup,
createGroup,
deleteGroup,
renameGroup,
addItemToGroup,
removeItemFromGroup,
reorderWithinGroup,
moveItemToGroupAt,
pairItemsInGroup,
replaceInPair,
unpairItem
}
})