mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-07 04:50:08 +00:00
Compare commits
2 Commits
refactor/n
...
feat/input
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c246e23a2f | ||
|
|
64b895028a |
@@ -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')"
|
||||
|
||||
@@ -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
|
||||
|
||||
313
src/components/builder/InputGroupAccordion.vue
Normal file
313
src/components/builder/InputGroupAccordion.vue
Normal 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>
|
||||
63
src/components/builder/UngroupConfirmDialog.vue
Normal file
63
src/components/builder/UngroupConfirmDialog.vue
Normal 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>
|
||||
55
src/components/builder/itemKeyHelper.test.ts
Normal file
55
src/components/builder/itemKeyHelper.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
27
src/components/builder/itemKeyHelper.ts
Normal file
27
src/components/builder/itemKeyHelper.ts
Normal 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)
|
||||
}
|
||||
63
src/components/builder/useGroupDrop.test.ts
Normal file
63
src/components/builder/useGroupDrop.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
267
src/components/builder/useGroupDrop.ts
Normal file
267
src/components/builder/useGroupDrop.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
201
src/components/builder/useInputGroups.test.ts
Normal file
201
src/components/builder/useInputGroups.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
86
src/components/builder/useInputGroups.ts
Normal file
86
src/components/builder/useInputGroups.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
360
src/stores/inputGroupStore.test.ts
Normal file
360
src/stores/inputGroupStore.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
277
src/stores/inputGroupStore.ts
Normal file
277
src/stores/inputGroupStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user