mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-06 07:51:57 +00:00
Compare commits
2 Commits
dev/remote
...
feat/input
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c246e23a2f | ||
|
|
64b895028a |
@@ -5,6 +5,7 @@ import type { MaybeRef } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||||
|
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
|
||||||
import DraggableList from '@/components/common/DraggableList.vue'
|
import DraggableList from '@/components/common/DraggableList.vue'
|
||||||
import IoItem from '@/components/builder/IoItem.vue'
|
import IoItem from '@/components/builder/IoItem.vue'
|
||||||
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
|
||||||
@@ -28,12 +29,14 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
|
|||||||
import { renameWidget } from '@/utils/widgetUtil'
|
import { renameWidget } from '@/utils/widgetUtil'
|
||||||
import { useAppMode } from '@/composables/useAppMode'
|
import { useAppMode } from '@/composables/useAppMode'
|
||||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||||
|
import { useInputGroupStore } from '@/stores/inputGroupStore'
|
||||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
type BoundStyle = { top: string; left: string; width: string; height: string }
|
type BoundStyle = { top: string; left: string; width: string; height: string }
|
||||||
|
|
||||||
const appModeStore = useAppModeStore()
|
const appModeStore = useAppModeStore()
|
||||||
|
const inputGroupStore = useInputGroupStore()
|
||||||
const canvasInteractions = useCanvasInteractions()
|
const canvasInteractions = useCanvasInteractions()
|
||||||
const canvasStore = useCanvasStore()
|
const canvasStore = useCanvasStore()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
@@ -207,13 +210,43 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
<DraggableList
|
<template v-if="isArrangeMode">
|
||||||
v-if="isArrangeMode"
|
<DraggableList
|
||||||
v-model="appModeStore.selectedInputs"
|
:key="inputGroupStore.groupedItemKeys.size"
|
||||||
class="overflow-x-clip"
|
v-model="appModeStore.selectedInputs"
|
||||||
>
|
class="overflow-x-clip"
|
||||||
<AppModeWidgetList builder-mode />
|
>
|
||||||
</DraggableList>
|
<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
|
<PropertiesAccordionItem
|
||||||
v-if="isSelectInputsMode"
|
v-if="isSelectInputsMode"
|
||||||
:label="t('nodeHelpPage.inputs')"
|
:label="t('nodeHelpPage.inputs')"
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useEventListener } from '@vueuse/core'
|
|||||||
import { computed, provide, shallowRef } from 'vue'
|
import { computed, provide, shallowRef } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 Popover from '@/components/ui/Popover.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||||
@@ -20,6 +23,7 @@ import { api } from '@/scripts/api'
|
|||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||||
import { useAppModeStore } from '@/stores/appModeStore'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
|
import { useInputGroupStore } from '@/stores/inputGroupStore'
|
||||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
@@ -42,6 +46,7 @@ const { mobile = false, builderMode = false } = defineProps<{
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const executionErrorStore = useExecutionErrorStore()
|
const executionErrorStore = useExecutionErrorStore()
|
||||||
const appModeStore = useAppModeStore()
|
const appModeStore = useAppModeStore()
|
||||||
|
const inputGroupStore = useInputGroupStore()
|
||||||
const maskEditor = useMaskEditor()
|
const maskEditor = useMaskEditor()
|
||||||
|
|
||||||
provide(HideLayoutFieldKey, true)
|
provide(HideLayoutFieldKey, true)
|
||||||
@@ -62,6 +67,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
|||||||
>()
|
>()
|
||||||
|
|
||||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
||||||
|
if (inputGroupStore.isGrouped(nodeId, widgetName)) return []
|
||||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
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 })
|
defineExpose({ handleDragDrop })
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
@@ -165,6 +218,9 @@ defineExpose({ handleDragDrop })
|
|||||||
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
|
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
|
:data-item-key="
|
||||||
|
builderMode ? inputItemKey(action.node.id, action.widget.name) : undefined
|
||||||
|
"
|
||||||
:aria-label="
|
:aria-label="
|
||||||
builderMode
|
builderMode
|
||||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||||
@@ -193,19 +249,7 @@ defineExpose({ handleDragDrop })
|
|||||||
<div v-else class="flex-1" />
|
<div v-else class="flex-1" />
|
||||||
<Popover
|
<Popover
|
||||||
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
|
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
|
||||||
:entries="[
|
:entries="buildMenuEntries(action)"
|
||||||
{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<template #button>
|
<template #button>
|
||||||
<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">
|
<script setup lang="ts">
|
||||||
import type { MenuItem } from 'primevue/menuitem'
|
|
||||||
import {
|
import {
|
||||||
PopoverArrow,
|
PopoverArrow,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -11,6 +10,14 @@ import {
|
|||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import { cn } from '@/utils/tailwindUtil'
|
import { cn } from '@/utils/tailwindUtil'
|
||||||
|
|
||||||
|
export interface PopoverMenuItem {
|
||||||
|
label?: string
|
||||||
|
icon?: string
|
||||||
|
separator?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
command?: (...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false
|
inheritAttrs: false
|
||||||
})
|
})
|
||||||
@@ -21,7 +28,7 @@ const {
|
|||||||
to,
|
to,
|
||||||
showArrow = true
|
showArrow = true
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
entries?: MenuItem[]
|
entries?: PopoverMenuItem[]
|
||||||
icon?: string
|
icon?: string
|
||||||
to?: string | HTMLElement
|
to?: string | HTMLElement
|
||||||
showArrow?: boolean
|
showArrow?: boolean
|
||||||
|
|||||||
@@ -3357,6 +3357,16 @@
|
|||||||
"queue": {
|
"queue": {
|
||||||
"clickToClear": "Click to clear queue",
|
"clickToClear": "Click to clear queue",
|
||||||
"clear": "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": {
|
"missingNodes": {
|
||||||
|
|||||||
@@ -9,9 +9,23 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
|||||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||||
import type { MissingNodeType } from '@/types/comfy'
|
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 {
|
export interface LinearData {
|
||||||
inputs: [NodeId, string][]
|
inputs: [NodeId, string][]
|
||||||
outputs: NodeId[]
|
outputs: NodeId[]
|
||||||
|
inputGroups?: InputGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingWarnings {
|
export interface PendingWarnings {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ref, useTemplateRef } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||||
|
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
|
||||||
import Loader from '@/components/loader/Loader.vue'
|
import Loader from '@/components/loader/Loader.vue'
|
||||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||||
import Popover from '@/components/ui/Popover.vue'
|
import Popover from '@/components/ui/Popover.vue'
|
||||||
@@ -19,6 +20,7 @@ import { useCommandStore } from '@/stores/commandStore'
|
|||||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||||
import { useAppMode } from '@/composables/useAppMode'
|
import { useAppMode } from '@/composables/useAppMode'
|
||||||
import { useAppModeStore } from '@/stores/appModeStore'
|
import { useAppModeStore } from '@/stores/appModeStore'
|
||||||
|
import { useInputGroupStore } from '@/stores/inputGroupStore'
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||||
@@ -27,6 +29,7 @@ const { isActiveSubscription } = useBillingContext()
|
|||||||
const workflowStore = useWorkflowStore()
|
const workflowStore = useWorkflowStore()
|
||||||
const { isBuilderMode } = useAppMode()
|
const { isBuilderMode } = useAppMode()
|
||||||
const appModeStore = useAppModeStore()
|
const appModeStore = useAppModeStore()
|
||||||
|
const inputGroupStore = useInputGroupStore()
|
||||||
const { hasOutputs } = storeToRefs(appModeStore)
|
const { hasOutputs } = storeToRefs(appModeStore)
|
||||||
|
|
||||||
const { toastTo, mobile } = defineProps<{
|
const { toastTo, mobile } = defineProps<{
|
||||||
@@ -101,6 +104,20 @@ function handleDragDrop(e: DragEvent) {
|
|||||||
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
|
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
|
||||||
>
|
>
|
||||||
<AppModeWidgetList ref="widgetListRef" :mobile />
|
<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>
|
</section>
|
||||||
<Teleport
|
<Teleport
|
||||||
v-if="!jobToastTimeout || pendingJobQueues > 0"
|
v-if="!jobToastTimeout || pendingJobQueues > 0"
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
|||||||
if (!graph) return
|
if (!graph) return
|
||||||
const extra = (graph.extra ??= {})
|
const extra = (graph.extra ??= {})
|
||||||
extra.linearData = {
|
extra.linearData = {
|
||||||
|
...(extra.linearData as Partial<LinearData>),
|
||||||
inputs: [...data.inputs],
|
inputs: [...data.inputs],
|
||||||
outputs: [...data.outputs]
|
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