mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
feat: builder layout system — templates, zones, drag-and-drop
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -264,41 +264,6 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<!-- Presets toggle — listed alongside inputs -->
|
||||
<div
|
||||
v-if="isSelectInputsMode"
|
||||
:class="
|
||||
cn(
|
||||
'my-2 flex items-center gap-2 rounded-lg p-2',
|
||||
appModeStore.presetsEnabled
|
||||
? 'bg-primary-background/30'
|
||||
: 'bg-primary-background/10 opacity-50'
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--layers] size-4 shrink-0" />
|
||||
<span class="flex-1 truncate text-sm">
|
||||
{{ t('linearMode.presets.label') }}
|
||||
</span>
|
||||
<button
|
||||
class="flex size-6 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
|
||||
@click="
|
||||
() => {
|
||||
appModeStore.presetsEnabled = !appModeStore.presetsEnabled
|
||||
appModeStore.persistLinearData()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
appModeStore.presetsEnabled
|
||||
? 'icon-[lucide--eye]'
|
||||
: 'icon-[lucide--eye-off]'
|
||||
"
|
||||
class="size-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
|
||||
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"
|
||||
|
||||
@@ -3,8 +3,6 @@ import { useEventListener } from '@vueuse/core'
|
||||
import { computed, provide, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
@@ -13,6 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
|
||||
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
@@ -21,7 +20,6 @@ import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
interface WidgetEntry {
|
||||
key: string
|
||||
@@ -31,9 +29,18 @@ interface WidgetEntry {
|
||||
action: { widget: IBaseWidget; node: LGraphNode }
|
||||
}
|
||||
|
||||
const { mobile = false, builderMode = false } = defineProps<{
|
||||
const {
|
||||
mobile = false,
|
||||
builderMode = false,
|
||||
zoneId,
|
||||
itemKeys
|
||||
} = defineProps<{
|
||||
mobile?: boolean
|
||||
builderMode?: boolean
|
||||
/** When set, only show inputs assigned to this zone. */
|
||||
zoneId?: string
|
||||
/** When set, only render these specific input keys in the given order. */
|
||||
itemKeys?: string[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -44,13 +51,61 @@ const maskEditor = useMaskEditor()
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph?.nodes ?? [])
|
||||
useEventListener(
|
||||
app.rootGraph.events,
|
||||
() => app.rootGraph?.events,
|
||||
'configured',
|
||||
() => (graphNodes.value = app.rootGraph.nodes)
|
||||
() => (graphNodes.value = app.rootGraph?.nodes ?? [])
|
||||
)
|
||||
|
||||
const groupedItemKeys = computed(() => {
|
||||
const keys = new Set<string>()
|
||||
for (const group of appModeStore.inputGroups) {
|
||||
for (const item of group.items) keys.add(item.key)
|
||||
}
|
||||
return keys
|
||||
})
|
||||
|
||||
function resolveInputEntry(
|
||||
nodeId: string | number,
|
||||
widgetName: string,
|
||||
nodeDataByNode: Map<LGraphNode, ReturnType<typeof nodeToNodeData>>
|
||||
): WidgetEntry | null {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return null
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
}
|
||||
const fullNodeData = nodeDataByNode.get(node)!
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
})
|
||||
if (!matchingWidget) return null
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
|
||||
return {
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
widgets: [matchingWidget]
|
||||
},
|
||||
action: { widget, node }
|
||||
}
|
||||
}
|
||||
|
||||
const mappedSelections = computed((): WidgetEntry[] => {
|
||||
void graphNodes.value
|
||||
const nodeDataByNode = new Map<
|
||||
@@ -58,43 +113,34 @@ const mappedSelections = computed((): WidgetEntry[] => {
|
||||
ReturnType<typeof nodeToNodeData>
|
||||
>()
|
||||
|
||||
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
|
||||
|
||||
if (!nodeDataByNode.has(node)) {
|
||||
nodeDataByNode.set(node, nodeToNodeData(node))
|
||||
if (itemKeys) {
|
||||
const results: WidgetEntry[] = []
|
||||
for (const key of itemKeys) {
|
||||
if (!key.startsWith('input:')) continue
|
||||
const parts = key.split(':')
|
||||
const nodeId = parts[1]
|
||||
const widgetName = parts.slice(2).join(':')
|
||||
const entry = resolveInputEntry(nodeId, widgetName, nodeDataByNode)
|
||||
if (entry) results.push(entry)
|
||||
}
|
||||
const fullNodeData = nodeDataByNode.get(node)!
|
||||
return results
|
||||
}
|
||||
|
||||
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
|
||||
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return (
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId == storeNodeId &&
|
||||
widget.sourceWidgetName === vueWidget.storeName
|
||||
const inputs = zoneId
|
||||
? appModeStore.selectedInputs.filter(
|
||||
([nId, wName]) => appModeStore.getZone(nId, wName) === zoneId
|
||||
)
|
||||
: appModeStore.selectedInputs
|
||||
|
||||
return inputs
|
||||
.filter(
|
||||
([nodeId, widgetName]) =>
|
||||
!groupedItemKeys.value.has(`input:${nodeId}:${widgetName}`)
|
||||
)
|
||||
.flatMap(([nodeId, widgetName]) => {
|
||||
const entry = resolveInputEntry(nodeId, widgetName, nodeDataByNode)
|
||||
return entry ? [entry] : []
|
||||
})
|
||||
if (!matchingWidget) return []
|
||||
|
||||
matchingWidget.slotMetadata = undefined
|
||||
matchingWidget.nodeId = String(node.id)
|
||||
|
||||
return [
|
||||
{
|
||||
key: `${nodeId}:${widgetName}`,
|
||||
nodeData: {
|
||||
...fullNodeData,
|
||||
widgets: [matchingWidget]
|
||||
},
|
||||
action: { widget, node }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
function getDropIndicator(node: LGraphNode) {
|
||||
@@ -105,6 +151,13 @@ function getDropIndicator(node: LGraphNode) {
|
||||
})
|
||||
}
|
||||
|
||||
function inputColorBg(key: string): string | undefined {
|
||||
const [nodeId, widgetName] = key.split(':')
|
||||
const colorName = appModeStore.getInputColor(Number(nodeId), widgetName)
|
||||
if (!colorName) return undefined
|
||||
return LGraphCanvas.node_colors[colorName]?.bgcolor
|
||||
}
|
||||
|
||||
function nodeToNodeData(node: LGraphNode) {
|
||||
const dropIndicator = getDropIndicator(node)
|
||||
const nodeData = extractVueNodeData(node)
|
||||
@@ -125,9 +178,15 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
:class="
|
||||
cn(
|
||||
builderMode &&
|
||||
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
|
||||
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing',
|
||||
!builderMode && inputColorBg(key) && 'rounded-sm border-l-2'
|
||||
)
|
||||
"
|
||||
:style="
|
||||
!builderMode && inputColorBg(key)
|
||||
? { borderLeftColor: inputColorBg(key) }
|
||||
: undefined
|
||||
"
|
||||
:aria-label="
|
||||
builderMode
|
||||
? `${action.widget.label ?? action.widget.name} — ${action.node.title}`
|
||||
@@ -137,12 +196,13 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
|
||||
'flex min-h-8 items-center gap-1 px-3 pt-1.5',
|
||||
builderMode && 'drag-handle'
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
v-tooltip.top="action.widget.label || action.widget.name"
|
||||
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
|
||||
>
|
||||
{{ action.widget.label || action.widget.name }}
|
||||
@@ -154,36 +214,6 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
{{ action.node.title }}
|
||||
</span>
|
||||
<div v-else class="flex-1" />
|
||||
<Popover
|
||||
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
|
||||
:entries="[
|
||||
{
|
||||
label: t('g.rename'),
|
||||
icon: 'icon-[lucide--pencil]',
|
||||
command: () => promptRenameWidget(action.widget, action.node, t)
|
||||
},
|
||||
{
|
||||
label: t('g.remove'),
|
||||
icon: 'icon-[lucide--x]',
|
||||
command: () => {
|
||||
const idx = appModeStore.selectedInputs.findIndex(
|
||||
([nId, wName]) => `${nId}:${wName}` === key
|
||||
)
|
||||
if (idx !== -1) appModeStore.selectedInputs.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
]"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon"
|
||||
data-testid="widget-actions-menu"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis]" />
|
||||
</Button>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div
|
||||
:class="builderMode && 'pointer-events-none'"
|
||||
@@ -206,5 +236,14 @@ function nodeToNodeData(node: LGraphNode) {
|
||||
/>
|
||||
</DropZone>
|
||||
</div>
|
||||
<div
|
||||
v-if="!builderMode"
|
||||
:class="
|
||||
cn(
|
||||
'mx-3 border-b border-border-subtle/30',
|
||||
key === mappedSelections.at(-1)?.key && 'hidden'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
75
src/components/builder/BuilderConfirmDialog.vue
Normal file
75
src/components/builder/BuilderConfirmDialog.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle
|
||||
} from 'reka-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
confirmVariant = 'secondary'
|
||||
} = defineProps<{
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel: string
|
||||
confirmVariant?: 'secondary' | 'destructive'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm')
|
||||
open.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-1800 bg-black/50" />
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 z-1800 w-80 -translate-1/2 rounded-xl border border-border-subtle bg-base-background p-5 shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle class="text-sm font-medium">
|
||||
{{ title }}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
class="flex size-6 items-center justify-center rounded-sm border-0 bg-transparent text-muted-foreground outline-none hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 border-t border-border-subtle pt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ description }}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-end gap-3">
|
||||
<DialogClose as-child>
|
||||
<Button variant="muted-textonly" size="sm">
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button :variant="confirmVariant" size="lg" @click="handleConfirm">
|
||||
{{ confirmLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -1,10 +1,21 @@
|
||||
<template>
|
||||
<nav
|
||||
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
|
||||
ref="toolbarEl"
|
||||
:class="
|
||||
cn(
|
||||
'fixed z-1000 origin-top-left select-none',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
:style="{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
transform: `scale(${toolbarScale})`
|
||||
}"
|
||||
:aria-label="t('builderToolbar.label')"
|
||||
>
|
||||
<div
|
||||
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
class="group inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
|
||||
>
|
||||
<template v-for="(step, index) in steps" :key="step.id">
|
||||
<button
|
||||
@@ -60,12 +71,22 @@
|
||||
/>
|
||||
<StepLabel :step="defaultViewStep" />
|
||||
</button>
|
||||
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
class="ml-1 flex cursor-se-resize items-center opacity-0 transition-opacity group-hover:opacity-40"
|
||||
@pointerdown.stop="startResize"
|
||||
>
|
||||
<i class="icon-[lucide--grip] size-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDraggable } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
@@ -83,6 +104,45 @@ const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
|
||||
|
||||
// ── Draggable positioning ──────────────────────────────────────────
|
||||
const toolbarEl = ref<HTMLElement | null>(null)
|
||||
const toolbarScale = ref(1)
|
||||
|
||||
const { position, isDragging } = useDraggable(toolbarEl, {
|
||||
initialValue: { x: 0, y: 50 },
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (toolbarEl.value) {
|
||||
const rect = toolbarEl.value.getBoundingClientRect()
|
||||
position.value = {
|
||||
x: Math.round((window.innerWidth - rect.width) / 2),
|
||||
y: 50
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Corner resize (scale) ──────────────────────────────────────────
|
||||
function startResize(e: PointerEvent) {
|
||||
const startX = e.clientX
|
||||
const startScale = toolbarScale.value
|
||||
const el = e.currentTarget as HTMLElement
|
||||
el.setPointerCapture(e.pointerId)
|
||||
|
||||
function onMove(ev: PointerEvent) {
|
||||
const delta = ev.clientX - startX
|
||||
toolbarScale.value = Math.max(0.5, Math.min(1.2, startScale + delta / 400))
|
||||
}
|
||||
function onUp() {
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
}
|
||||
|
||||
// ── Step definitions ───────────────────────────────────────────────
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
|
||||
|
||||
366
src/components/builder/InputGroupAccordion.vue
Normal file
366
src/components/builder/InputGroupAccordion.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CollapsibleContent,
|
||||
CollapsibleRoot,
|
||||
CollapsibleTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle
|
||||
} from 'reka-ui'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
|
||||
|
||||
import {
|
||||
vGroupDropTarget,
|
||||
vGroupItemDraggable,
|
||||
vGroupItemReorderTarget
|
||||
} from '@/components/builder/useGroupDrop'
|
||||
import {
|
||||
autoGroupName,
|
||||
groupedByPair,
|
||||
resolveGroupItems
|
||||
} from '@/components/builder/useInputGroups'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
group,
|
||||
zoneId,
|
||||
builderMode = false,
|
||||
position = 'middle'
|
||||
} = defineProps<{
|
||||
group: InputGroup
|
||||
zoneId: string
|
||||
builderMode?: boolean
|
||||
position?: 'first' | 'middle' | 'last' | 'only'
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const isOpen = ref(builderMode)
|
||||
const isRenaming = ref(false)
|
||||
const showUngroupDialog = ref(false)
|
||||
const renameValue = ref('')
|
||||
let renameStartedAt = 0
|
||||
|
||||
const displayName = computed(() => group.name ?? autoGroupName(group))
|
||||
const resolvedItems = computed(() => resolveGroupItems(group))
|
||||
const rows = computed(() => groupedByPair(resolvedItems.value))
|
||||
|
||||
function startRename() {
|
||||
if (!builderMode) return
|
||||
renameValue.value = displayName.value
|
||||
renameStartedAt = Date.now()
|
||||
isRenaming.value = true
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (Date.now() - renameStartedAt < 150) return
|
||||
const trimmed = renameValue.value.trim()
|
||||
appModeStore.renameGroup(group.id, trimmed || null)
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
isRenaming.value = false
|
||||
}
|
||||
|
||||
function startRenameDeferred() {
|
||||
setTimeout(startRename, 50)
|
||||
}
|
||||
|
||||
function handleDissolve() {
|
||||
appModeStore.dissolveGroup(group.id, zoneId)
|
||||
}
|
||||
|
||||
function handleWidgetValueUpdate(widget: IBaseWidget, value: WidgetValue) {
|
||||
if (value === undefined) return
|
||||
widget.value = value
|
||||
widget.callback?.(value)
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CollapsibleRoot
|
||||
v-model:open="isOpen"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col',
|
||||
builderMode &&
|
||||
'rounded-lg border border-dashed border-primary-background/40',
|
||||
!builderMode && 'border-border-subtle/40',
|
||||
!builderMode &&
|
||||
position !== 'first' &&
|
||||
position !== 'only' &&
|
||||
'border-t',
|
||||
!builderMode &&
|
||||
(position === 'last' || position === 'only') &&
|
||||
'border-b'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Header row — draggable in builder mode -->
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-1',
|
||||
builderMode ? 'drag-handle cursor-grab py-1 pr-1.5 pl-1' : 'px-4 py-2'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Rename input (outside CollapsibleTrigger to avoid focus conflicts) -->
|
||||
<div v-if="isRenaming" class="flex flex-1 items-center gap-1.5 px-3 py-2">
|
||||
<input
|
||||
v-model="renameValue"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none"
|
||||
@click.stop
|
||||
@keydown.enter.stop="confirmRename"
|
||||
@keydown.escape.stop="cancelRename"
|
||||
@blur="confirmRename"
|
||||
@vue:mounted="
|
||||
($event: any) => {
|
||||
$event.el?.focus()
|
||||
$event.el?.select()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- Name + chevron -->
|
||||
<CollapsibleTrigger v-else as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 border border-transparent bg-transparent px-3 py-2 text-left outline-none"
|
||||
>
|
||||
<Tooltip :text="displayName" side="left" :side-offset="20">
|
||||
<span
|
||||
class="flex-1 truncate text-sm font-bold text-base-foreground"
|
||||
@dblclick.stop="startRename"
|
||||
>
|
||||
{{ displayName }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
'icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<!-- Builder actions on the right -->
|
||||
<Popover v-if="builderMode" class="-mr-2 shrink-0">
|
||||
<template #button>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis-vertical]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
startRenameDeferred()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
{{ t('g.rename') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
showUngroupDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--ungroup]" />
|
||||
{{ t('linearMode.layout.ungroup') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<!-- Ungroup confirmation dialog -->
|
||||
<DialogRoot v-model:open="showUngroupDialog">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="fixed inset-0 z-1800 bg-black/50" />
|
||||
<DialogContent
|
||||
class="fixed top-1/2 left-1/2 z-1800 w-80 -translate-1/2 rounded-xl border border-border-subtle bg-base-background p-5 shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle class="text-sm font-medium">
|
||||
{{ t('linearMode.groups.confirmUngroup') }}
|
||||
</DialogTitle>
|
||||
<DialogClose
|
||||
class="flex size-6 items-center justify-center rounded-sm border-0 bg-transparent text-muted-foreground outline-none hover:text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div
|
||||
class="mt-3 border-t border-border-subtle pt-3 text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('linearMode.groups.ungroupDescription') }}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-end gap-3">
|
||||
<DialogClose as-child>
|
||||
<Button variant="muted-textonly" size="sm">
|
||||
{{ t('g.cancel') }}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
@click="
|
||||
() => {
|
||||
handleDissolve()
|
||||
showUngroupDialog = false
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ t('linearMode.layout.ungroup') }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
<!-- Builder mode: drop zone -->
|
||||
<div
|
||||
v-if="builderMode"
|
||||
v-group-drop-target="{ groupId: group.id, zoneId }"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-10 flex-col gap-3 px-2 pb-2',
|
||||
'[&.group-drag-over]:bg-primary-background/5'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.type === 'single' ? row.item.key : row.items[0].key"
|
||||
>
|
||||
<div
|
||||
v-if="row.type === 'single'"
|
||||
v-group-item-draggable="{
|
||||
itemKey: row.item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
v-group-item-reorder-target="{
|
||||
itemKey: row.item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
class="cursor-grab overflow-hidden rounded-lg p-1.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="row.item.widget"
|
||||
:node="row.item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
hidden-favorite-indicator
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-stretch gap-2">
|
||||
<div
|
||||
v-for="item in row.items"
|
||||
:key="item.key"
|
||||
v-group-item-draggable="{
|
||||
itemKey: item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
v-group-item-reorder-target="{
|
||||
itemKey: item.key,
|
||||
groupId: group.id
|
||||
}"
|
||||
class="min-w-0 flex-1 cursor-grab overflow-hidden rounded-lg p-0.5 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="item.widget"
|
||||
:node="item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
hidden-favorite-indicator
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="group.items.length === 0"
|
||||
class="flex items-center justify-center py-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('linearMode.arrange.dropHere') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App mode: clean read-only -->
|
||||
<div v-else class="flex flex-col gap-4 px-4 pt-2 pb-4">
|
||||
<template
|
||||
v-for="row in rows"
|
||||
:key="row.type === 'single' ? row.item.key : row.items[0].key"
|
||||
>
|
||||
<div v-if="row.type === 'single'">
|
||||
<WidgetItem
|
||||
:widget="row.item.widget"
|
||||
:node="row.item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
@update:widget-value="
|
||||
handleWidgetValueUpdate(row.item.widget, $event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-stretch gap-2">
|
||||
<div
|
||||
v-for="item in row.items"
|
||||
:key="item.key"
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<WidgetItem
|
||||
:widget="item.widget"
|
||||
:node="item.node"
|
||||
hidden-label
|
||||
hidden-widget-actions
|
||||
class="w-full"
|
||||
@update:widget-value="
|
||||
handleWidgetValueUpdate(item.widget, $event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
</template>
|
||||
@@ -16,9 +16,10 @@ const selected = defineModel<LayoutTemplateId>({ required: true })
|
||||
<button
|
||||
v-for="template in LAYOUT_TEMPLATES"
|
||||
:key="template.id"
|
||||
v-tooltip.right="t(template.description)"
|
||||
:class="
|
||||
cn(
|
||||
'flex cursor-pointer flex-col items-center gap-0.5 rounded-lg border-2 px-2.5 py-1.5 transition-colors',
|
||||
'flex cursor-pointer items-center justify-center rounded-lg border-2 p-2 transition-colors',
|
||||
selected === template.id
|
||||
? 'border-primary-background bg-primary-background/10'
|
||||
: 'border-transparent bg-transparent hover:bg-secondary-background'
|
||||
@@ -29,7 +30,6 @@ const selected = defineModel<LayoutTemplateId>({ required: true })
|
||||
@click="selected = template.id"
|
||||
>
|
||||
<i :class="cn(template.icon, 'size-5')" />
|
||||
<span class="text-[10px]">{{ t(template.label) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type {
|
||||
@@ -9,28 +9,21 @@ import type {
|
||||
LayoutZone
|
||||
} from '@/components/builder/layoutTemplates'
|
||||
import { buildGridTemplate } from '@/components/builder/layoutTemplates'
|
||||
import ZoneResizeHandle from '@/components/builder/ZoneResizeHandle.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const {
|
||||
template,
|
||||
highlightedZone,
|
||||
dashed = true,
|
||||
gridOverrides,
|
||||
resizable = false
|
||||
gridOverrides
|
||||
} = defineProps<{
|
||||
template: LayoutTemplate
|
||||
highlightedZone?: string
|
||||
dashed?: boolean
|
||||
gridOverrides?: GridOverride
|
||||
resizable?: boolean
|
||||
/** Zone IDs that have content — empty zones get no border in app mode. */
|
||||
filledZones?: Set<string>
|
||||
/** Extra CSS classes per zone ID, applied to the grid cell div. */
|
||||
zoneClasses?: Record<string, string>
|
||||
}>()
|
||||
@@ -39,30 +32,6 @@ defineSlots<{
|
||||
zone(props: { zone: LayoutZone }): unknown
|
||||
}>()
|
||||
|
||||
/** Local fractions for live resize feedback before persisting. */
|
||||
const liveColumnFractions = ref<number[] | undefined>(undefined)
|
||||
const liveRowFractions = ref<number[] | undefined>(undefined)
|
||||
|
||||
// Clear live fractions when template changes to avoid stale values
|
||||
watch(
|
||||
() => template.id,
|
||||
() => {
|
||||
liveColumnFractions.value = undefined
|
||||
liveRowFractions.value = undefined
|
||||
}
|
||||
)
|
||||
|
||||
const effectiveOverrides = computed<GridOverride | undefined>(() => {
|
||||
if (!liveColumnFractions.value && !liveRowFractions.value)
|
||||
return gridOverrides
|
||||
return {
|
||||
...gridOverrides,
|
||||
columnFractions:
|
||||
liveColumnFractions.value ?? gridOverrides?.columnFractions,
|
||||
rowFractions: liveRowFractions.value ?? gridOverrides?.rowFractions
|
||||
}
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => {
|
||||
if (isMobile.value) {
|
||||
// Stack all zones vertically on mobile
|
||||
@@ -72,149 +41,8 @@ const gridStyle = computed(() => {
|
||||
gridAutoRows: 'minmax(200px, auto)'
|
||||
}
|
||||
}
|
||||
return { gridTemplate: buildGridTemplate(template, effectiveOverrides.value) }
|
||||
return { gridTemplate: buildGridTemplate(template, gridOverrides) }
|
||||
})
|
||||
|
||||
/** Parse column/row counts from the template. */
|
||||
const columnCount = computed(() => {
|
||||
const firstRow = template.gridTemplate
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('"'))
|
||||
if (!firstRow) return 0
|
||||
const match = firstRow.match(/"([^"]+)"/)
|
||||
return match ? match[1].split(/\s+/).length : 0
|
||||
})
|
||||
|
||||
/**
|
||||
* Parse fractions from the grid template, handling minmax() expressions.
|
||||
* For minmax(Xpx, Yfr), extracts the fr value Y.
|
||||
*/
|
||||
function parseFractions(
|
||||
gridTemplate: string,
|
||||
type: 'column' | 'row'
|
||||
): number[] {
|
||||
const lines = gridTemplate
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
|
||||
if (type === 'column') {
|
||||
const slashLine = lines.find((l) => l.startsWith('/'))
|
||||
if (!slashLine) return Array.from({ length: columnCount.value }, () => 1)
|
||||
// Split on spaces but keep minmax() together
|
||||
const parts: string[] = []
|
||||
let depth = 0
|
||||
let current = ''
|
||||
for (const ch of slashLine.substring(1).trim()) {
|
||||
if (ch === '(') depth++
|
||||
if (ch === ')') depth--
|
||||
if (ch === ' ' && depth === 0) {
|
||||
if (current) parts.push(current)
|
||||
current = ''
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current)
|
||||
|
||||
return parts.map((p) => {
|
||||
const minmaxMatch = p.match(/minmax\([^,]+,\s*([\d.]+)fr\)/)
|
||||
if (minmaxMatch) return parseFloat(minmaxMatch[1])
|
||||
const n = parseFloat(p)
|
||||
return isNaN(n) ? 1 : n
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
.filter((l) => l.startsWith('"'))
|
||||
.map((line) => {
|
||||
const match = line.match(/"[^"]+"\s+(.+)/)
|
||||
if (!match) return 1
|
||||
const n = parseFloat(match[1])
|
||||
return isNaN(n) ? 1 : n
|
||||
})
|
||||
}
|
||||
|
||||
const defaultColumnFractions = computed(() =>
|
||||
parseFractions(template.gridTemplate, 'column')
|
||||
)
|
||||
const defaultRowFractions = computed(() =>
|
||||
parseFractions(template.gridTemplate, 'row')
|
||||
)
|
||||
|
||||
const effectiveColumnFractions = computed(
|
||||
() =>
|
||||
effectiveOverrides.value?.columnFractions ?? defaultColumnFractions.value
|
||||
)
|
||||
const effectiveRowFractions = computed(
|
||||
() => effectiveOverrides.value?.rowFractions ?? defaultRowFractions.value
|
||||
)
|
||||
|
||||
// Matches p-3 and gap-3 on the grid container
|
||||
const GRID_PADDING_PX = 12
|
||||
const GRID_GAP_PX = 12
|
||||
|
||||
/** Column handle positions as CSS calc() values accounting for padding and gaps. */
|
||||
const columnHandles = computed(() => {
|
||||
const fracs = effectiveColumnFractions.value
|
||||
const total = fracs.reduce((a, b) => a + b, 0)
|
||||
if (total === 0) return []
|
||||
const handles: { index: number; cssLeft: string }[] = []
|
||||
let cumulative = 0
|
||||
const totalPadding = GRID_PADDING_PX * 2
|
||||
const gapCount = fracs.length - 1
|
||||
for (let i = 0; i < fracs.length - 1; i++) {
|
||||
cumulative += fracs[i]
|
||||
const pct = (cumulative / total) * 100
|
||||
handles.push({
|
||||
index: i,
|
||||
cssLeft: `calc(${GRID_PADDING_PX}px + (100% - ${totalPadding + gapCount * GRID_GAP_PX}px) * ${pct / 100} + ${i * GRID_GAP_PX + GRID_GAP_PX / 2}px)`
|
||||
})
|
||||
}
|
||||
return handles
|
||||
})
|
||||
|
||||
/** Row handle positions as CSS calc() values. */
|
||||
const rowHandles = computed(() => {
|
||||
const fracs = effectiveRowFractions.value
|
||||
const total = fracs.reduce((a, b) => a + b, 0)
|
||||
if (total === 0) return []
|
||||
const handles: { index: number; cssTop: string }[] = []
|
||||
let cumulative = 0
|
||||
const totalPadding = GRID_PADDING_PX * 2
|
||||
const gapCount = fracs.length - 1
|
||||
for (let i = 0; i < fracs.length - 1; i++) {
|
||||
cumulative += fracs[i]
|
||||
const pct = (cumulative / total) * 100
|
||||
handles.push({
|
||||
index: i,
|
||||
cssTop: `calc(${GRID_PADDING_PX}px + (100% - ${totalPadding + gapCount * GRID_GAP_PX}px) * ${pct / 100} + ${i * GRID_GAP_PX + GRID_GAP_PX / 2}px)`
|
||||
})
|
||||
}
|
||||
return handles
|
||||
})
|
||||
|
||||
function onColumnResize(fractions: number[]) {
|
||||
liveColumnFractions.value = fractions
|
||||
}
|
||||
|
||||
function onRowResize(fractions: number[]) {
|
||||
liveRowFractions.value = fractions
|
||||
}
|
||||
|
||||
function onColumnResizeEnd(fractions: number[]) {
|
||||
liveColumnFractions.value = undefined
|
||||
const overrides = appModeStore.gridOverrides ?? {}
|
||||
appModeStore.setGridOverrides({ ...overrides, columnFractions: fractions })
|
||||
}
|
||||
|
||||
function onRowResizeEnd(fractions: number[]) {
|
||||
liveRowFractions.value = undefined
|
||||
const overrides = appModeStore.gridOverrides ?? {}
|
||||
appModeStore.setGridOverrides({ ...overrides, rowFractions: fractions })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -230,16 +58,15 @@ function onRowResizeEnd(fractions: number[]) {
|
||||
cn(
|
||||
'relative flex flex-col overflow-y-auto rounded-xl transition-colors',
|
||||
dashed
|
||||
? 'border-2 border-dashed border-border-subtle'
|
||||
: filledZones?.has(zone.id)
|
||||
? 'border-0'
|
||||
: 'border-2 border-solid border-border-subtle',
|
||||
? 'border border-dashed border-border-subtle/40'
|
||||
: 'border border-border-subtle/40',
|
||||
highlightedZone === zone.id &&
|
||||
'border-primary-background bg-primary-background/10',
|
||||
zoneClasses?.[zone.id]
|
||||
)
|
||||
"
|
||||
:data-zone-id="zone.id"
|
||||
:aria-label="t(zone.label)"
|
||||
>
|
||||
<slot name="zone" :zone="zone">
|
||||
<div
|
||||
@@ -252,38 +79,5 @@ function onRowResizeEnd(fractions: number[]) {
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Resize handle overlay — sits above zones so stacking contexts don't block it -->
|
||||
<template v-if="resizable && !isMobile">
|
||||
<ZoneResizeHandle
|
||||
v-for="handle in columnHandles"
|
||||
:key="`col-${handle.index}`"
|
||||
direction="column"
|
||||
:index="handle.index"
|
||||
:fractions="gridOverrides?.columnFractions ?? defaultColumnFractions"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
bottom: '0',
|
||||
left: handle.cssLeft
|
||||
}"
|
||||
@resize="onColumnResize"
|
||||
@resize-end="onColumnResizeEnd"
|
||||
/>
|
||||
<ZoneResizeHandle
|
||||
v-for="handle in rowHandles"
|
||||
:key="`row-${handle.index}`"
|
||||
direction="row"
|
||||
:index="handle.index"
|
||||
:fractions="gridOverrides?.rowFractions ?? defaultRowFractions"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
right: '0',
|
||||
top: handle.cssTop
|
||||
}"
|
||||
@resize="onRowResize"
|
||||
@resize-end="onRowResizeEnd"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,49 +7,37 @@ const meta = {
|
||||
component: PresetMenu,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#1a1a1b' },
|
||||
{ name: 'light', value: '#ffffff' },
|
||||
{ name: 'sidebar', value: '#232326' }
|
||||
]
|
||||
}
|
||||
}
|
||||
} satisfies Meta<typeof PresetMenu>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Empty: Story = {
|
||||
/** Default rendering — click to see built-in quick presets (Min/Mid/Max) and saved presets. */
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
setup() {
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div class="p-8">
|
||||
<p class="mb-4 text-sm text-muted-foreground">No presets saved — shows empty state:</p>
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
export const WithPresets: Story = {
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
setup() {
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div class="p-8">
|
||||
<p class="mb-4 text-sm text-muted-foreground">Click to see built-in quick presets (Min/Mid/Max) and saved presets:</p>
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** In a toolbar context alongside a workflow title. */
|
||||
export const InToolbar: Story = {
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
setup() {
|
||||
return {}
|
||||
},
|
||||
template: `
|
||||
<div class="flex h-12 items-center gap-2 rounded-lg border border-border-subtle bg-comfy-menu-bg px-4 py-2 min-w-80">
|
||||
<span class="truncate font-bold">my_workflow.json</span>
|
||||
@@ -59,3 +47,32 @@ export const InToolbar: Story = {
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** On sidebar background — verify contrast against dark sidebar. */
|
||||
export const OnSidebarBackground: Story = {
|
||||
parameters: {
|
||||
backgrounds: { default: 'sidebar' }
|
||||
},
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
template: `
|
||||
<div class="p-8">
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
/** Narrow container — verify truncation of long preset names. */
|
||||
export const Compact: Story = {
|
||||
render: () => ({
|
||||
components: { PresetMenu },
|
||||
template: `
|
||||
<div class="flex h-10 w-48 items-center rounded-lg border border-border-subtle bg-comfy-menu-bg px-2">
|
||||
<span class="truncate text-sm font-bold">long_workflow_name.json</span>
|
||||
<div class="flex-1" />
|
||||
<PresetMenu />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
407
src/components/builder/SidebarAppLayout.vue
Normal file
407
src/components/builder/SidebarAppLayout.vue
Normal file
@@ -0,0 +1,407 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
|
||||
import BuilderConfirmDialog from '@/components/builder/BuilderConfirmDialog.vue'
|
||||
import InputGroupAccordion from '@/components/builder/InputGroupAccordion.vue'
|
||||
import {
|
||||
inputItemKey,
|
||||
parseGroupItemKey
|
||||
} from '@/components/builder/itemKeyHelper'
|
||||
import LayoutZoneGrid from '@/components/builder/LayoutZoneGrid.vue'
|
||||
import { getTemplate } from '@/components/builder/layoutTemplates'
|
||||
import { useBuilderRename } from '@/components/builder/useBuilderRename'
|
||||
import { vGroupDraggable } from '@/components/builder/useGroupDrop'
|
||||
import { useLinearRunPrompt } from '@/components/builder/useLinearRunPrompt'
|
||||
import {
|
||||
vWidgetDraggable,
|
||||
vZoneDropTarget
|
||||
} from '@/components/builder/useZoneDrop'
|
||||
import { vZoneItemReorderTarget } from '@/components/builder/useWidgetReorder'
|
||||
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
|
||||
import { useArrangeZoneWidgets } from '@/components/builder/useZoneWidgets'
|
||||
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { InputGroup } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { runPrompt } = useLinearRunPrompt()
|
||||
const settingStore = useSettingStore()
|
||||
const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const { isBuilderMode } = useAppMode()
|
||||
|
||||
const activeTemplate = computed(
|
||||
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('single')!
|
||||
)
|
||||
|
||||
/** The zone where run controls should render (last zone = right column in dual). */
|
||||
const runZoneId = computed(() => {
|
||||
const zones = activeTemplate.value.zones
|
||||
return zones.at(-1)?.id ?? zones[0]?.id ?? ''
|
||||
})
|
||||
|
||||
// Builder mode: draggable zone widgets
|
||||
const zoneWidgets = useArrangeZoneWidgets()
|
||||
|
||||
onMounted(() => {
|
||||
if (isBuilderMode.value) appModeStore.autoAssignInputs()
|
||||
})
|
||||
|
||||
const widgetsByKey = computed(() => {
|
||||
const map = new Map<string, ResolvedArrangeWidget>()
|
||||
for (const [, widgets] of zoneWidgets.value) {
|
||||
for (const w of widgets) map.set(inputItemKey(w.nodeId, w.widgetName), w)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function getOrderedItems(zoneId: string) {
|
||||
const widgets = zoneWidgets.value.get(zoneId) ?? []
|
||||
const hasRun = zoneId === appModeStore.runControlsZoneId
|
||||
return appModeStore.getZoneItems(zoneId, [], widgets, hasRun, false)
|
||||
}
|
||||
|
||||
const {
|
||||
renamingKey,
|
||||
renameValue,
|
||||
startRename: startRenameInput,
|
||||
confirmRename: confirmRenameInput,
|
||||
cancelRename: cancelRenameInput,
|
||||
startRenameDeferred: startRenameInputDeferred
|
||||
} = useBuilderRename((key) => widgetsByKey.value.get(key))
|
||||
|
||||
const showRemoveDialog = ref(false)
|
||||
const pendingRemove = ref<{ nodeId: NodeId; widgetName: string } | null>(null)
|
||||
|
||||
function confirmRemoveInput(nodeId: NodeId, widgetName: string) {
|
||||
pendingRemove.value = { nodeId, widgetName }
|
||||
showRemoveDialog.value = true
|
||||
}
|
||||
|
||||
function removeInput() {
|
||||
if (!pendingRemove.value) return
|
||||
const { nodeId, widgetName } = pendingRemove.value
|
||||
const idx = appModeStore.selectedInputs.findIndex(
|
||||
([nId, wName]) => nId === nodeId && wName === widgetName
|
||||
)
|
||||
if (idx !== -1) appModeStore.selectedInputs.splice(idx, 1)
|
||||
showRemoveDialog.value = false
|
||||
pendingRemove.value = null
|
||||
}
|
||||
|
||||
function findGroupById(itemKey: string) {
|
||||
const groupId = parseGroupItemKey(itemKey)
|
||||
if (!groupId) return undefined
|
||||
return appModeStore.inputGroups.find((g) => g.id === groupId)
|
||||
}
|
||||
|
||||
type ZoneSegment =
|
||||
| { type: 'inputs'; keys: string[] }
|
||||
| { type: 'group'; group: InputGroup }
|
||||
|
||||
function getZoneSegments(zoneId: string): ZoneSegment[] {
|
||||
const items = getOrderedItems(zoneId)
|
||||
const segments: ZoneSegment[] = []
|
||||
let currentInputKeys: string[] = []
|
||||
|
||||
function flushInputs() {
|
||||
if (currentInputKeys.length > 0) {
|
||||
segments.push({ type: 'inputs', keys: [...currentInputKeys] })
|
||||
currentInputKeys = []
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of items) {
|
||||
if (key.startsWith('input:')) {
|
||||
currentInputKeys.push(key)
|
||||
} else if (key.startsWith('group:')) {
|
||||
const group = findGroupById(key)
|
||||
if (group && (isBuilderMode.value || group.items.length >= 1)) {
|
||||
flushInputs()
|
||||
segments.push({ type: 'group', group })
|
||||
}
|
||||
}
|
||||
}
|
||||
flushInputs()
|
||||
return segments
|
||||
}
|
||||
|
||||
function groupPosition(
|
||||
group: InputGroup,
|
||||
segments: ZoneSegment[]
|
||||
): 'first' | 'middle' | 'last' | 'only' {
|
||||
const groupSegments = segments.filter(
|
||||
(s): s is ZoneSegment & { type: 'group' } => s.type === 'group'
|
||||
)
|
||||
const idx = groupSegments.findIndex((s) => s.group.id === group.id)
|
||||
const total = groupSegments.length
|
||||
const isFirst = idx === 0 && !segments.some((s) => s.type === 'inputs')
|
||||
if (total === 1) return isFirst ? 'only' : 'last'
|
||||
if (isFirst) return 'first'
|
||||
if (idx === total - 1) return 'last'
|
||||
return 'middle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Inputs area -->
|
||||
<div class="flex min-h-0 flex-1 flex-col bg-comfy-menu-bg px-2">
|
||||
<!-- === ZONE GRID (always — single or dual) === -->
|
||||
<LayoutZoneGrid
|
||||
:template="activeTemplate"
|
||||
:grid-overrides="appModeStore.gridOverrides"
|
||||
:dashed="isBuilderMode"
|
||||
class="min-h-0 flex-1"
|
||||
>
|
||||
<template #zone="{ zone }">
|
||||
<div class="flex size-full flex-col" :data-zone-id="zone.id">
|
||||
<!-- Inputs (scrollable, order matches builder mode) -->
|
||||
<div
|
||||
v-if="!isBuilderMode"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
<div>
|
||||
<template
|
||||
v-for="(segment, sIdx) in getZoneSegments(zone.id)"
|
||||
:key="
|
||||
segment.type === 'inputs'
|
||||
? `inputs-${sIdx}`
|
||||
: `group-${segment.group.id}`
|
||||
"
|
||||
>
|
||||
<AppModeWidgetList
|
||||
v-if="segment.type === 'inputs'"
|
||||
:item-keys="segment.keys"
|
||||
/>
|
||||
<InputGroupAccordion
|
||||
v-else
|
||||
:group="segment.group"
|
||||
:zone-id="zone.id"
|
||||
:position="
|
||||
groupPosition(segment.group, getZoneSegments(zone.id))
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Builder mode: draggable zone content (scrollable, short content hugs bottom) -->
|
||||
<div
|
||||
v-else
|
||||
v-zone-drop-target="zone.id"
|
||||
class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-2 [&.zone-drag-over]:bg-primary-background/10 [&.zone-drag-over]:ring-2 [&.zone-drag-over]:ring-primary-background [&.zone-drag-over]:ring-inset"
|
||||
>
|
||||
<template
|
||||
v-for="itemKey in getOrderedItems(zone.id)"
|
||||
:key="itemKey"
|
||||
>
|
||||
<!-- Input widget -->
|
||||
<div
|
||||
v-if="
|
||||
itemKey.startsWith('input:') && widgetsByKey.get(itemKey)
|
||||
"
|
||||
v-widget-draggable="{
|
||||
nodeId: widgetsByKey.get(itemKey)!.nodeId,
|
||||
widgetName: widgetsByKey.get(itemKey)!.widgetName,
|
||||
zone: zone.id
|
||||
}"
|
||||
v-zone-item-reorder-target="{
|
||||
itemKey,
|
||||
zone: zone.id
|
||||
}"
|
||||
class="shrink-0 cursor-grab overflow-hidden rounded-lg border border-dashed border-border-subtle p-2 [&.pair-indicator]:ring-2 [&.pair-indicator]:ring-primary-background [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<!-- Builder menu -->
|
||||
<div class="mb-1 flex items-center gap-1">
|
||||
<div
|
||||
v-if="renamingKey === itemKey"
|
||||
class="flex flex-1 items-center"
|
||||
>
|
||||
<input
|
||||
v-model="renameValue"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 border-none bg-transparent text-sm text-base-foreground outline-none"
|
||||
@click.stop
|
||||
@keydown.enter.stop="confirmRenameInput"
|
||||
@keydown.escape.stop="cancelRenameInput"
|
||||
@blur="confirmRenameInput"
|
||||
@vue:mounted="
|
||||
($event: any) => {
|
||||
$event.el?.focus()
|
||||
$event.el?.select()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="flex-1 truncate text-sm text-muted-foreground"
|
||||
@dblclick.stop="startRenameInput(itemKey)"
|
||||
>
|
||||
{{
|
||||
widgetsByKey.get(itemKey)!.widget.label ||
|
||||
widgetsByKey.get(itemKey)!.widget.name
|
||||
}}
|
||||
—
|
||||
{{ widgetsByKey.get(itemKey)!.node.title }}
|
||||
</span>
|
||||
<Popover class="pointer-events-auto shrink-0">
|
||||
<template #button>
|
||||
<Button variant="textonly" size="icon">
|
||||
<i class="icon-[lucide--ellipsis-vertical]" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close }">
|
||||
<div class="flex flex-col gap-1 p-1">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
close()
|
||||
startRenameInputDeferred(itemKey)
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--pencil]" />
|
||||
{{ t('g.rename') }}
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
|
||||
@click="
|
||||
() => {
|
||||
confirmRemoveInput(
|
||||
widgetsByKey.get(itemKey)!.nodeId,
|
||||
widgetsByKey.get(itemKey)!.widgetName
|
||||
)
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
{{ t('g.remove') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div class="pointer-events-none" inert>
|
||||
<WidgetItem
|
||||
:widget="widgetsByKey.get(itemKey)!.widget"
|
||||
:node="widgetsByKey.get(itemKey)!.node"
|
||||
hidden-label
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Group accordion -->
|
||||
<div
|
||||
v-else-if="
|
||||
itemKey.startsWith('group:') && findGroupById(itemKey)
|
||||
"
|
||||
v-group-draggable="{
|
||||
groupId: findGroupById(itemKey)!.id,
|
||||
zone: zone.id
|
||||
}"
|
||||
v-zone-item-reorder-target="{
|
||||
itemKey,
|
||||
zone: zone.id
|
||||
}"
|
||||
class="shrink-0 [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
|
||||
>
|
||||
<InputGroupAccordion
|
||||
:group="findGroupById(itemKey)!"
|
||||
:zone-id="zone.id"
|
||||
builder-mode
|
||||
/>
|
||||
</div>
|
||||
<!-- Run controls handled below, pinned to zone bottom -->
|
||||
</template>
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="getOrderedItems(zone.id).length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="mr-2 icon-[lucide--plus] size-4" />
|
||||
{{ t('linearMode.arrange.dropHere') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create group (pinned below scroll, builder only) -->
|
||||
<button
|
||||
v-if="isBuilderMode"
|
||||
type="button"
|
||||
class="group/cg flex w-full shrink-0 items-center justify-between border-0 border-t border-border-subtle/40 bg-transparent py-4 pr-5 pl-4 text-sm text-base-foreground outline-none"
|
||||
@click="appModeStore.createGroup(zone.id)"
|
||||
>
|
||||
{{ t('linearMode.groups.createGroup') }}
|
||||
<i
|
||||
class="icon-[lucide--plus] size-5 text-muted-foreground group-hover/cg:text-base-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Run controls (pinned to bottom of last zone, both modes) -->
|
||||
<section
|
||||
v-if="zone.id === runZoneId"
|
||||
data-testid="linear-run-controls"
|
||||
:class="[
|
||||
'mt-auto shrink-0 border-t p-4 pb-6',
|
||||
isBuilderMode
|
||||
? 'border-border-subtle/40'
|
||||
: 'mx-3 border-border-subtle'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span
|
||||
class="shrink-0 text-sm text-node-component-slot-text"
|
||||
v-text="t('linearMode.runCount')"
|
||||
/>
|
||||
<ScrubableNumberInput
|
||||
v-model="batchCount"
|
||||
:aria-label="t('linearMode.runCount')"
|
||||
:min="1"
|
||||
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
|
||||
class="h-7 max-w-[35%] min-w-fit flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="mt-4 w-full text-sm"
|
||||
size="lg"
|
||||
data-testid="linear-run-button"
|
||||
@click="runPrompt"
|
||||
>
|
||||
<i class="icon-[lucide--play]" />
|
||||
{{ t('menu.run') }}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutZoneGrid>
|
||||
|
||||
<PartnerNodesList />
|
||||
</div>
|
||||
|
||||
<BuilderConfirmDialog
|
||||
v-model="showRemoveDialog"
|
||||
:title="t('linearMode.groups.confirmRemove')"
|
||||
:description="t('linearMode.groups.removeDescription')"
|
||||
:confirm-label="t('g.remove')"
|
||||
confirm-variant="destructive"
|
||||
@confirm="removeInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
119
src/components/builder/dropIndicatorUtil.test.ts
Normal file
119
src/components/builder/dropIndicatorUtil.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
import { buildDropIndicator } from './dropIndicatorUtil'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (path: string) => `http://localhost:8188${path}` }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { getPreviewFormatParam: () => '&format=webp' }
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/cloudPreviewUtil', () => ({
|
||||
appendCloudResParam: vi.fn()
|
||||
}))
|
||||
|
||||
function makeNode(type: string, widgetValue?: unknown): LGraphNode {
|
||||
return {
|
||||
type,
|
||||
widgets:
|
||||
widgetValue !== undefined
|
||||
? [{ value: widgetValue }, { callback: vi.fn() }]
|
||||
: undefined
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('buildDropIndicator', () => {
|
||||
it('returns undefined for unsupported node types', () => {
|
||||
expect(buildDropIndicator(makeNode('KSampler'), {})).toBeUndefined()
|
||||
expect(buildDropIndicator(makeNode('CLIPTextEncode'), {})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns image indicator for LoadImage node with filename', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadImage', 'photo.png'), {
|
||||
imageLabel: 'Upload'
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.iconClass).toBe('icon-[lucide--image]')
|
||||
expect(result!.imageUrl).toContain('/view?')
|
||||
expect(result!.imageUrl).toContain('filename=photo.png')
|
||||
expect(result!.label).toBe('Upload')
|
||||
})
|
||||
|
||||
it('returns image indicator with no imageUrl when widget has no value', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadImage', ''), {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.imageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns image indicator with no imageUrl when widgets are missing', () => {
|
||||
const node = { type: 'LoadImage' } as unknown as LGraphNode
|
||||
const result = buildDropIndicator(node, {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.imageUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes onMaskEdit when imageUrl exists and openMaskEditor is provided', () => {
|
||||
const openMaskEditor = vi.fn()
|
||||
const node = makeNode('LoadImage', 'photo.png')
|
||||
const result = buildDropIndicator(node, { openMaskEditor })
|
||||
|
||||
expect(result!.onMaskEdit).toBeDefined()
|
||||
result!.onMaskEdit!()
|
||||
expect(openMaskEditor).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('omits onMaskEdit when no imageUrl', () => {
|
||||
const openMaskEditor = vi.fn()
|
||||
const result = buildDropIndicator(makeNode('LoadImage', ''), {
|
||||
openMaskEditor
|
||||
})
|
||||
|
||||
expect(result!.onMaskEdit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns video indicator for LoadVideo node with filename', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadVideo', 'clip.mp4'), {
|
||||
videoLabel: 'Upload Video'
|
||||
})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.iconClass).toBe('icon-[lucide--video]')
|
||||
expect(result!.videoUrl).toContain('/view?')
|
||||
expect(result!.videoUrl).toContain('filename=clip.mp4')
|
||||
expect(result!.label).toBe('Upload Video')
|
||||
expect(result!.onMaskEdit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns video indicator with no videoUrl when widget has no value', () => {
|
||||
const result = buildDropIndicator(makeNode('LoadVideo', ''), {})
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.videoUrl).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses subfolder and type from widget value', () => {
|
||||
const result = buildDropIndicator(
|
||||
makeNode('LoadImage', 'sub/folder/image.png [output]'),
|
||||
{}
|
||||
)
|
||||
|
||||
expect(result!.imageUrl).toContain('filename=image.png')
|
||||
expect(result!.imageUrl).toContain('subfolder=sub%2Ffolder')
|
||||
expect(result!.imageUrl).toContain('type=output')
|
||||
})
|
||||
|
||||
it('invokes widget callback on onClick', () => {
|
||||
const node = makeNode('LoadImage', 'photo.png')
|
||||
const result = buildDropIndicator(node, {})
|
||||
|
||||
result!.onClick!({} as MouseEvent)
|
||||
expect(node.widgets![1].callback).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
|
||||
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
|
||||
@@ -5,13 +6,15 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { parseImageWidgetValue } from '@/utils/imageUtil'
|
||||
|
||||
export interface DropIndicatorData {
|
||||
interface DropIndicatorData {
|
||||
iconClass: string
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
label?: string
|
||||
onClick?: (e: MouseEvent) => void
|
||||
onMaskEdit?: () => void
|
||||
onDownload?: () => void
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +69,17 @@ function buildImageDropIndicator(
|
||||
onMaskEdit:
|
||||
imageUrl && options.openMaskEditor
|
||||
? () => options.openMaskEditor!(node)
|
||||
: undefined
|
||||
: undefined,
|
||||
onDownload: imageUrl ? () => downloadFile(imageUrl) : undefined,
|
||||
onRemove: imageUrl
|
||||
? () => {
|
||||
const imageWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (imageWidget) {
|
||||
imageWidget.value = ''
|
||||
imageWidget.callback?.(undefined)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
src/components/builder/layoutTemplates.test.ts
Normal file
163
src/components/builder/layoutTemplates.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { LayoutTemplateId } from './layoutTemplates'
|
||||
import {
|
||||
buildGridTemplate,
|
||||
getTemplate,
|
||||
LAYOUT_TEMPLATES
|
||||
} from './layoutTemplates'
|
||||
|
||||
/** Extract area rows from a grid template string. */
|
||||
function parseAreaRows(gridStr: string) {
|
||||
return gridStr
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith('"'))
|
||||
.map((l) => {
|
||||
const match = l.match(/"([^"]+)"\s*(.*)/)
|
||||
return {
|
||||
areas: match?.[1].split(/\s+/) ?? [],
|
||||
fraction: match?.[2]?.trim() || '1fr'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildGridTemplate', () => {
|
||||
const dualTemplate = getTemplate('dual')!
|
||||
|
||||
it('returns original gridTemplate when no overrides', () => {
|
||||
const result = buildGridTemplate(dualTemplate)
|
||||
expect(result).toBe(dualTemplate.gridTemplate)
|
||||
})
|
||||
|
||||
it('applies column fraction overrides', () => {
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
const colCount = originalRows[0].areas.length
|
||||
|
||||
const fractions = Array.from({ length: colCount }, (_, i) => i + 1)
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
columnFractions: fractions
|
||||
})
|
||||
|
||||
const colLine = result
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
expect(colLine).toBe(`/ ${fractions.map((f) => `${f}fr`).join(' ')}`)
|
||||
})
|
||||
|
||||
it('applies row fraction overrides in correct positions', () => {
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
rowFractions: [2]
|
||||
})
|
||||
const rows = parseAreaRows(result)
|
||||
expect(rows[0].fraction).toBe('2fr')
|
||||
})
|
||||
|
||||
it('reorders zone areas in output', () => {
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
const uniqueAreas = [...new Set(originalRows.flatMap((r) => r.areas))]
|
||||
const swapped = [uniqueAreas[1], uniqueAreas[0]]
|
||||
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
zoneOrder: swapped
|
||||
})
|
||||
const resultRows = parseAreaRows(result)
|
||||
|
||||
expect(resultRows[0].areas[0]).toBe(originalRows[0].areas[1])
|
||||
expect(resultRows[0].areas[1]).toBe(originalRows[0].areas[0])
|
||||
})
|
||||
|
||||
it('preserves row count when applying overrides', () => {
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
rowFractions: [1]
|
||||
})
|
||||
const resultRows = parseAreaRows(result)
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
expect(resultRows).toHaveLength(originalRows.length)
|
||||
})
|
||||
|
||||
it('falls back to original columns when fractions length mismatches', () => {
|
||||
const originalColLine = dualTemplate.gridTemplate
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
columnFractions: [1] // wrong count — should be ignored
|
||||
})
|
||||
const resultColLine = result
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
|
||||
expect(resultColLine).toBe(originalColLine)
|
||||
})
|
||||
|
||||
it('applies combined overrides together', () => {
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
const uniqueAreas = [...new Set(originalRows.flatMap((r) => r.areas))]
|
||||
const swapped = [uniqueAreas[1], uniqueAreas[0]]
|
||||
const colCount = originalRows[0].areas.length
|
||||
|
||||
const result = buildGridTemplate(dualTemplate, {
|
||||
zoneOrder: swapped,
|
||||
rowFractions: [5],
|
||||
columnFractions: Array.from({ length: colCount }, () => 2)
|
||||
})
|
||||
|
||||
const resultRows = parseAreaRows(result)
|
||||
expect(resultRows[0].areas[0]).toBe(originalRows[0].areas[1])
|
||||
expect(resultRows[0].fraction).toBe('5fr')
|
||||
const colLine = result
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith('/'))
|
||||
expect(colLine).toContain('2fr')
|
||||
})
|
||||
|
||||
it('empty overrides produce same structure as original', () => {
|
||||
const result = buildGridTemplate(dualTemplate, {})
|
||||
const resultRows = parseAreaRows(result)
|
||||
const originalRows = parseAreaRows(dualTemplate.gridTemplate)
|
||||
expect(resultRows.map((r) => r.areas)).toEqual(
|
||||
originalRows.map((r) => r.areas)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTemplate', () => {
|
||||
it('returns undefined for invalid ID', () => {
|
||||
expect(
|
||||
getTemplate('nonexistent' as unknown as LayoutTemplateId)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns matching template for each known ID', () => {
|
||||
for (const template of LAYOUT_TEMPLATES) {
|
||||
expect(getTemplate(template.id)).toBe(template)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('LAYOUT_TEMPLATES', () => {
|
||||
it('has unique IDs', () => {
|
||||
const ids = LAYOUT_TEMPLATES.map((t) => t.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('every template has at least one zone', () => {
|
||||
for (const template of LAYOUT_TEMPLATES) {
|
||||
expect(template.zones.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('every template has valid default zone references', () => {
|
||||
for (const template of LAYOUT_TEMPLATES) {
|
||||
const zoneIds = template.zones.map((z) => z.id)
|
||||
expect(zoneIds).toContain(template.defaultRunControlsZone)
|
||||
expect(zoneIds).toContain(template.defaultPresetStripZone)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
export type LayoutTemplateId = 'focus' | 'grid' | 'sidebar'
|
||||
export type LayoutTemplateId = 'single' | 'dual'
|
||||
|
||||
export interface LayoutZone {
|
||||
id: string
|
||||
/** i18n key for the zone label */
|
||||
label: string
|
||||
gridArea: string
|
||||
isOutput?: boolean
|
||||
}
|
||||
|
||||
export interface LayoutTemplate {
|
||||
@@ -21,99 +20,51 @@ export interface LayoutTemplate {
|
||||
defaultRunControlsZone: string
|
||||
/** Zone ID where preset strip goes by default */
|
||||
defaultPresetStripZone: string
|
||||
/** Zone IDs that default to bottom alignment */
|
||||
defaultBottomAlignZones?: string[]
|
||||
}
|
||||
|
||||
export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
|
||||
{
|
||||
id: 'focus',
|
||||
label: 'linearMode.layout.templates.focus',
|
||||
description: 'linearMode.layout.templates.focusDesc',
|
||||
icon: 'icon-[lucide--layout-panel-left]',
|
||||
id: 'single',
|
||||
label: 'linearMode.layout.templates.single',
|
||||
description: 'linearMode.layout.templates.singleDesc',
|
||||
icon: 'icon-[lucide--panel-right]',
|
||||
gridTemplate: `
|
||||
"main side1" 1fr
|
||||
"main side2" 1fr
|
||||
/ 2fr 1fr
|
||||
"main" 1fr
|
||||
/ 1fr
|
||||
`,
|
||||
zones: [
|
||||
{
|
||||
id: 'main',
|
||||
label: 'linearMode.layout.zones.main',
|
||||
gridArea: 'main',
|
||||
isOutput: true
|
||||
},
|
||||
{
|
||||
id: 'side1',
|
||||
label: 'linearMode.layout.zones.topRight',
|
||||
gridArea: 'side1'
|
||||
},
|
||||
{
|
||||
id: 'side2',
|
||||
label: 'linearMode.layout.zones.bottomRight',
|
||||
gridArea: 'side2'
|
||||
gridArea: 'main'
|
||||
}
|
||||
],
|
||||
defaultRunControlsZone: 'side2',
|
||||
defaultPresetStripZone: 'side1',
|
||||
defaultBottomAlignZones: ['side2']
|
||||
defaultRunControlsZone: 'main',
|
||||
defaultPresetStripZone: 'main'
|
||||
},
|
||||
{
|
||||
id: 'grid',
|
||||
label: 'linearMode.layout.templates.grid',
|
||||
description: 'linearMode.layout.templates.gridDesc',
|
||||
icon: 'icon-[lucide--grid-3x3]',
|
||||
id: 'dual',
|
||||
label: 'linearMode.layout.templates.dual',
|
||||
description: 'linearMode.layout.templates.dualDesc',
|
||||
icon: 'icon-[lucide--columns-2]',
|
||||
gridTemplate: `
|
||||
"z1 z2 z3" 1fr
|
||||
"z4 z5 z6" 1fr
|
||||
/ 1fr 1fr 1fr
|
||||
"left right" 1fr
|
||||
/ 1fr 1fr
|
||||
`,
|
||||
zones: [
|
||||
{ id: 'z1', label: 'linearMode.layout.zones.zone1', gridArea: 'z1' },
|
||||
{ id: 'z2', label: 'linearMode.layout.zones.zone2', gridArea: 'z2' },
|
||||
{ id: 'z3', label: 'linearMode.layout.zones.zone3', gridArea: 'z3' },
|
||||
{ id: 'z4', label: 'linearMode.layout.zones.zone4', gridArea: 'z4' },
|
||||
{
|
||||
id: 'z5',
|
||||
label: 'linearMode.layout.zones.zone5',
|
||||
gridArea: 'z5',
|
||||
isOutput: true
|
||||
id: 'left',
|
||||
label: 'linearMode.layout.zones.left',
|
||||
gridArea: 'left'
|
||||
},
|
||||
{ id: 'z6', label: 'linearMode.layout.zones.zone6', gridArea: 'z6' }
|
||||
],
|
||||
defaultRunControlsZone: 'z6',
|
||||
defaultPresetStripZone: 'z3',
|
||||
defaultBottomAlignZones: ['z6']
|
||||
},
|
||||
{
|
||||
id: 'sidebar',
|
||||
label: 'linearMode.layout.templates.sidebar',
|
||||
description: 'linearMode.layout.templates.sidebarDesc',
|
||||
icon: 'icon-[lucide--panel-right]',
|
||||
gridTemplate: `
|
||||
"z1 z2 sb" 1fr
|
||||
"z3 z4 sb" 1fr
|
||||
/ 1fr 1fr minmax(240px, 0.8fr)
|
||||
`,
|
||||
zones: [
|
||||
{ id: 'z1', label: 'linearMode.layout.zones.zone1', gridArea: 'z1' },
|
||||
{
|
||||
id: 'z2',
|
||||
label: 'linearMode.layout.zones.zone2',
|
||||
gridArea: 'z2',
|
||||
isOutput: true
|
||||
},
|
||||
{ id: 'z3', label: 'linearMode.layout.zones.zone3', gridArea: 'z3' },
|
||||
{ id: 'z4', label: 'linearMode.layout.zones.zone4', gridArea: 'z4' },
|
||||
{
|
||||
id: 'sb',
|
||||
label: 'linearMode.layout.zones.sidebar',
|
||||
gridArea: 'sb'
|
||||
id: 'right',
|
||||
label: 'linearMode.layout.zones.right',
|
||||
gridArea: 'right'
|
||||
}
|
||||
],
|
||||
defaultRunControlsZone: 'sb',
|
||||
defaultPresetStripZone: 'sb',
|
||||
defaultBottomAlignZones: ['sb']
|
||||
defaultRunControlsZone: 'right',
|
||||
defaultPresetStripZone: 'left'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -163,13 +114,6 @@ export function buildGridTemplate(
|
||||
// Apply zone order reordering if provided
|
||||
let reorderedRows = rows
|
||||
if (zoneOrder && zoneOrder.length > 0) {
|
||||
const zoneToArea = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
for (const area of row.areas) {
|
||||
zoneToArea.set(area, area)
|
||||
}
|
||||
}
|
||||
|
||||
// Build a mapping from old position to new position
|
||||
const allAreas = rows.flatMap((r) => r.areas)
|
||||
const uniqueAreas = [...new Set(allAreas)]
|
||||
|
||||
50
src/components/builder/useBuilderRename.ts
Normal file
50
src/components/builder/useBuilderRename.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
|
||||
export function useBuilderRename(
|
||||
getWidget: (key: string) => ResolvedArrangeWidget | undefined
|
||||
) {
|
||||
const renamingKey = ref<string | null>(null)
|
||||
const renameValue = ref('')
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function startRename(itemKey: string) {
|
||||
const w = getWidget(itemKey)
|
||||
if (!w) return
|
||||
renameValue.value = w.widget.label || w.widget.name
|
||||
renamingKey.value = itemKey
|
||||
}
|
||||
|
||||
function confirmRename() {
|
||||
if (!renamingKey.value) return
|
||||
const w = getWidget(renamingKey.value)
|
||||
if (w) {
|
||||
const trimmed = renameValue.value.trim()
|
||||
if (trimmed) {
|
||||
renameWidget(w.widget, w.node, trimmed)
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
}
|
||||
}
|
||||
renamingKey.value = null
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
renamingKey.value = null
|
||||
}
|
||||
|
||||
function startRenameDeferred(itemKey: string) {
|
||||
setTimeout(() => startRename(itemKey), 50)
|
||||
}
|
||||
|
||||
return {
|
||||
renamingKey,
|
||||
renameValue,
|
||||
startRename,
|
||||
confirmRename,
|
||||
cancelRename,
|
||||
startRenameDeferred
|
||||
}
|
||||
}
|
||||
234
src/components/builder/useGroupDrop.ts
Normal file
234
src/components/builder/useGroupDrop.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import {
|
||||
inputItemKey,
|
||||
parseInputItemKey
|
||||
} from '@/components/builder/itemKeyHelper'
|
||||
import { getEdgeTriZone } from '@/components/builder/useWidgetReorder'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
function getDragItemKey(data: Record<string | symbol, unknown>): string | null {
|
||||
if (data.type === 'zone-widget')
|
||||
return inputItemKey(data.nodeId as string, data.widgetName as string)
|
||||
return null
|
||||
}
|
||||
|
||||
// --- Group body drop target ---
|
||||
|
||||
interface GroupDropBinding {
|
||||
groupId: string
|
||||
zoneId: string
|
||||
}
|
||||
|
||||
type GroupDropEl = HTMLElement & {
|
||||
__groupDropCleanup?: () => void
|
||||
__groupDropValue?: GroupDropBinding
|
||||
}
|
||||
|
||||
/** Drop zone for the group body — accepts zone-widget drags. */
|
||||
export const vGroupDropTarget: Directive<HTMLElement, GroupDropBinding> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupDropEl
|
||||
typedEl.__groupDropValue = value
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
typedEl.__groupDropCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const itemKey = getDragItemKey(source.data)
|
||||
if (!itemKey) return false
|
||||
const group = appModeStore.inputGroups.find(
|
||||
(g) => g.id === typedEl.__groupDropValue!.groupId
|
||||
)
|
||||
return !group?.items.some((i) => i.key === itemKey)
|
||||
},
|
||||
onDragEnter: () => el.classList.add('group-drag-over'),
|
||||
onDragLeave: () => el.classList.remove('group-drag-over'),
|
||||
onDrop: ({ source, location }) => {
|
||||
el.classList.remove('group-drag-over')
|
||||
// Skip if the innermost drop target is a child (item reorder handled it)
|
||||
if (location.current.dropTargets[0]?.element !== el) return
|
||||
const itemKey = getDragItemKey(source.data)
|
||||
if (!itemKey) return
|
||||
const { groupId, zoneId } = typedEl.__groupDropValue!
|
||||
appModeStore.moveWidgetItem(itemKey, {
|
||||
kind: 'group',
|
||||
zoneId,
|
||||
groupId
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupDropEl).__groupDropValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupDropEl).__groupDropCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group item reorder (with center detection for pairing) ---
|
||||
|
||||
interface GroupItemReorderBinding {
|
||||
itemKey: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
type GroupItemReorderEl = HTMLElement & {
|
||||
__groupReorderCleanup?: () => void
|
||||
__groupReorderValue?: GroupItemReorderBinding
|
||||
}
|
||||
|
||||
function clearGroupIndicator(el: HTMLElement) {
|
||||
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
|
||||
}
|
||||
|
||||
function setGroupIndicator(
|
||||
el: HTMLElement,
|
||||
edge: 'before' | 'center' | 'after'
|
||||
) {
|
||||
clearGroupIndicator(el)
|
||||
if (edge === 'center') {
|
||||
el.classList.add('pair-indicator')
|
||||
} else {
|
||||
el.classList.add(`reorder-${edge}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder within a group with three-zone detection for side-by-side pairing. */
|
||||
export const vGroupItemReorderTarget: Directive<
|
||||
HTMLElement,
|
||||
GroupItemReorderBinding
|
||||
> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupItemReorderEl
|
||||
typedEl.__groupReorderValue = value
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
typedEl.__groupReorderCleanup = dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) => {
|
||||
const dragKey = getDragItemKey(source.data)
|
||||
return !!dragKey && dragKey !== typedEl.__groupReorderValue!.itemKey
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
setGroupIndicator(
|
||||
el,
|
||||
getEdgeTriZone(el, location.current.input.clientY)
|
||||
)
|
||||
},
|
||||
onDragEnter: ({ location }) => {
|
||||
setGroupIndicator(
|
||||
el,
|
||||
getEdgeTriZone(el, location.current.input.clientY)
|
||||
)
|
||||
},
|
||||
onDragLeave: () => clearGroupIndicator(el),
|
||||
onDrop: ({ source, location }) => {
|
||||
clearGroupIndicator(el)
|
||||
const dragKey = getDragItemKey(source.data)
|
||||
if (!dragKey) return
|
||||
|
||||
const { groupId, itemKey } = typedEl.__groupReorderValue!
|
||||
const edge = getEdgeTriZone(el, location.current.input.clientY)
|
||||
|
||||
appModeStore.moveWidgetItem(dragKey, {
|
||||
kind: 'group-relative',
|
||||
zoneId: '',
|
||||
groupId,
|
||||
targetKey: itemKey,
|
||||
edge
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupItemReorderEl).__groupReorderValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupItemReorderEl).__groupReorderCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Draggable for items inside a group ---
|
||||
|
||||
interface GroupItemDragBinding {
|
||||
itemKey: string
|
||||
groupId: string
|
||||
}
|
||||
|
||||
type GroupItemDragEl = HTMLElement & {
|
||||
__groupItemDragCleanup?: () => void
|
||||
__groupItemDragValue?: GroupItemDragBinding
|
||||
}
|
||||
|
||||
/** Makes an item inside a group draggable. */
|
||||
export const vGroupItemDraggable: Directive<HTMLElement, GroupItemDragBinding> =
|
||||
{
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupItemDragEl
|
||||
typedEl.__groupItemDragValue = value
|
||||
|
||||
typedEl.__groupItemDragCleanup = draggable({
|
||||
element: el,
|
||||
getInitialData: () => {
|
||||
const parsed = parseInputItemKey(
|
||||
typedEl.__groupItemDragValue!.itemKey
|
||||
)
|
||||
return {
|
||||
type: 'zone-widget',
|
||||
nodeId: parsed?.nodeId ?? '',
|
||||
widgetName: parsed?.widgetName ?? '',
|
||||
sourceZone: '__group__',
|
||||
sourceGroupId: typedEl.__groupItemDragValue!.groupId
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupItemDragEl).__groupItemDragValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupItemDragEl).__groupItemDragCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Draggable for entire group (reorder within zone) ---
|
||||
|
||||
interface GroupDragBinding {
|
||||
groupId: string
|
||||
zone: string
|
||||
}
|
||||
|
||||
type GroupDragEl = HTMLElement & {
|
||||
__groupDragCleanup?: () => void
|
||||
__groupDragValue?: GroupDragBinding
|
||||
}
|
||||
|
||||
/** Makes a group draggable within the zone order. Uses drag-handle class. */
|
||||
export const vGroupDraggable: Directive<HTMLElement, GroupDragBinding> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as GroupDragEl
|
||||
typedEl.__groupDragValue = value
|
||||
|
||||
typedEl.__groupDragCleanup = draggable({
|
||||
element: el,
|
||||
dragHandle: el.querySelector('.drag-handle') ?? undefined,
|
||||
getInitialData: () => ({
|
||||
type: 'zone-group',
|
||||
groupId: typedEl.__groupDragValue!.groupId,
|
||||
sourceZone: typedEl.__groupDragValue!.zone
|
||||
})
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as GroupDragEl).__groupDragValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as GroupDragEl).__groupDragCleanup?.()
|
||||
}
|
||||
}
|
||||
17
src/components/builder/useLinearRunPrompt.ts
Normal file
17
src/components/builder/useLinearRunPrompt.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
export function useLinearRunPrompt() {
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
async function runPrompt(e: Event) {
|
||||
const isShiftPressed = 'shiftKey' in e && e.shiftKey
|
||||
const commandId = isShiftPressed
|
||||
? 'Comfy.QueuePromptFront'
|
||||
: 'Comfy.QueuePrompt'
|
||||
await commandStore.execute(commandId, {
|
||||
metadata: { subscribe_to_run: false, trigger_source: 'linear' }
|
||||
})
|
||||
}
|
||||
|
||||
return { runPrompt }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import { groupItemKey, inputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
/** Determine if cursor is in the top or bottom half of the element. */
|
||||
@@ -9,22 +10,36 @@ function getEdge(el: HTMLElement, clientY: number): 'before' | 'after' {
|
||||
return clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
function clearIndicator(el: HTMLElement) {
|
||||
el.classList.remove('reorder-before', 'reorder-after')
|
||||
/** Three-zone detection: top third = before, center = pair, bottom third = after. */
|
||||
export function getEdgeTriZone(
|
||||
el: HTMLElement,
|
||||
clientY: number
|
||||
): 'before' | 'center' | 'after' {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const third = rect.height / 3
|
||||
if (clientY < rect.top + third) return 'before'
|
||||
if (clientY > rect.top + third * 2) return 'after'
|
||||
return 'center'
|
||||
}
|
||||
|
||||
function setIndicator(el: HTMLElement, edge: 'before' | 'after') {
|
||||
function clearIndicator(el: HTMLElement) {
|
||||
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
|
||||
}
|
||||
|
||||
function setIndicator(el: HTMLElement, edge: 'before' | 'after' | 'center') {
|
||||
clearIndicator(el)
|
||||
el.classList.add(`reorder-${edge}`)
|
||||
if (edge === 'center') el.classList.add('pair-indicator')
|
||||
else el.classList.add(`reorder-${edge}`)
|
||||
}
|
||||
|
||||
/** Extract item key from drag data. */
|
||||
function getDragKey(data: Record<string | symbol, unknown>): string | null {
|
||||
if (data.type === 'zone-widget')
|
||||
return `input:${data.nodeId}:${data.widgetName}`
|
||||
return inputItemKey(data.nodeId as string, data.widgetName as string)
|
||||
if (data.type === 'zone-output') return `output:${data.nodeId}`
|
||||
if (data.type === 'zone-run-controls') return 'run-controls'
|
||||
if (data.type === 'zone-preset-strip') return 'preset-strip'
|
||||
if (data.type === 'zone-group') return groupItemKey(data.groupId as string)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -32,6 +47,11 @@ function getDragZone(data: Record<string | symbol, unknown>): string | null {
|
||||
return (data.sourceZone as string) ?? null
|
||||
}
|
||||
|
||||
/** Both keys are input widgets — eligible for center-drop pairing. */
|
||||
function canPairKeys(a: string, b: string): boolean {
|
||||
return a.startsWith('input:') && b.startsWith('input:')
|
||||
}
|
||||
|
||||
// --- Unified reorder drop target ---
|
||||
|
||||
interface ZoneItemReorderBinding {
|
||||
@@ -39,8 +59,6 @@ interface ZoneItemReorderBinding {
|
||||
itemKey: string
|
||||
/** The zone this item belongs to. */
|
||||
zone: string
|
||||
/** The current ordered list of item keys for this zone. */
|
||||
order: string[]
|
||||
}
|
||||
|
||||
type ReorderEl = HTMLElement & {
|
||||
@@ -51,6 +69,7 @@ type ReorderEl = HTMLElement & {
|
||||
/**
|
||||
* Unified reorder directive — any zone item (input, output, run controls)
|
||||
* can be reordered relative to any other item in the same zone.
|
||||
* When two input widgets are involved, center-drop creates a paired group.
|
||||
*/
|
||||
export const vZoneItemReorderTarget: Directive<
|
||||
HTMLElement,
|
||||
@@ -67,31 +86,61 @@ export const vZoneItemReorderTarget: Directive<
|
||||
const dragKey = getDragKey(source.data)
|
||||
const dragZone = getDragZone(source.data)
|
||||
if (!dragKey || !dragZone) return false
|
||||
// Same zone, different item
|
||||
// Same zone or from a group, different item
|
||||
return (
|
||||
dragZone === typedEl.__reorderValue!.zone &&
|
||||
(dragZone === typedEl.__reorderValue!.zone ||
|
||||
dragZone === '__group__') &&
|
||||
dragKey !== typedEl.__reorderValue!.itemKey
|
||||
)
|
||||
},
|
||||
onDrag: ({ location }) => {
|
||||
setIndicator(el, getEdge(el, location.current.input.clientY))
|
||||
onDrag: ({ location, source }) => {
|
||||
const dragKey = getDragKey(source.data)
|
||||
const targetKey = typedEl.__reorderValue!.itemKey
|
||||
const pairingAllowed = dragKey && canPairKeys(dragKey, targetKey)
|
||||
const edge = pairingAllowed
|
||||
? getEdgeTriZone(el, location.current.input.clientY)
|
||||
: getEdge(el, location.current.input.clientY)
|
||||
setIndicator(el, edge)
|
||||
},
|
||||
onDragEnter: ({ location }) => {
|
||||
setIndicator(el, getEdge(el, location.current.input.clientY))
|
||||
onDragEnter: ({ location, source }) => {
|
||||
const dragKey = getDragKey(source.data)
|
||||
const targetKey = typedEl.__reorderValue!.itemKey
|
||||
const pairingAllowed = dragKey && canPairKeys(dragKey, targetKey)
|
||||
const edge = pairingAllowed
|
||||
? getEdgeTriZone(el, location.current.input.clientY)
|
||||
: getEdge(el, location.current.input.clientY)
|
||||
setIndicator(el, edge)
|
||||
},
|
||||
onDragLeave: () => clearIndicator(el),
|
||||
onDrop: ({ source, location }) => {
|
||||
onDrop: ({ source, location, self }) => {
|
||||
clearIndicator(el)
|
||||
// Skip if a nested drop target (e.g. group body) is the innermost target
|
||||
const innermost = location.current.dropTargets[0]
|
||||
if (innermost && innermost.element !== self.element) return
|
||||
|
||||
const dragKey = getDragKey(source.data)
|
||||
if (!dragKey) return
|
||||
const edge = getEdge(el, location.current.input.clientY)
|
||||
appModeStore.reorderZoneItem(
|
||||
typedEl.__reorderValue!.zone,
|
||||
dragKey,
|
||||
typedEl.__reorderValue!.itemKey,
|
||||
edge,
|
||||
typedEl.__reorderValue!.order
|
||||
)
|
||||
|
||||
const { zone, itemKey } = typedEl.__reorderValue!
|
||||
const pairingAllowed = canPairKeys(dragKey, itemKey)
|
||||
const edge = pairingAllowed
|
||||
? getEdgeTriZone(el, location.current.input.clientY)
|
||||
: getEdge(el, location.current.input.clientY)
|
||||
|
||||
if (edge === 'center') {
|
||||
appModeStore.moveWidgetItem(dragKey, {
|
||||
kind: 'zone-pair',
|
||||
zoneId: zone,
|
||||
targetKey: itemKey
|
||||
})
|
||||
} else {
|
||||
appModeStore.moveWidgetItem(dragKey, {
|
||||
kind: 'zone-relative',
|
||||
zoneId: zone,
|
||||
targetKey: itemKey,
|
||||
edge
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
|
||||
import type { Directive } from 'vue'
|
||||
|
||||
import { inputItemKey } from '@/components/builder/itemKeyHelper'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { OUTPUT_ZONE_KEY } from '@/components/builder/useZoneWidgets'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
interface WidgetDragData {
|
||||
@@ -15,12 +15,6 @@ interface WidgetDragData {
|
||||
sourceZone: string
|
||||
}
|
||||
|
||||
interface OutputDragData {
|
||||
type: 'zone-output'
|
||||
nodeId: NodeId
|
||||
sourceZone: string
|
||||
}
|
||||
|
||||
interface RunControlsDragData {
|
||||
type: 'zone-run-controls'
|
||||
sourceZone: string
|
||||
@@ -37,12 +31,6 @@ function isWidgetDragData(
|
||||
return data.type === 'zone-widget'
|
||||
}
|
||||
|
||||
function isOutputDragData(
|
||||
data: Record<string | symbol, unknown>
|
||||
): data is Record<string | symbol, unknown> & OutputDragData {
|
||||
return data.type === 'zone-output'
|
||||
}
|
||||
|
||||
function isRunControlsDragData(
|
||||
data: Record<string | symbol, unknown>
|
||||
): data is Record<string | symbol, unknown> & RunControlsDragData {
|
||||
@@ -55,28 +43,30 @@ function isPresetStripDragData(
|
||||
return data.type === 'zone-preset-strip'
|
||||
}
|
||||
|
||||
interface GroupDragData {
|
||||
type: 'zone-group'
|
||||
groupId: string
|
||||
sourceZone: string
|
||||
}
|
||||
|
||||
function isGroupDragData(
|
||||
data: Record<string | symbol, unknown>
|
||||
): data is Record<string | symbol, unknown> & GroupDragData {
|
||||
return data.type === 'zone-group'
|
||||
}
|
||||
|
||||
interface DragBindingValue {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
zone: string
|
||||
}
|
||||
|
||||
interface OutputDragBindingValue {
|
||||
nodeId: NodeId
|
||||
zone: string
|
||||
}
|
||||
|
||||
type DragEl = HTMLElement & {
|
||||
__dragCleanup?: () => void
|
||||
__dragValue?: DragBindingValue
|
||||
__zoneId?: string
|
||||
}
|
||||
|
||||
type OutputDragEl = HTMLElement & {
|
||||
__dragCleanup?: () => void
|
||||
__dragValue?: OutputDragBindingValue
|
||||
}
|
||||
|
||||
export const vWidgetDraggable: Directive<HTMLElement, DragBindingValue> = {
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as DragEl
|
||||
@@ -99,78 +89,6 @@ export const vWidgetDraggable: Directive<HTMLElement, DragBindingValue> = {
|
||||
}
|
||||
}
|
||||
|
||||
export const vOutputDraggable: Directive<HTMLElement, OutputDragBindingValue> =
|
||||
{
|
||||
mounted(el, { value }) {
|
||||
const typedEl = el as OutputDragEl
|
||||
typedEl.__dragValue = value
|
||||
typedEl.__dragCleanup = draggable({
|
||||
element: el,
|
||||
getInitialData: () => ({
|
||||
type: 'zone-output',
|
||||
nodeId: typedEl.__dragValue!.nodeId,
|
||||
sourceZone: typedEl.__dragValue!.zone
|
||||
})
|
||||
})
|
||||
},
|
||||
updated(el, { value }) {
|
||||
;(el as OutputDragEl).__dragValue = value
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as OutputDragEl).__dragCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
type RunControlsDragEl = HTMLElement & {
|
||||
__dragCleanup?: () => void
|
||||
__sourceZone?: string
|
||||
}
|
||||
|
||||
export const vRunControlsDraggable: Directive<HTMLElement, string> = {
|
||||
mounted(el, { value: sourceZone }) {
|
||||
const typedEl = el as RunControlsDragEl
|
||||
typedEl.__sourceZone = sourceZone
|
||||
typedEl.__dragCleanup = draggable({
|
||||
element: el,
|
||||
getInitialData: () => ({
|
||||
type: 'zone-run-controls',
|
||||
sourceZone: typedEl.__sourceZone!
|
||||
})
|
||||
})
|
||||
},
|
||||
updated(el, { value: sourceZone }) {
|
||||
;(el as RunControlsDragEl).__sourceZone = sourceZone
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as RunControlsDragEl).__dragCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
type PresetStripDragEl = HTMLElement & {
|
||||
__dragCleanup?: () => void
|
||||
__sourceZone?: string
|
||||
}
|
||||
|
||||
export const vPresetStripDraggable: Directive<HTMLElement, string> = {
|
||||
mounted(el, { value: sourceZone }) {
|
||||
const typedEl = el as PresetStripDragEl
|
||||
typedEl.__sourceZone = sourceZone
|
||||
typedEl.__dragCleanup = draggable({
|
||||
element: el,
|
||||
getInitialData: () => ({
|
||||
type: 'zone-preset-strip',
|
||||
sourceZone: typedEl.__sourceZone!
|
||||
})
|
||||
})
|
||||
},
|
||||
updated(el, { value: sourceZone }) {
|
||||
;(el as PresetStripDragEl).__sourceZone = sourceZone
|
||||
},
|
||||
unmounted(el) {
|
||||
;(el as PresetStripDragEl).__dragCleanup?.()
|
||||
}
|
||||
}
|
||||
|
||||
export const vZoneDropTarget: Directive<HTMLElement, string> = {
|
||||
mounted(el, { value: zoneId }) {
|
||||
const typedEl = el as DragEl
|
||||
@@ -181,26 +99,39 @@ export const vZoneDropTarget: Directive<HTMLElement, string> = {
|
||||
canDrop: ({ source }) => {
|
||||
const data = source.data
|
||||
if (isWidgetDragData(data)) return data.sourceZone !== typedEl.__zoneId
|
||||
if (isOutputDragData(data)) return data.sourceZone !== typedEl.__zoneId
|
||||
if (isRunControlsDragData(data))
|
||||
return data.sourceZone !== typedEl.__zoneId
|
||||
if (isPresetStripDragData(data))
|
||||
return data.sourceZone !== typedEl.__zoneId
|
||||
if (isGroupDragData(data)) return data.sourceZone !== typedEl.__zoneId
|
||||
return false
|
||||
},
|
||||
onDragEnter: () => el.classList.add('zone-drag-over'),
|
||||
onDragLeave: () => el.classList.remove('zone-drag-over'),
|
||||
onDrop: ({ source }) => {
|
||||
onDrop: ({ source, location, self }) => {
|
||||
el.classList.remove('zone-drag-over')
|
||||
// Skip if a nested drop target (e.g. group body) is the innermost target
|
||||
const innermost = location.current.dropTargets[0]
|
||||
if (innermost && innermost.element !== self.element) return
|
||||
|
||||
const data = source.data
|
||||
if (isWidgetDragData(data)) {
|
||||
const itemKey = inputItemKey(data.nodeId, data.widgetName)
|
||||
appModeStore.moveWidgetItem(itemKey, {
|
||||
kind: 'zone',
|
||||
zoneId: typedEl.__zoneId!
|
||||
})
|
||||
appModeStore.setZone(data.nodeId, data.widgetName, typedEl.__zoneId!)
|
||||
} else if (isOutputDragData(data)) {
|
||||
appModeStore.setZone(data.nodeId, OUTPUT_ZONE_KEY, typedEl.__zoneId!)
|
||||
} else if (isRunControlsDragData(data)) {
|
||||
appModeStore.setRunControlsZone(typedEl.__zoneId!)
|
||||
} else if (isPresetStripDragData(data)) {
|
||||
appModeStore.setPresetStripZone(typedEl.__zoneId!)
|
||||
} else if (isGroupDragData(data)) {
|
||||
appModeStore.moveGroupToZone(
|
||||
data.groupId,
|
||||
data.sourceZone,
|
||||
typedEl.__zoneId!
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { DropIndicatorData } from '@/components/builder/dropIndicatorUtil'
|
||||
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
|
||||
import { getTemplate } from '@/components/builder/layoutTemplates'
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
|
||||
export const OUTPUT_ZONE_KEY = '__output__'
|
||||
|
||||
export interface ResolvedArrangeWidget {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
@@ -27,13 +13,6 @@ export interface ResolvedArrangeWidget {
|
||||
widget: IBaseWidget
|
||||
}
|
||||
|
||||
export interface EnrichedNodeData extends VueNodeData {
|
||||
hasErrors: boolean
|
||||
dropIndicator?: DropIndicatorData
|
||||
onDragDrop?: LGraphNode['onDragDrop']
|
||||
onDragOver?: LGraphNode['onDragOver']
|
||||
}
|
||||
|
||||
export function inputsForZone(
|
||||
selectedInputs: [NodeId, string][],
|
||||
getZone: (nodeId: NodeId, widgetName: string) => string | undefined,
|
||||
@@ -48,21 +27,19 @@ export function inputsForZone(
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for ArrangeLayout (builder/arrange mode).
|
||||
* Composable for builder arrange mode.
|
||||
* Returns a computed Map<zoneId, resolved widget items[]>.
|
||||
*/
|
||||
export function useArrangeZoneWidgets() {
|
||||
const appModeStore = useAppModeStore()
|
||||
|
||||
const template = computed(
|
||||
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
|
||||
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('single')!
|
||||
)
|
||||
|
||||
return computed(() => {
|
||||
const map = new Map<string, ResolvedArrangeWidget[]>()
|
||||
const defaultZoneId =
|
||||
template.value.zones.find((z) => !z.isOutput)?.id ??
|
||||
template.value.zones[0]?.id
|
||||
const defaultZoneId = template.value.zones[0]?.id
|
||||
|
||||
for (const zone of template.value.zones) {
|
||||
const inputs = inputsForZone(
|
||||
@@ -83,116 +60,3 @@ export function useArrangeZoneWidgets() {
|
||||
return map
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for AppTemplateView (app runtime mode).
|
||||
* Returns a computed Map<zoneId, enriched VueNodeData[]>.
|
||||
*
|
||||
* Uses Map<LGraphNode, Widget[]> grouping instead of takeWhile,
|
||||
* so non-contiguous inputs for the same node are correctly merged.
|
||||
*/
|
||||
export function useAppZoneWidgets() {
|
||||
const appModeStore = useAppModeStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
const { t } = useI18n()
|
||||
|
||||
const template = computed(
|
||||
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
|
||||
)
|
||||
|
||||
const dropIndicatorOptions = computed(() => ({
|
||||
imageLabel: t('linearMode.dragAndDropImage'),
|
||||
videoLabel: t('linearMode.dragAndDropVideo'),
|
||||
openMaskEditor: maskEditor.openMaskEditor
|
||||
}))
|
||||
|
||||
return computed(() => {
|
||||
const map = new Map<string, EnrichedNodeData[]>()
|
||||
const defaultZoneId =
|
||||
template.value.zones.find((z) => !z.isOutput)?.id ??
|
||||
template.value.zones[0]?.id
|
||||
|
||||
for (const zone of template.value.zones) {
|
||||
const inputs = inputsForZone(
|
||||
appModeStore.selectedInputs,
|
||||
appModeStore.getZone,
|
||||
zone.id,
|
||||
defaultZoneId
|
||||
)
|
||||
map.set(
|
||||
zone.id,
|
||||
resolveAppWidgets(
|
||||
inputs,
|
||||
executionErrorStore,
|
||||
dropIndicatorOptions.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve inputs into enriched VueNodeData grouped by node.
|
||||
* Uses Map-based grouping (fixes takeWhile non-contiguous bug).
|
||||
* Filters out nodes with mode !== ALWAYS.
|
||||
*/
|
||||
function resolveAppWidgets(
|
||||
inputs: [NodeId, string][],
|
||||
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
|
||||
dropIndicatorOptions?: Parameters<typeof buildDropIndicator>[1]
|
||||
): EnrichedNodeData[] {
|
||||
const nodeWidgetMap = new Map<LGraphNode, IBaseWidget[]>()
|
||||
|
||||
for (const [nodeId, widgetName] of inputs) {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) continue
|
||||
if (!nodeWidgetMap.has(node)) nodeWidgetMap.set(node, [])
|
||||
nodeWidgetMap.get(node)!.push(widget)
|
||||
}
|
||||
|
||||
const result: EnrichedNodeData[] = []
|
||||
|
||||
for (const [node, inputGroup] of nodeWidgetMap) {
|
||||
if (node.mode !== LGraphEventMode.ALWAYS) continue
|
||||
|
||||
const nodeData = extractVueNodeData(node)
|
||||
const enriched: EnrichedNodeData = {
|
||||
...nodeData,
|
||||
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
|
||||
dropIndicator: dropIndicatorOptions
|
||||
? buildDropIndicator(node, dropIndicatorOptions)
|
||||
: undefined,
|
||||
onDragDrop: node.onDragDrop,
|
||||
onDragOver: node.onDragOver
|
||||
}
|
||||
|
||||
const filteredWidgets = (enriched.widgets ?? []).filter(
|
||||
(vueWidget: SafeWidgetData) => {
|
||||
if (vueWidget.slotMetadata?.linked) return false
|
||||
if (!node.isSubgraphNode())
|
||||
return inputGroup.some((w) => w.name === vueWidget.name)
|
||||
|
||||
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
|
||||
return inputGroup.some(
|
||||
(subWidget) =>
|
||||
isPromotedWidgetView(subWidget) &&
|
||||
subWidget.sourceNodeId === storeNodeId &&
|
||||
subWidget.sourceWidgetName === vueWidget.storeName
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const updatedWidgets = filteredWidgets.map((widget) => ({
|
||||
...widget,
|
||||
slotMetadata: undefined,
|
||||
nodeId: String(node.id)
|
||||
}))
|
||||
|
||||
result.push({ ...enriched, widgets: updatedWidgets })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user