Compare commits

...

22 Commits

Author SHA1 Message Date
Koshi
2859343b66 fix: update rename tests for restructured app mode UI
Replace preview sidebar and app mode rename tests with a single
test that renames via builder inputs step, matching the actual
UI flow in the new SidebarAppLayout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:31:41 +02:00
Koshi
562e1c3216 fix: restore output filtering and fix test failures
- Remove useEventListener mock that blocked configured event test
- Restore filterByOutputNodes in useOutputHistory (accidentally removed)
- Add missing nextTick in checkState deselect test
- Fix oxlint disable comment syntax for perfReporter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 00:03:32 +02:00
GitHub Action
535e7c6ea4 [automated] Apply ESLint and Oxfmt fixes 2026-03-29 20:27:51 +00:00
Koshi
9aa3b5604b fix: restore selectedOutputs filter and fix oxlint console error
- Restore output node filtering in linearOutputStore that was
  accidentally removed, fixing the skips-unselected-outputs test
- Fix oxlint no-console error in perfReporter with proper disable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:24:04 +02:00
Koshi
79fb6b7d0a Merge branch 'main' into feat/app-mode-layouts
Take main's refactored test helpers from #10680. Fix pre-existing
oxlint errors in main's perfReporter and GtmTelemetryProvider test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:06:28 +02:00
Koshi
78891e406b Merge branch 'main' into feat/app-mode-layouts
Resolve conflicts from main's save flow rework (#10439).
Fix BuilderToolbar setDefaultView type errors by mapping to
builder:arrange step.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:16:30 +01:00
Koshi
491fb9bf01 fix: guard configured event to prevent state wipe during editing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:10:52 +01:00
Koshi
5c07cc8548 fix: update e2e tests, restore float step, align resolution text, remove input colors
- Add data-testid="linear-widgets" to SidebarAppLayout
- Update AppModeHelper.renameWidget for inline input
- Restore main's precision-based stepValue in WidgetInputNumberInput
- Align video resolution text to bottom-right matching images
- Remove unused inputColors from store and widget list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:32:32 +01:00
Koshi
067e778e8a Merge branch 'main' into feat/app-mode-layouts
Resolve conflicts keeping branch app-mode changes for store,
tests, and linear-mode previews; take main for knip config and
clipspace cleanup. Add missing imports for main's VueNode switch
and promoted widget features.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:20:58 +01:00
Koshi
accc91bb20 fix: remove unused files, exports, and knip config entries
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 17:40:08 +01:00
Koshi
a2ea5a9a9c test: browser tests for app mode layouts
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 17:35:53 +01:00
Koshi
8d95cd8bbc chore: storybook stories and peripheral updates
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 17:33:44 +01:00
Koshi
b40fb33e7a feat: linear mode views, mobile display, and UI components
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 17:33:05 +01:00
Koshi
ec3c7bd8fe feat: output grid and media preview improvements
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 17:32:24 +01:00
Koshi
07e3b92266 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>
2026-03-27 17:31:43 +01:00
Koshi
6c3f4a6d99 feat: app mode store — input groups, zone items, and widget ordering
Amp-Thread-ID: https://ampcode.com/threads/T-019d3012-26c4-70ff-984c-913b12217454
Co-authored-by: Amp <amp@ampcode.com>
2026-03-27 17:31:06 +01:00
Koshi
7c3b75534b Refactor grid math; fix resize and output zones
- Extract GRID_PADDING_PX and GRID_GAP_PX constants in LayoutZoneGrid.vue, use them in column/row handle calc() positions instead
   of hardcoded 12, 24, 6 values
- Fix filledZones check to use filledZones?.has(zone.id) (optional chaining guard)
- Add bounds guard in ZoneResizeHandle.vue onPointerDown to prevent invalid index access
- In AppTemplateView, prefer template default run-controls/preset-strip zone IDs when store values are absent
- Pass outputNodeId for output render items and rework output rendering to match outputs by nodeId
- Show empty state when no outputs exist for an output item
- Move ImageLightbox markup for correct placement in DropZone.vue
2026-03-21 03:07:30 +01:00
Koshi
4b74c0182a Add ImageLightbox and preview dblclick handling
Introduce an ImageLightbox and wire double-click preview behavior across the linear mode UI. AppTemplateView: import ImageLightbox, add selectedOutput prop, refactor zoneOutputs into liveZoneOutputs and a new zoneOutputs that injects a selected history item into the output zone, add lightbox state/openLightbox handler, wrap MediaOutputPreview in buttons to support @dblclick to open the lightbox, and render the lightbox component. LinearPreview: import ImageLightbox, add lightbox state, forward open-lightbox events from children to open the lightbox, and adjust conditional rendering to pass selectedOutput into AppTemplateView. OutputHistory: add an openLightbox emit and emit it on dblclick of a history item. Also add a ResultItemImpl type import and small UI/class tweaks to accommodate the new behavior. These changes let users open outputs in a full lightbox and preview a selected history item in the template's output zone.
2026-03-21 02:31:26 +01:00
Koshi
2eb2514d25 fix: route unassigned widgets to default zone and fix CI test failures
Widgets without explicit zone assignments now fall back to the first
non-output zone, fixing empty app mode when no builder layout is saved.
Also fixes CodeRabbit findings: aria-label, splice index, pointercancel,
duplicate i18n key, and subgraph widget matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 00:22:24 +01:00
Koshi
6fb8004829 Add presets UI, drop indicators & zone drag/drop
Features:

  - Preset system: PresetStrip with tabs/buttons/menu display modes, save/overwrite/rename/delete
  - Draggable presets between zones with within-zone reordering
  - Presets toggle (eye icon) in builder inputs sidebar
  - LoadImage/LoadVideo drop zones with image/video preview in app mode
  - Persistent mask editor button below image preview
  - Default run controls in bottom-right zone per template
  - Default preset strip in top-right zone per template
  - Zone align toggle with smooth 300ms rotation animation
  - Reka UI Tooltip component with Storybook story
  - Welcome screen when no inputs/outputs selected

  Fixes:

  - Widget deduplication — render each node once per zone
  - Zone overflow scrolling with full rounded borders
  - Resize handle clamping at MIN_FR=0.25
  - Toast alert on queue failure (was silent console.error)
  - i18n for all tooltips and labels
  - Float +/- buttons work on all value ranges
  - Improved drag sensitivity for float widgets
  - data-testid shims for E2E backward compatibility
  - E2E tests updated for zone-based layout
  - Removed unused handleDragDrop/widgetListRef from LinearControls
2026-03-20 23:47:53 +01:00
Koshi
2a788f1f52 Merge branch 'main' into feat/app-mode-layouts 2026-03-20 23:12:50 +01:00
Koshi
3d0a145061 Add layout templates, arrange UI & zone drag/drop
Introduce a new arrange/builder layout system: add layoutTemplates, an ArrangeLayout view, LayoutZoneGrid, LayoutTemplateSelector and ZoneResizeHandle components to render configurable grid templates and support live resizing. Implement drag/drop and reorder behavior with new directives/composables (useZoneDrop, useZoneReorder, useWidgetReorder) plus a useZoneWidgets composable and unit tests. Enable dragging of widgets, outputs and run-controls between zones, zone reordering, and item-level reordering; persist grid overrides via appModeStore. Also update AppModeWidgetList to adjust removal logic, and apply small related changes to linear-mode renderer, stores, litegraph utilities and locale/test files to integrate the new features.
2026-03-20 03:49:59 +01:00
64 changed files with 6581 additions and 406 deletions

View File

@@ -49,12 +49,12 @@ onUnmounted(() => {
<template>
<nav
class="fixed top-0 left-0 right-0 z-50 bg-black/80 backdrop-blur-md"
class="fixed inset-x-0 top-0 z-50 bg-black/80 backdrop-blur-md"
aria-label="Main navigation"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<!-- Logo -->
<a href="/" class="text-2xl font-bold italic text-brand-yellow">
<a href="/" class="text-2xl font-bold text-brand-yellow italic">
Comfy
</a>
@@ -77,8 +77,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
"
class="rounded-full px-5 py-2 text-sm font-semibold"
>
@@ -135,8 +135,8 @@ onUnmounted(() => {
:href="cta.href"
:class="
cta.primary
? 'bg-brand-yellow text-black hover:opacity-90 transition-opacity'
: 'border border-brand-yellow text-brand-yellow hover:bg-brand-yellow hover:text-black transition-colors'
? 'bg-brand-yellow text-black transition-opacity hover:opacity-90'
: 'border border-brand-yellow text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black'
"
class="rounded-full px-5 py-2 text-center text-sm font-semibold"
>

View File

@@ -41,6 +41,8 @@ export function logMeasurement(
if (formatter) return formatter(m)
return `${f}=${m[f]}`
})
// oxlint-disable-next-line no-console -- perf reporter intentionally logs
console.log(`${label}: ${parts.join(', ')}`)
}

View File

@@ -85,11 +85,13 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
timeout: 5000
})
// Scroll to bottom so the codec widget is at the clipping edge
// Scroll to bottom so the codec widget is at the clipping edge.
// In the zone layout, overflow-y-auto is on the inner zone div.
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
await widgetList.evaluate((el) => {
const scrollable = el.querySelector('[class*="overflow-y"]') ?? el
scrollable.scrollTo({ top: scrollable.scrollHeight, behavior: 'instant' })
})
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
@@ -129,11 +131,13 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
timeout: 5000
})
// Scroll to bottom so the image widget is at the clipping edge
// Scroll to bottom so the image widget is at the clipping edge.
// In the zone layout, overflow-y-auto is on the inner zone div.
const widgetList = comfyPage.appMode.linearWidgets
await widgetList.evaluate((el) =>
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
)
await widgetList.evaluate((el) => {
const scrollable = el.querySelector('[class*="overflow-y"]') ?? el
scrollable.scrollTo({ top: scrollable.scrollHeight, behavior: 'instant' })
})
// Click the FormDropdown trigger button for the image widget.
// The button emits 'select-click' which toggles the Popover.

View File

@@ -64,38 +64,21 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
})
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
test('Rename persists in app mode after save/reload', async ({
comfyPage
}) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.steps.goToPreview()
// Rename via builder inputs step (app mode view has no inline rename)
await appMode.steps.goToInputs()
await appMode.select.renameInputViaMenu('seed', 'App Mode Seed')
const menu = appMode.select.getPreviewWidgetMenu('seed — New Subgraph')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.select.renameWidget(menu, 'Preview Seed')
// Verify in app mode after save/reload
await appMode.footer.exitBuilder()
const workflowName = `${new Date().getTime()} builder-preview`
await saveAndReopenInAppMode(comfyPage, workflowName)
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
await expect(appMode.linearWidgets.getByText('Preview Seed')).toBeVisible()
})
test('Rename from app mode', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
// Enter app mode from builder
// Exit builder and enter app mode
await appMode.footer.exitBuilder()
await appMode.toggleAppMode()
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
const menu = appMode.getAppModeWidgetMenu('seed')
await appMode.select.renameWidget(menu, 'App Mode Seed')
await expect(appMode.linearWidgets.getByText('App Mode Seed')).toBeVisible()
// Verify persistence after save/reload

View File

@@ -1,3 +1,5 @@
import type { Page } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
@@ -9,10 +11,58 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
await comfyPage.setup()
})
async function enterAppMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
// LinearControls requires hasOutputs to be true. Serialize the current
// graph, inject linearData with output node IDs, then reload so the
// appModeStore picks up the outputs via its activeWorkflow watcher.
await comfyPage.page.evaluate(async () => {
const graph = window.app!.graph
if (!graph) return
const outputNodeIds = graph.nodes
.filter(
(n: { type?: string }) =>
n.type === 'SaveImage' || n.type === 'PreviewImage'
)
.map((n: { id: number | string }) => String(n.id))
// Serialize, inject linearData, and reload to sync stores
const workflow = graph.serialize() as unknown as Record<string, unknown>
const extra = (workflow.extra ?? {}) as Record<string, unknown>
extra.linearData = { inputs: [], outputs: outputNodeIds }
workflow.extra = extra
await window.app!.loadGraphData(
workflow as unknown as Parameters<
NonNullable<typeof window.app>['loadGraphData']
>[0]
)
})
await comfyPage.nextFrame()
// Toggle to app mode via the command which sets canvasStore.linearMode
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
async function enterGraphMode(comfyPage: {
page: Page
nextFrame: () => Promise<void>
}) {
await comfyPage.page.evaluate(() => {
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
})
await comfyPage.nextFrame()
}
test('Displays linear controls when app mode active', async ({
comfyPage
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
@@ -20,29 +70,29 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible({ timeout: 5000 })
})
test('Workflow info section visible', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
test('Run controls visible in app mode', async ({ comfyPage }) => {
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible({ timeout: 5000 })
})
test('Returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')
).toBeVisible({ timeout: 5000 })
await comfyPage.appMode.toggleAppMode()
await enterGraphMode(comfyPage)
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
await expect(
@@ -51,7 +101,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
})
test('Canvas not visible in app mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await enterAppMode(comfyPage)
await expect(
comfyPage.page.locator('[data-testid="linear-widgets"]')

View File

@@ -102,7 +102,8 @@ export default defineConfig([
projectService: {
allowDefaultProject: [
'vite.electron.config.mts',
'vite.types.config.mts'
'vite.types.config.mts',
'apps/website/astro.config.ts'
]
}
}

View File

@@ -40,7 +40,7 @@ const config: KnipConfig = {
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
}
},
ignoreBinaries: ['python3'],
ignoreBinaries: ['python3', 'gh'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',

View File

@@ -1,5 +1,35 @@
@import '@comfyorg/design-system/css/style.css';
/* PrimeVue tooltip override — white on black, consistent everywhere. */
.p-tooltip .p-tooltip-text {
background: #000;
color: #fff;
border: 1px solid #333;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.25;
max-width: 18.75rem;
box-shadow: none;
}
.p-tooltip-top .p-tooltip-arrow {
border-top-color: #000;
}
.p-tooltip-bottom .p-tooltip-arrow {
border-bottom-color: #000;
}
.p-tooltip-left .p-tooltip-arrow {
border-left-color: #000;
}
.p-tooltip-right .p-tooltip-arrow {
border-right-color: #000;
}
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */
.disable-animations *,

View File

@@ -46,7 +46,7 @@ function showApps() {
</script>
<template>
<div class="pointer-events-auto flex flex-row items-start gap-2">
<div class="pointer-events-auto flex w-fit flex-row items-start gap-2">
<div class="pointer-events-auto flex flex-col gap-2">
<Button
v-if="enableAppBuilder"

View File

@@ -3,8 +3,7 @@ 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'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
@@ -12,19 +11,14 @@ 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 { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
@@ -34,9 +28,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()
@@ -47,13 +50,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<
@@ -61,70 +112,42 @@ 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) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
const imageUrl = buildImageUrl()
return {
iconClass: 'icon-[lucide--image]',
imageUrl,
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit: imageUrl ? () => maskEditor.openMaskEditor(node) : undefined
}
return buildDropIndicator(node, {
imageLabel: mobile ? undefined : t('linearMode.dragAndDropImage'),
videoLabel: mobile ? undefined : t('linearMode.dragAndDropVideo'),
openMaskEditor: maskEditor.openMaskEditor
})
}
function nodeToNodeData(node: LGraphNode) {
@@ -139,21 +162,6 @@ function nodeToNodeData(node: LGraphNode) {
onDragOver: node.onDragOver
}
}
async function handleDragDrop(e: DragEvent) {
for (const { nodeData } of mappedSelections.value) {
if (!nodeData?.onDragOver?.(e)) continue
const rawResult = nodeData?.onDragDrop?.(e)
if (rawResult === false) continue
e.stopPropagation()
e.preventDefault()
if ((await rawResult) === true) return
}
}
defineExpose({ handleDragDrop })
</script>
<template>
<div
@@ -174,12 +182,13 @@ defineExpose({ handleDragDrop })
<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 }}
@@ -191,32 +200,6 @@ defineExpose({ handleDragDrop })
{{ 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: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]"
>
<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'"
@@ -239,5 +222,14 @@ defineExpose({ handleDragDrop })
/>
</DropZone>
</div>
<div
v-if="!builderMode"
:class="
cn(
'mx-3 border-b border-border-subtle/30',
key === mappedSelections.at(-1)?.key && 'hidden'
)
"
/>
</div>
</template>

View File

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

View File

@@ -1,10 +1,21 @@
<template>
<nav
class="fixed top-[calc(var(--workflow-tabs-height)+var(--spacing)*1.5)] left-1/2 z-1000 -translate-x-1/2"
ref="toolbarEl"
:class="
cn(
'fixed z-1000 origin-top-left select-none',
isDragging && 'cursor-grabbing'
)
"
:style="{
left: `${position.x}px`,
top: `${position.y}px`,
transform: `scale(${toolbarScale})`
}"
:aria-label="t('builderToolbar.label')"
>
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
class="group inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template v-for="(step, index) in steps" :key="step.id">
<button
@@ -23,21 +34,65 @@
<StepLabel :step />
</button>
<div
v-if="index < steps.length - 1"
class="mx-1 h-px w-4 bg-border-default"
role="separator"
/>
<div class="mx-1 h-px w-4 bg-border-default" role="separator" />
</template>
<!-- Default view -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<button :class="cn(stepClasses, 'bg-transparent opacity-30')">
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
</button>
</ConnectOutputPopover>
<button
v-else
:class="
cn(
stepClasses,
activeStep === 'builder:arrange'
? 'bg-interface-builder-mode-background'
: 'bg-transparent hover:bg-secondary-background'
)
"
@click="navigateToStep('builder:arrange')"
>
<StepBadge
:step="defaultViewStep"
:index="steps.length"
:model-value="activeStep"
/>
<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'
import { cn } from '@/utils/tailwindUtil'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
@@ -45,8 +100,49 @@ import type { BuilderStepId } from './useBuilderSteps'
import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const { activeStep, navigateToStep } = useBuilderSteps()
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'
@@ -71,5 +167,11 @@ const arrangeStep: BuilderToolbarStep<BuilderStepId> = {
icon: 'icon-[lucide--layout-panel-left]'
}
const defaultViewStep: BuilderToolbarStep<string> = {
id: 'setDefaultView',
title: t('builderToolbar.defaultView'),
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

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

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { LayoutTemplateId } from '@/components/builder/layoutTemplates'
import { LAYOUT_TEMPLATES } from '@/components/builder/layoutTemplates'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const selected = defineModel<LayoutTemplateId>({ required: true })
</script>
<template>
<div
class="fixed top-1/2 left-4 z-1000 flex -translate-y-1/2 flex-col gap-1 rounded-2xl border border-border-default bg-base-background p-1.5 shadow-interface"
>
<button
v-for="template in LAYOUT_TEMPLATES"
:key="template.id"
v-tooltip.right="t(template.description)"
:class="
cn(
'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'
)
"
:aria-label="t(template.label)"
:aria-pressed="selected === template.id"
@click="selected = template.id"
>
<i :class="cn(template.icon, 'size-5')" />
</button>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
GridOverride,
LayoutTemplate,
LayoutZone
} from '@/components/builder/layoutTemplates'
import { buildGridTemplate } from '@/components/builder/layoutTemplates'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
const {
template,
highlightedZone,
dashed = true,
gridOverrides
} = defineProps<{
template: LayoutTemplate
highlightedZone?: string
dashed?: boolean
gridOverrides?: GridOverride
/** Extra CSS classes per zone ID, applied to the grid cell div. */
zoneClasses?: Record<string, string>
}>()
defineSlots<{
zone(props: { zone: LayoutZone }): unknown
}>()
const gridStyle = computed(() => {
if (isMobile.value) {
// Stack all zones vertically on mobile
const areas = template.zones.map((z) => `"${z.gridArea}"`).join(' ')
return {
gridTemplate: `${areas} / 1fr`,
gridAutoRows: 'minmax(200px, auto)'
}
}
return { gridTemplate: buildGridTemplate(template, gridOverrides) }
})
</script>
<template>
<!-- Wrapper so handles overlay above zone content (overflow-y-auto creates stacking contexts) -->
<div class="relative size-full overflow-hidden">
<!-- Grid with zones -->
<div class="grid size-full gap-3 overflow-hidden p-3" :style="gridStyle">
<div
v-for="zone in template.zones"
:key="zone.id"
:style="{ gridArea: zone.gridArea }"
:class="
cn(
'relative flex flex-col overflow-y-auto rounded-xl transition-colors',
dashed
? '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
class="flex size-full flex-col items-center justify-center gap-2 p-4 text-sm text-muted-foreground"
>
<i class="icon-[lucide--plus] size-5" />
<span>{{ t('linearMode.arrange.dropHere') }}</span>
<span class="text-xs opacity-60">{{ t(zone.label) }}</span>
</div>
</slot>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PresetMenu from './PresetMenu.vue'
const meta = {
title: 'Builder/PresetMenu',
component: PresetMenu,
tags: ['autodocs'],
parameters: {
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>
/** Default rendering — click to see built-in quick presets (Min/Mid/Max) and saved presets. */
export const Default: Story = {
render: () => ({
components: { PresetMenu },
template: `
<div class="p-8">
<PresetMenu />
</div>
`
})
}
/** In a toolbar context alongside a workflow title. */
export const InToolbar: Story = {
render: () => ({
components: { PresetMenu },
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>
<div class="flex-1" />
<PresetMenu />
</div>
`
})
}
/** On sidebar background — verify contrast against dark sidebar. */
export const OnSidebarBackground: Story = {
parameters: {
backgrounds: { default: 'sidebar' }
},
render: () => ({
components: { PresetMenu },
template: `
<div class="p-8">
<PresetMenu />
</div>
`
})
}
/** Narrow container — verify truncation of long preset names. */
export const Compact: Story = {
render: () => ({
components: { PresetMenu },
template: `
<div class="flex h-10 w-48 items-center rounded-lg border border-border-subtle bg-comfy-menu-bg px-2">
<span class="truncate text-sm font-bold">long_workflow_name.json</span>
<div class="flex-1" />
<PresetMenu />
</div>
`
})
}

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { BUILTIN_PRESET_IDS, useAppPresets } from '@/composables/useAppPresets'
import type { PresetDisplayMode } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useDialogService } from '@/services/dialogService'
import { useAppModeStore } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const { presets, savePreset, deletePreset, applyPreset } = useAppPresets()
const appModeStore = useAppModeStore()
const { presetDisplayMode } = storeToRefs(appModeStore)
const builtinPresets = [
{
id: BUILTIN_PRESET_IDS.min,
label: () => t('linearMode.presets.builtinMin'),
icon: 'icon-[lucide--arrow-down-to-line]'
},
{
id: BUILTIN_PRESET_IDS.mid,
label: () => t('linearMode.presets.builtinMid'),
icon: 'icon-[lucide--minus]'
},
{
id: BUILTIN_PRESET_IDS.max,
label: () => t('linearMode.presets.builtinMax'),
icon: 'icon-[lucide--arrow-up-to-line]'
}
]
const displayModes: { value: PresetDisplayMode; label: () => string }[] = [
{ value: 'tabs', label: () => t('linearMode.presets.displayTabs') },
{ value: 'buttons', label: () => t('linearMode.presets.displayButtons') },
{ value: 'menu', label: () => t('linearMode.presets.displayMenu') }
]
function setDisplayMode(mode: PresetDisplayMode) {
presetDisplayMode.value = mode
appModeStore.persistLinearData()
}
async function handleSave() {
const name = await useDialogService().prompt({
title: t('linearMode.presets.saveTitle'),
message: t('linearMode.presets.saveMessage'),
placeholder: t('linearMode.presets.namePlaceholder')
})
if (name?.trim()) savePreset(name.trim())
}
</script>
<template>
<Popover>
<template #button>
<Button
variant="textonly"
size="sm"
:aria-label="t('linearMode.presets.label')"
:class="
cn(
'gap-1 text-xs text-muted-foreground hover:text-base-foreground',
presets.length > 0 && 'text-base-foreground'
)
"
>
<i class="icon-[lucide--bookmark]" />
{{ t('linearMode.presets.label') }}
</Button>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col">
<!-- Built-in quick presets -->
<div
class="px-3 py-1 text-xs font-medium text-muted-foreground"
v-text="t('linearMode.presets.builtinSection')"
/>
<div class="flex gap-1 px-2 pb-2">
<button
v-for="bp in builtinPresets"
:key="bp.id"
class="flex flex-1 cursor-pointer items-center justify-center gap-1 rounded-md px-2 py-1.5 text-xs hover:bg-secondary-background-hover"
@click="
() => {
applyPreset(bp.id)
close()
}
"
>
<i :class="cn(bp.icon, 'size-3')" />
{{ bp.label() }}
</button>
</div>
<!-- Saved presets -->
<div class="border-t border-border-subtle">
<div
class="px-3 pt-2 pb-1 text-xs font-medium text-muted-foreground"
v-text="t('linearMode.presets.savedSection')"
/>
<div
v-if="presets.length === 0"
class="px-3 py-1.5 text-xs text-muted-foreground"
v-text="t('linearMode.presets.empty')"
/>
<div
v-for="preset in presets"
:key="preset.id"
class="group flex items-center gap-2 rounded-sm px-3 py-1.5 hover:bg-secondary-background-hover"
>
<button
class="flex-1 cursor-pointer truncate text-left text-sm"
@click="
() => {
applyPreset(preset.id)
close()
}
"
v-text="preset.name"
/>
<button
class="hover:text-danger invisible shrink-0 cursor-pointer text-muted-foreground group-hover:visible"
:aria-label="t('g.remove')"
@click="deletePreset(preset.id)"
>
<i class="icon-[lucide--x] size-3.5" />
</button>
</div>
</div>
<!-- Save action -->
<div class="border-t border-border-subtle pt-1">
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-3 py-1.5 text-sm hover:bg-secondary-background-hover"
@click="
() => {
handleSave()
close()
}
"
>
<i class="icon-[lucide--plus] size-3.5" />
{{ t('linearMode.presets.save') }}
</button>
</div>
<!-- Display mode -->
<div class="border-t border-border-subtle pt-1">
<div
class="px-3 py-1 text-xs font-medium text-muted-foreground"
v-text="t('linearMode.presets.displayAs')"
/>
<div class="flex gap-1 px-2 pb-1">
<button
v-for="dm in displayModes"
:key="dm.value"
:class="
cn(
'flex-1 cursor-pointer rounded-md px-2 py-1 text-xs',
presetDisplayMode === dm.value
? 'bg-secondary-background-hover font-medium'
: 'hover:bg-secondary-background-hover'
)
"
@click="setDisplayMode(dm.value)"
v-text="dm.label()"
/>
</div>
</div>
</div>
</template>
</Popover>
</template>

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
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'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { parseImageWidgetValue } from '@/utils/imageUtil'
interface DropIndicatorData {
iconClass: string
imageUrl?: string
videoUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
onDownload?: () => void
onRemove?: () => void
}
/**
* Build a DropZone indicator for LoadImage or LoadVideo nodes.
* Returns undefined for other node types.
*/
export function buildDropIndicator(
node: LGraphNode,
options: {
imageLabel?: string
videoLabel?: string
openMaskEditor?: (node: LGraphNode) => void
}
): DropIndicatorData | undefined {
if (node.type === 'LoadImage') {
return buildImageDropIndicator(node, options)
}
if (node.type === 'LoadVideo') {
return buildVideoDropIndicator(node, options)
}
return undefined
}
function buildImageDropIndicator(
node: LGraphNode,
options: {
imageLabel?: string
openMaskEditor?: (node: LGraphNode) => void
}
): DropIndicatorData {
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const imageUrl = filename
? (() => {
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
})()
: undefined
return {
iconClass: 'icon-[lucide--image]',
imageUrl,
label: options.imageLabel,
onClick: () => node.widgets?.[1]?.callback?.(undefined),
onMaskEdit:
imageUrl && options.openMaskEditor
? () => options.openMaskEditor!(node)
: 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
}
}
function buildVideoDropIndicator(
node: LGraphNode,
options: { videoLabel?: string }
): DropIndicatorData {
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
: { filename: '', subfolder: '', type: 'input' }
const videoUrl = filename
? api.apiURL(`/view?${new URLSearchParams({ filename, subfolder, type })}`)
: undefined
return {
iconClass: 'icon-[lucide--video]',
videoUrl,
label: options.videoLabel,
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,159 @@
export type LayoutTemplateId = 'single' | 'dual'
export interface LayoutZone {
id: string
/** i18n key for the zone label */
label: string
gridArea: string
}
export interface LayoutTemplate {
id: LayoutTemplateId
/** i18n key for the template label */
label: string
/** i18n key for the template description */
description: string
icon: string
gridTemplate: string
zones: LayoutZone[]
/** Zone ID where run controls go by default */
defaultRunControlsZone: string
/** Zone ID where preset strip goes by default */
defaultPresetStripZone: string
}
export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
{
id: 'single',
label: 'linearMode.layout.templates.single',
description: 'linearMode.layout.templates.singleDesc',
icon: 'icon-[lucide--panel-right]',
gridTemplate: `
"main" 1fr
/ 1fr
`,
zones: [
{
id: 'main',
label: 'linearMode.layout.zones.main',
gridArea: 'main'
}
],
defaultRunControlsZone: 'main',
defaultPresetStripZone: 'main'
},
{
id: 'dual',
label: 'linearMode.layout.templates.dual',
description: 'linearMode.layout.templates.dualDesc',
icon: 'icon-[lucide--columns-2]',
gridTemplate: `
"left right" 1fr
/ 1fr 1fr
`,
zones: [
{
id: 'left',
label: 'linearMode.layout.zones.left',
gridArea: 'left'
},
{
id: 'right',
label: 'linearMode.layout.zones.right',
gridArea: 'right'
}
],
defaultRunControlsZone: 'right',
defaultPresetStripZone: 'left'
}
]
export function getTemplate(id: LayoutTemplateId): LayoutTemplate | undefined {
return LAYOUT_TEMPLATES.find((t) => t.id === id)
}
export interface GridOverride {
zoneOrder?: string[]
columnFractions?: number[]
rowFractions?: number[]
}
/**
* Build a CSS grid-template string from a template and optional overrides.
* When overrides are provided, zone order and column/row fractions are adjusted.
* Returns the original gridTemplate if no overrides apply.
*/
export function buildGridTemplate(
template: LayoutTemplate,
overrides?: GridOverride
): string {
if (!overrides) return template.gridTemplate
const { zoneOrder, columnFractions, rowFractions } = overrides
// Parse the template's grid areas to determine row/column structure
const areaLines = template.gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('"'))
if (areaLines.length === 0) return template.gridTemplate
// Extract area names per row and row fractions
const rows = areaLines.map((line) => {
const match = line.match(/"([^"]+)"\s*(.*)/)
if (!match) return { areas: [] as string[], fraction: '1fr' }
const areas = match[1].split(/\s+/)
const fraction = match[2].trim() || '1fr'
return { areas, fraction }
})
// Determine unique column count from first row
const colCount = rows[0]?.areas.length ?? 0
// Apply zone order reordering if provided
let reorderedRows = rows
if (zoneOrder && zoneOrder.length > 0) {
// Build a mapping from old position to new position
const allAreas = rows.flatMap((r) => r.areas)
const uniqueAreas = [...new Set(allAreas)]
const reorderMap = new Map<string, string>()
for (let i = 0; i < Math.min(zoneOrder.length, uniqueAreas.length); i++) {
reorderMap.set(uniqueAreas[i], zoneOrder[i])
}
reorderedRows = rows.map((row) => ({
...row,
areas: row.areas.map((a) => reorderMap.get(a) ?? a)
}))
}
// Build row fraction strings
const rowFrStrs = reorderedRows.map((row, i) => {
if (rowFractions && i < rowFractions.length) {
return `${rowFractions[i]}fr`
}
return row.fraction
})
// Build column fraction string
let colStr: string
if (columnFractions && columnFractions.length === colCount) {
colStr = columnFractions.map((f) => `${f}fr`).join(' ')
} else {
// Extract original column definitions from the "/" line
const slashLine = template.gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
.find((l) => l.startsWith('/'))
colStr = slashLine ? slashLine.substring(1).trim() : '1fr '.repeat(colCount)
}
// Assemble
const areaStrs = reorderedRows.map(
(row, i) => `"${row.areas.join(' ')}" ${rowFrStrs[i]}`
)
return `\n ${areaStrs.join('\n ')}\n / ${colStr}\n `
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
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. */
function getEdge(el: HTMLElement, clientY: number): 'before' | 'after' {
const rect = el.getBoundingClientRect()
return clientY < rect.top + rect.height / 2 ? 'before' : '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 clearIndicator(el: HTMLElement) {
el.classList.remove('reorder-before', 'reorder-after', 'pair-indicator')
}
function setIndicator(el: HTMLElement, edge: 'before' | 'after' | 'center') {
clearIndicator(el)
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 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
}
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 {
/** The item key for this drop target (e.g. "input:5:steps", "output:7", "run-controls"). */
itemKey: string
/** The zone this item belongs to. */
zone: string
}
type ReorderEl = HTMLElement & {
__reorderCleanup?: () => void
__reorderValue?: ZoneItemReorderBinding
}
/**
* 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,
ZoneItemReorderBinding
> = {
mounted(el, { value }) {
const typedEl = el as ReorderEl
typedEl.__reorderValue = value
const appModeStore = useAppModeStore()
typedEl.__reorderCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const dragKey = getDragKey(source.data)
const dragZone = getDragZone(source.data)
if (!dragKey || !dragZone) return false
// Same zone or from a group, different item
return (
(dragZone === typedEl.__reorderValue!.zone ||
dragZone === '__group__') &&
dragKey !== typedEl.__reorderValue!.itemKey
)
},
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, 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, 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 { 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
})
}
}
})
},
updated(el, { value }) {
;(el as ReorderEl).__reorderValue = value
},
unmounted(el) {
;(el as ReorderEl).__reorderCleanup?.()
}
}

View File

@@ -0,0 +1,145 @@
import {
draggable,
dropTargetForElements
} 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 { useAppModeStore } from '@/stores/appModeStore'
interface WidgetDragData {
type: 'zone-widget'
nodeId: NodeId
widgetName: string
sourceZone: string
}
interface RunControlsDragData {
type: 'zone-run-controls'
sourceZone: string
}
interface PresetStripDragData {
type: 'zone-preset-strip'
sourceZone: string
}
function isWidgetDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & WidgetDragData {
return data.type === 'zone-widget'
}
function isRunControlsDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & RunControlsDragData {
return data.type === 'zone-run-controls'
}
function isPresetStripDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & PresetStripDragData {
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
}
type DragEl = HTMLElement & {
__dragCleanup?: () => void
__dragValue?: DragBindingValue
__zoneId?: string
}
export const vWidgetDraggable: Directive<HTMLElement, DragBindingValue> = {
mounted(el, { value }) {
const typedEl = el as DragEl
typedEl.__dragValue = value
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => ({
type: 'zone-widget',
nodeId: typedEl.__dragValue!.nodeId,
widgetName: typedEl.__dragValue!.widgetName,
sourceZone: typedEl.__dragValue!.zone
})
})
},
updated(el, { value }) {
;(el as DragEl).__dragValue = value
},
unmounted(el) {
;(el as DragEl).__dragCleanup?.()
}
}
export const vZoneDropTarget: Directive<HTMLElement, string> = {
mounted(el, { value: zoneId }) {
const typedEl = el as DragEl
typedEl.__zoneId = zoneId
const appModeStore = useAppModeStore()
typedEl.__dragCleanup = dropTargetForElements({
element: el,
canDrop: ({ source }) => {
const data = source.data
if (isWidgetDragData(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, 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 (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!
)
}
}
})
},
updated(el, { value: zoneId }) {
;(el as DragEl).__zoneId = zoneId
},
unmounted(el) {
;(el as DragEl).__dragCleanup?.()
}
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
vi.mock('@/composables/graph/useGraphNodeManager', () => ({
extractVueNodeData: vi.fn()
}))
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
isPromotedWidgetView: vi.fn()
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', async (importOriginal) => ({
...(await importOriginal()),
LGraphEventMode: { ALWAYS: 0 }
}))
vi.mock('@/utils/litegraphUtil', () => ({
resolveNodeWidget: vi.fn()
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn()
}))
vi.mock('@/stores/executionErrorStore', () => ({
useExecutionErrorStore: vi.fn()
}))
import { inputsForZone } from './useZoneWidgets'
describe('useZoneWidgets', () => {
describe('inputsForZone', () => {
const inputs: [NodeId, string][] = [
[1, 'prompt'],
[2, 'width'],
[1, 'steps'],
[3, 'seed']
]
function makeGetZone(
assignments: Record<string, string>
): (nodeId: NodeId, widgetName: string) => string | undefined {
return (nodeId, widgetName) => assignments[`${nodeId}:${widgetName}`]
}
it('returns inputs matching the given zone', () => {
const getZone = makeGetZone({
'1:prompt': 'z1',
'2:width': 'z2',
'1:steps': 'z1',
'3:seed': 'z2'
})
const result = inputsForZone(inputs, getZone, 'z1')
expect(result).toEqual([
[1, 'prompt'],
[1, 'steps']
])
})
it('returns empty array when no inputs match', () => {
const getZone = makeGetZone({
'1:prompt': 'z1',
'2:width': 'z1'
})
const result = inputsForZone(inputs, getZone, 'z2')
expect(result).toEqual([])
})
it('handles empty inputs', () => {
const getZone = makeGetZone({})
expect(inputsForZone([], getZone, 'z1')).toEqual([])
})
it('handles unassigned inputs (getZone returns undefined)', () => {
const getZone = makeGetZone({ '1:prompt': 'z1' })
// Only 1:prompt is assigned to z1; rest are undefined
const result = inputsForZone(inputs, getZone, 'z1')
expect(result).toEqual([[1, 'prompt']])
})
it('routes unassigned inputs to defaultZoneId when provided', () => {
const getZone = makeGetZone({ '1:prompt': 'z1' })
const z1 = inputsForZone(inputs, getZone, 'z1', 'z1')
const z2 = inputsForZone(inputs, getZone, 'z2', 'z1')
// 1:prompt is explicitly z1; unassigned ones also go to z1 (default)
expect(z1).toEqual([
[1, 'prompt'],
[2, 'width'],
[1, 'steps'],
[3, 'seed']
])
// z2 gets nothing since unassigned defaults to z1
expect(z2).toEqual([])
})
it('filters non-contiguous inputs for the same node across zones', () => {
const getZone = makeGetZone({
'1:prompt': 'z1',
'2:width': 'z2',
'1:steps': 'z2', // same node 1, different zone
'3:seed': 'z1'
})
const z1 = inputsForZone(inputs, getZone, 'z1')
const z2 = inputsForZone(inputs, getZone, 'z2')
expect(z1).toEqual([
[1, 'prompt'],
[3, 'seed']
])
expect(z2).toEqual([
[2, 'width'],
[1, 'steps']
])
})
})
})

View File

@@ -0,0 +1,62 @@
import { computed } from 'vue'
import { getTemplate } from '@/components/builder/layoutTemplates'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
export interface ResolvedArrangeWidget {
nodeId: NodeId
widgetName: string
node: LGraphNode
widget: IBaseWidget
}
export function inputsForZone(
selectedInputs: [NodeId, string][],
getZone: (nodeId: NodeId, widgetName: string) => string | undefined,
zoneId: string,
defaultZoneId?: string
): [NodeId, string][] {
return selectedInputs.filter(([nodeId, widgetName]) => {
const assigned = getZone(nodeId, widgetName)
if (assigned) return assigned === zoneId
return defaultZoneId ? zoneId === defaultZoneId : false
})
}
/**
* 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('single')!
)
return computed(() => {
const map = new Map<string, ResolvedArrangeWidget[]>()
const defaultZoneId = template.value.zones[0]?.id
for (const zone of template.value.zones) {
const inputs = inputsForZone(
appModeStore.selectedInputs,
appModeStore.getZone,
zone.id,
defaultZoneId
)
const resolved = inputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node && widget ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
map.set(zone.id, resolved)
}
return map
})
}

View File

@@ -0,0 +1,136 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import Dialogue from './Dialogue.vue'
const meta = {
title: 'UI/Dialog',
component: Dialogue,
tags: ['autodocs'],
parameters: {
layout: 'centered'
}
} satisfies Meta<typeof Dialogue>
export default meta
type Story = StoryObj<typeof meta>
export const WithTitle: Story = {
render: (args) => ({
components: { Dialogue, Button },
setup: () => ({ args }),
template: `
<Dialogue v-bind="args">
<template #button>
<Button>Open dialog</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-6 p-4">
<p class="text-sm text-muted-foreground">
A more descriptive lorem ipsum text...
</p>
<div class="flex items-center justify-end gap-4">
<Button variant="muted-textonly" size="sm" @click="close">
Cancel
</Button>
<Button variant="secondary" size="lg" @click="close">
Ok
</Button>
</div>
</div>
</template>
</Dialogue>
`
}),
args: {
title: 'Modal Title'
}
}
export const WithoutTitle: Story = {
render: () => ({
components: { Dialogue, Button },
template: `
<Dialogue>
<template #button>
<Button>Open dialog</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-4 p-4">
<p class="text-sm text-muted-foreground">
This dialog has no title header.
</p>
<div class="flex justify-end">
<Button variant="secondary" size="lg" @click="close">
Got it
</Button>
</div>
</div>
</template>
</Dialogue>
`
})
}
export const Confirmation: Story = {
render: () => ({
components: { Dialogue, Button },
template: `
<Dialogue title="Delete this item?">
<template #button>
<Button variant="destructive">Delete</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-6 p-4">
<p class="text-sm text-muted-foreground">
This action cannot be undone. The item will be permanently removed.
</p>
<div class="flex items-center justify-end gap-4">
<Button variant="muted-textonly" size="sm" @click="close">
Cancel
</Button>
<Button variant="destructive" size="lg" @click="close">
Delete
</Button>
</div>
</div>
</template>
</Dialogue>
`
})
}
export const WithLink: Story = {
render: () => ({
components: { Dialogue, Button },
template: `
<Dialogue title="Modal Title">
<template #button>
<Button>Open dialog</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-6 p-4">
<p class="text-sm text-muted-foreground">
A more descriptive lorem ipsum text...
</p>
<div class="flex items-center justify-between">
<button class="flex items-center gap-2 text-sm text-muted-foreground hover:text-base-foreground">
<i class="icon-[lucide--external-link] size-4" />
See what's new
</button>
<div class="flex items-center gap-4">
<Button variant="muted-textonly" size="sm" @click="close">
Cancel
</Button>
<Button variant="secondary" size="lg" @click="close">
Ok
</Button>
</div>
</div>
</div>
</template>
</Dialogue>
`
})
}

View File

@@ -9,6 +9,7 @@ import {
DialogTitle,
VisuallyHidden
} from 'reka-ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
@@ -20,6 +21,16 @@ const { src, alt = '' } = defineProps<{
alt?: string
}>()
const isVideo = computed(() => {
const videoExt = /\.(mp4|webm|mov)/i
return (
videoExt.test(src) ||
videoExt.test(
new URL(src, location.href).searchParams.get('filename') ?? ''
)
)
})
const { t } = useI18n()
</script>
<template>
@@ -46,7 +57,15 @@ const { t } = useI18n()
<i class="icon-[lucide--x] size-5" />
</Button>
</DialogClose>
<video
v-if="isVideo"
:src
controls
autoplay
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"
/>
<img
v-else
:src
:alt
class="max-h-[90vh] max-w-[90vw] rounded-sm object-contain"

View File

@@ -1,14 +1,14 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
class="flex h-8 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
class="aspect-square h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canDecrement"
tabindex="-1"
@@ -16,7 +16,7 @@
>
<i class="pi pi-minus" />
</Button>
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
<div class="relative my-0.25 min-w-[2ch] flex-1 py-1.5">
<input
ref="inputField"
v-bind="inputAttrs"
@@ -54,7 +54,7 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
class="aspect-square h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
variant="muted-textonly"
:disabled="!canIncrement"
tabindex="-1"
@@ -142,8 +142,12 @@ const { distanceX, isSwiping } = usePointerSwipe(swipeElement, {
whenever(distanceX, () => {
if (disabled) return
const delta = ((distanceX.value - dragDelta) / 10) | 0
dragDelta += delta * 10
// Scale sensitivity: small steps (floats) need less drag distance.
// For step >= 1, use 10px per increment. For step < 1, scale proportionally
// so 0.01 step requires ~2px per increment instead of 10px.
const pxPerStep = step >= 1 ? 10 : Math.max(2, Math.round(step * 100))
const delta = ((distanceX.value - dragDelta) / pxPerStep) | 0
dragDelta += delta * pxPerStep
modelValue.value = clamp(modelValue.value - delta * step)
})

View File

@@ -34,6 +34,7 @@ const {
node,
isDraggable = false,
hiddenFavoriteIndicator = false,
hiddenLabel = false,
hiddenWidgetActions = false,
showNodeName = false,
parents = [],
@@ -43,6 +44,7 @@ const {
node: LGraphNode
isDraggable?: boolean
hiddenFavoriteIndicator?: boolean
hiddenLabel?: boolean
hiddenWidgetActions?: boolean
showNodeName?: boolean
parents?: SubgraphNode[]
@@ -148,6 +150,7 @@ const displayLabel = customRef((track, trigger) => {
>
<!-- widget header -->
<div
v-if="!hiddenLabel"
:class="
cn(
'mb-1.5 flex min-h-8 min-w-0 items-center justify-between gap-1',

View File

@@ -0,0 +1,214 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import Popover from './Popover.vue'
const meta = {
title: 'UI/Popover',
component: Popover,
tags: ['autodocs'],
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#1a1a1b' },
{ name: 'light', value: '#ffffff' },
{ name: 'sidebar', value: '#232326' }
]
}
}
} satisfies Meta<typeof Popover>
export default meta
type Story = StoryObj<typeof meta>
/** Default: menu-style popover with action entries. */
export const Default: Story = {
render: () => ({
components: { Popover },
template: `
<Popover
:entries="[
{ label: 'Rename', icon: 'icon-[lucide--pencil]', command: () => {} },
{ label: 'Duplicate', icon: 'icon-[lucide--copy]', command: () => {} },
{ separator: true },
{ label: 'Delete', icon: 'icon-[lucide--trash-2]', command: () => {} }
]"
/>
`
})
}
/** Custom trigger button. */
export const CustomTrigger: Story = {
render: () => ({
components: { Popover, Button },
template: `
<Popover
:entries="[
{ label: 'Option A', command: () => {} },
{ label: 'Option B', command: () => {} }
]"
>
<template #button>
<Button variant="outline">Click me</Button>
</template>
</Popover>
`
})
}
/** Action prompt: small inline confirmation bubble.
* Use this pattern for contextual Yes/No prompts like
* "Group these?", "Align to bottom?", etc. */
export const ActionPrompt: Story = {
render: () => ({
components: { Popover, Button },
template: `
<Popover>
<template #button>
<Button variant="outline" size="sm">
<i class="icon-[lucide--layout-grid] mr-1 size-3.5" />
Group
</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-2 p-1">
<p class="text-sm text-muted-foreground">Group into a row?</p>
<div class="flex gap-2">
<Button
size="sm"
variant="primary"
class="flex-1"
@click="close()"
>
Yes
</Button>
<Button
size="sm"
variant="ghost"
class="flex-1"
@click="close()"
>
No
</Button>
</div>
</div>
</template>
</Popover>
`
})
}
/** Alignment prompt: contextual bubble for zone actions. */
export const AlignPrompt: Story = {
render: () => ({
components: { Popover, Button },
template: `
<Popover>
<template #button>
<Button variant="ghost" size="sm">
<i class="icon-[lucide--align-vertical-justify-end] size-4" />
</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col gap-1.5 p-1">
<button
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-secondary-background"
@click="close()"
>
<i class="icon-[lucide--arrow-down-to-line] size-4" />
Align to bottom
</button>
<button
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-secondary-background"
@click="close()"
>
<i class="icon-[lucide--columns-2] size-4" />
Group into row
</button>
</div>
</template>
</Popover>
`
})
}
/** On light background — verify popover visibility. */
export const OnLightBackground: Story = {
parameters: {
backgrounds: { default: 'light' }
},
render: () => ({
components: { Popover, Button },
template: `
<Popover>
<template #button>
<Button>Open popover</Button>
</template>
<template #default="{ close }">
<div class="p-2">
<p class="text-sm">Popover on light background</p>
<Button size="sm" class="mt-2" @click="close()">Close</Button>
</div>
</template>
</Popover>
`
})
}
/** On sidebar background — verify contrast against dark sidebar. */
export const OnSidebarBackground: Story = {
parameters: {
backgrounds: { default: 'sidebar' }
},
render: () => ({
components: { Popover, Button },
template: `
<Popover>
<template #button>
<Button>Open popover</Button>
</template>
<template #default="{ close }">
<div class="p-2">
<p class="text-sm">Popover on sidebar background</p>
<Button size="sm" class="mt-2" @click="close()">Close</Button>
</div>
</template>
</Popover>
`
})
}
/** No arrow variant. */
export const NoArrow: Story = {
render: () => ({
components: { Popover },
template: `
<Popover
:show-arrow="false"
:entries="[
{ label: 'Settings', icon: 'icon-[lucide--settings]', command: () => {} },
{ label: 'Help', icon: 'icon-[lucide--circle-help]', command: () => {} }
]"
/>
`
})
}
/** Disabled entry. */
export const WithDisabled: Story = {
render: () => ({
components: { Popover },
template: `
<Popover
:entries="[
{ label: 'Available', command: () => {} },
{ label: 'Coming soon', disabled: true }
]"
/>
`
})
}

View File

@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TypeformPopoverButton from './TypeformPopoverButton.vue'
const meta = {
title: 'UI/TypeformPopoverButton',
component: TypeformPopoverButton,
tags: ['autodocs'],
parameters: {
layout: 'centered'
}
} satisfies Meta<typeof TypeformPopoverButton>
export default meta
type Story = StoryObj<typeof meta>
/** Default: help button that opens an embedded Typeform survey. */
export const Default: Story = {
args: {
dataTfWidget: 'example123',
active: true
}
}
/** Inactive: popover content is hidden. */
export const Inactive: Story = {
args: {
dataTfWidget: 'example123',
active: false
}
}

View File

@@ -0,0 +1,161 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Button from '@/components/ui/button/Button.vue'
import Tooltip from './Tooltip.vue'
const meta = {
title: 'UI/Tooltip',
component: Tooltip,
tags: ['autodocs'],
parameters: {
layout: 'centered'
},
argTypes: {
side: {
control: 'select',
options: ['top', 'bottom', 'left', 'right']
},
size: {
control: 'select',
options: ['sm', 'lg']
}
}
} satisfies Meta<typeof Tooltip>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { Tooltip, Button },
setup: () => ({ args }),
template: `
<Tooltip v-bind="args">
<Button>Hover me</Button>
</Tooltip>
`
}),
args: {
text: 'This is a tooltip',
side: 'top',
size: 'sm'
}
}
export const Small: Story = {
render: () => ({
components: { Tooltip, Button },
template: `
<div class="flex gap-12 p-20">
<Tooltip text="Tool tip left aligned" side="top" size="sm">
<Button>Top</Button>
</Tooltip>
<Tooltip text="Tool tip center aligned" side="bottom" size="sm">
<Button>Bottom</Button>
</Tooltip>
<Tooltip text="Tool tip right aligned" side="left" size="sm">
<Button>Left</Button>
</Tooltip>
<Tooltip text="Tool tip pointing left" side="right" size="sm">
<Button>Right</Button>
</Tooltip>
</div>
`
})
}
export const Large: Story = {
render: () => ({
components: { Tooltip, Button },
template: `
<div class="flex gap-12 p-20">
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="top" size="lg">
<Button>Top</Button>
</Tooltip>
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="bottom" size="lg">
<Button>Bottom</Button>
</Tooltip>
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="left" size="lg">
<Button>Left</Button>
</Tooltip>
<Tooltip text="Lorem ipsum dolor sit amet, consectetur dolor si adipiscing elit. Proin maximus nisl nec posuere mattis." side="right" size="lg">
<Button>Right</Button>
</Tooltip>
</div>
`
})
}
export const WithKeybind: Story = {
render: () => ({
components: { Tooltip, Button },
template: `
<div class="flex gap-12 p-20">
<Tooltip text="Select all" keybind="Ctrl+A" side="top" size="sm">
<Button>With keybind</Button>
</Tooltip>
<Tooltip text="Save" keybind="Ctrl+S" side="bottom" size="sm">
<Button>Save</Button>
</Tooltip>
<Tooltip text="Undo" keybind="Ctrl+Z" side="right" size="sm">
<Button>Undo</Button>
</Tooltip>
</div>
`
})
}
export const AllSides: Story = {
render: () => ({
components: { Tooltip, Button },
template: `
<div class="flex flex-col items-center gap-12 p-20">
<Tooltip text="Top tooltip" side="top">
<Button>Top</Button>
</Tooltip>
<div class="flex gap-12">
<Tooltip text="Left tooltip" side="left">
<Button>Left</Button>
</Tooltip>
<Tooltip text="Right tooltip" side="right">
<Button>Right</Button>
</Tooltip>
</div>
<Tooltip text="Bottom tooltip" side="bottom">
<Button>Bottom</Button>
</Tooltip>
</div>
`
})
}
export const WithOffset: Story = {
render: () => ({
components: { Tooltip, Button },
template: `
<div class="flex gap-12 p-20">
<Tooltip text="20px offset" side="left" :side-offset="20" size="sm">
<Button>Left 20px</Button>
</Tooltip>
<Tooltip text="20px offset" side="top" :side-offset="20" size="sm">
<Button>Top 20px</Button>
</Tooltip>
<Tooltip text="Default offset" side="left" size="sm">
<Button>Left default</Button>
</Tooltip>
</div>
`
})
}
export const Disabled: Story = {
render: () => ({
components: { Tooltip, Button },
template: `
<Tooltip text="You won't see this" :disabled="true">
<Button>No tooltip</Button>
</Tooltip>
`
})
}

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger
} from 'reka-ui'
import { cn } from '@/utils/tailwindUtil'
const {
text,
side = 'top',
sideOffset = 5,
delayDuration = 400,
disabled = false,
size = 'sm',
keybind
} = defineProps<{
text?: string
side?: 'top' | 'bottom' | 'left' | 'right'
sideOffset?: number
delayDuration?: number
disabled?: boolean
size?: 'sm' | 'lg'
keybind?: string
}>()
</script>
<template>
<TooltipProvider
:delay-duration="delayDuration"
:disable-hoverable-content="true"
>
<TooltipRoot>
<TooltipTrigger as-child>
<slot />
</TooltipTrigger>
<TooltipPortal v-if="text && !disabled">
<TooltipContent
:side
:side-offset="sideOffset"
:collision-padding="10"
:class="
cn(
'z-1700 border border-border-default bg-base-background font-normal text-base-foreground shadow-[1px_1px_8px_rgba(0,0,0,0.4)]',
size === 'sm' &&
'flex items-center gap-2 rounded-lg px-4 py-2 text-xs',
size === 'lg' && 'max-w-75 rounded-md px-4 py-2 text-sm'
)
"
>
{{ text }}
<span
v-if="keybind && size === 'sm'"
class="rounded-sm bg-secondary-background px-1 text-xs/4"
>
{{ keybind }}
</span>
<TooltipArrow
:width="8"
:height="5"
class="fill-base-background stroke-border-default"
/>
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</template>

View File

@@ -0,0 +1,307 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
const mockWidgets = vi.hoisted(() => new Map<string, IBaseWidget>())
vi.mock('@/utils/litegraphUtil', () => ({
resolveNodeWidget: (nodeId: NodeId, widgetName: string) => {
const widget = mockWidgets.get(`${nodeId}:${widgetName}`)
return widget ? [{}, widget] : []
}
}))
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { extra: {}, nodes: [{ id: 1 }] }
}
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
getCanvas: () => ({ read_only: false })
})
}))
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => ({ show: vi.fn() })
}))
vi.mock('@/scripts/changeTracker', () => ({
ChangeTracker: { isLoadingGraph: false }
}))
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppPresets } from './useAppPresets'
function createWidget(
name: string,
value: unknown,
options?: Record<string, unknown>
): IBaseWidget {
return { name, value, type: 'number', options } as unknown as IBaseWidget
}
describe('useAppPresets', () => {
let appModeStore: ReturnType<typeof useAppModeStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
appModeStore = useAppModeStore()
mockWidgets.clear()
})
describe('savePreset', () => {
it('snapshots current widget values and saves with a name', () => {
const widget = createWidget('steps', 20)
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { savePreset, presets } = useAppPresets()
const preset = savePreset('My Preset')
expect(preset.name).toBe('My Preset')
expect(preset.values['1:steps']).toBe(20)
expect(presets.value).toHaveLength(1)
})
it('saves multiple widget values', () => {
mockWidgets.set('1:steps', createWidget('steps', 20))
mockWidgets.set('2:cfg', createWidget('cfg', 7.5))
appModeStore.selectedInputs.push(
['1' as NodeId, 'steps'],
['2' as NodeId, 'cfg']
)
const { savePreset } = useAppPresets()
const preset = savePreset('Dual')
expect(preset.values['1:steps']).toBe(20)
expect(preset.values['2:cfg']).toBe(7.5)
})
})
describe('applyPreset', () => {
it('sets widget values from the preset', () => {
const widget = createWidget('steps', 20)
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { savePreset, applyPreset } = useAppPresets()
const preset = savePreset('Saved')
widget.value = 50
applyPreset(preset.id)
expect(widget.value).toBe(20)
})
it('clamps numeric values to widget overrides', () => {
const widget = createWidget('steps', 25)
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
appModeStore.widgetOverrides['1:steps'] = { min: 10, max: 30 }
const { savePreset, applyPreset } = useAppPresets()
const preset = savePreset('High Steps')
// Manually override the preset value to be out of range
preset.values['1:steps'] = 50
applyPreset(preset.id)
expect(widget.value).toBe(30)
})
it('clamps to min override', () => {
const widget = createWidget('steps', 5)
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
appModeStore.widgetOverrides['1:steps'] = { min: 10 }
const { savePreset, applyPreset } = useAppPresets()
const preset = savePreset('Low Steps')
preset.values['1:steps'] = 2
applyPreset(preset.id)
expect(widget.value).toBe(10)
})
it('does not clamp non-numeric values', () => {
const widget = createWidget('sampler', 'euler')
mockWidgets.set('1:sampler', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'sampler'])
appModeStore.widgetOverrides['1:sampler'] = { min: 0, max: 10 }
const { savePreset, applyPreset } = useAppPresets()
const preset = savePreset('Sampler Preset')
applyPreset(preset.id)
expect(widget.value).toBe('euler')
})
it('ignores unknown preset id', () => {
const widget = createWidget('steps', 20)
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { applyPreset } = useAppPresets()
applyPreset('nonexistent')
expect(widget.value).toBe(20)
})
})
describe('deletePreset', () => {
it('removes the preset by id', () => {
mockWidgets.set('1:steps', createWidget('steps', 20))
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { savePreset, deletePreset, presets } = useAppPresets()
const preset = savePreset('To Delete')
deletePreset(preset.id)
expect(presets.value).toHaveLength(0)
})
it('ignores unknown id', () => {
mockWidgets.set('1:steps', createWidget('steps', 20))
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { savePreset, deletePreset, presets } = useAppPresets()
savePreset('Keep')
deletePreset('nonexistent')
expect(presets.value).toHaveLength(1)
})
})
describe('renamePreset', () => {
it('updates the preset name', () => {
mockWidgets.set('1:steps', createWidget('steps', 20))
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { savePreset, renamePreset, presets } = useAppPresets()
const preset = savePreset('Old Name')
renamePreset(preset.id, 'New Name')
expect(presets.value[0].name).toBe('New Name')
})
})
describe('updatePreset', () => {
it('replaces preset values with current widget values', () => {
const widget = createWidget('steps', 20)
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { savePreset, updatePreset, presets } = useAppPresets()
const preset = savePreset('Updatable')
widget.value = 42
updatePreset(preset.id)
expect(presets.value[0].values['1:steps']).toBe(42)
})
})
describe('applyBuiltin', () => {
it('sets numeric widgets to min when t=0', () => {
const widget = createWidget('steps', 20, { min: 1, max: 100 })
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { applyBuiltin } = useAppPresets()
applyBuiltin(0)
expect(widget.value).toBe(1)
})
it('sets numeric widgets to max when t=1', () => {
const widget = createWidget('steps', 20, { min: 1, max: 100 })
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { applyBuiltin } = useAppPresets()
applyBuiltin(1)
expect(widget.value).toBe(100)
})
it('sets numeric widgets to midpoint when t=0.5', () => {
const widget = createWidget('steps', 20, { min: 0, max: 50 })
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { applyBuiltin } = useAppPresets()
applyBuiltin(0.5)
expect(widget.value).toBe(25)
})
it('respects widget overrides over widget options', () => {
const widget = createWidget('steps', 20, { min: 1, max: 100 })
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
appModeStore.widgetOverrides['1:steps'] = { min: 10, max: 30 }
const { applyBuiltin } = useAppPresets()
applyBuiltin(1)
expect(widget.value).toBe(30)
})
it('skips numeric widgets without min/max', () => {
const widget = createWidget('steps', 20)
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { applyBuiltin } = useAppPresets()
applyBuiltin(0)
expect(widget.value).toBe(20)
})
it('picks first combo option at t=0', () => {
const widget = createWidget('sampler', 'euler', {
values: ['euler', 'dpmpp_2m', 'ddim']
})
mockWidgets.set('1:sampler', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'sampler'])
const { applyBuiltin } = useAppPresets()
applyBuiltin(0)
expect(widget.value).toBe('euler')
})
it('picks middle combo option at t=0.5', () => {
const widget = createWidget('ratio', '4:3', {
values: ['1:1', '4:3', '16:9']
})
mockWidgets.set('1:ratio', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'ratio'])
const { applyBuiltin } = useAppPresets()
applyBuiltin(0.5)
expect(widget.value).toBe('4:3')
})
it('picks last combo option at t=1', () => {
const widget = createWidget('ratio', '4:3', {
values: ['1:1', '4:3', '16:9']
})
mockWidgets.set('1:ratio', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'ratio'])
const { applyBuiltin } = useAppPresets()
applyBuiltin(1)
expect(widget.value).toBe('16:9')
})
it('applies via applyPreset with builtin IDs', () => {
const widget = createWidget('steps', 20, { min: 1, max: 100 })
mockWidgets.set('1:steps', widget)
appModeStore.selectedInputs.push(['1' as NodeId, 'steps'])
const { applyPreset } = useAppPresets()
applyPreset('__builtin:max')
expect(widget.value).toBe(100)
})
})
})

View File

@@ -0,0 +1,193 @@
import { computed } from 'vue'
import type {
AppModePreset,
WidgetOverride
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
type WidgetKey = `${string}:${string}`
/** Well-known IDs for built-in presets. */
export const BUILTIN_PRESET_IDS = {
min: '__builtin:min',
mid: '__builtin:mid',
max: '__builtin:max'
} as const
function makeKey(nodeId: string, widgetName: string): WidgetKey {
return `${nodeId}:${widgetName}`
}
/** Clamp a numeric value to widget override bounds if set. */
function clampToOverride(
value: unknown,
override: WidgetOverride | undefined
): unknown {
if (override === undefined || typeof value !== 'number') return value
let clamped = value
if (override.min != null && clamped < override.min) clamped = override.min
if (override.max != null && clamped > override.max) clamped = override.max
return clamped
}
/**
* Resolve effective min/max for a widget: user override > widget options > undefined.
*/
function getEffectiveBounds(
widgetOptions: { min?: number; max?: number } | undefined,
override: WidgetOverride | undefined
): { min: number | undefined; max: number | undefined } {
return {
min: override?.min ?? widgetOptions?.min,
max: override?.max ?? widgetOptions?.max
}
}
function lerp(
min: number | undefined,
max: number | undefined,
t: number
): number | undefined {
if (min == null || max == null) return undefined
return min + (max - min) * t
}
export function useAppPresets() {
const appModeStore = useAppModeStore()
const presets = computed(() => appModeStore.presets)
/** Snapshot current widget values for all selected inputs. */
function snapshotValues(): Record<string, unknown> {
const values: Record<string, unknown> = {}
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
const [, widget] = resolveNodeWidget(nodeId, widgetName)
if (widget) {
values[makeKey(String(nodeId), widgetName)] = widget.value
}
}
return values
}
/**
* Pick an item from a list at interpolation factor t (0=first, 0.5=mid, 1=last).
*/
function pickFromList(list: unknown[], t: number): unknown {
if (list.length === 0) return undefined
const idx = Math.round(t * (list.length - 1))
return list[idx]
}
/**
* Compute a built-in preset (min/mid/max) from widget bounds.
* Numeric widgets use min/max interpolation.
* Combo/list widgets pick from available options by position.
*/
function computeBuiltinValues(t: number): Record<string, unknown> {
const values: Record<string, unknown> = {}
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
const key = makeKey(String(nodeId), widgetName)
const [, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget) continue
// Numeric widgets: interpolate between min and max
if (typeof widget.value === 'number') {
const override = appModeStore.widgetOverrides[key]
const bounds = getEffectiveBounds(widget.options, override)
const val = lerp(bounds.min, bounds.max, t)
if (val != null) values[key] = val
continue
}
// Combo/list widgets: pick from options by position
const opts = widget.options?.values
if (Array.isArray(opts) && opts.length > 0) {
values[key] = pickFromList(opts, t)
}
}
return values
}
/** Apply a built-in preset by interpolation factor (0=min, 0.5=mid, 1=max). */
function applyBuiltin(t: number) {
const values = computeBuiltinValues(t)
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
const key = makeKey(String(nodeId), widgetName)
if (!(key in values)) continue
const [, widget] = resolveNodeWidget(nodeId, widgetName)
if (widget) widget.value = values[key] as typeof widget.value
}
}
function savePreset(name: string): AppModePreset {
const preset: AppModePreset = {
id: crypto.randomUUID(),
name,
values: snapshotValues()
}
appModeStore.presets.push(preset)
appModeStore.persistLinearData()
return preset
}
function deletePreset(id: string) {
const idx = appModeStore.presets.findIndex((p) => p.id === id)
if (idx !== -1) {
appModeStore.presets.splice(idx, 1)
appModeStore.persistLinearData()
}
}
function renamePreset(id: string, name: string) {
const preset = appModeStore.presets.find((p) => p.id === id)
if (preset) {
preset.name = name
appModeStore.persistLinearData()
}
}
/** Apply a preset — sets widget values, clamping to any overrides. */
function applyPreset(id: string) {
// Handle built-in presets
if (id === BUILTIN_PRESET_IDS.min) return applyBuiltin(0)
if (id === BUILTIN_PRESET_IDS.mid) return applyBuiltin(0.5)
if (id === BUILTIN_PRESET_IDS.max) return applyBuiltin(1)
const preset = appModeStore.presets.find((p) => p.id === id)
if (!preset) return
for (const [nodeId, widgetName] of appModeStore.selectedInputs) {
const key = makeKey(String(nodeId), widgetName)
if (!(key in preset.values)) continue
const [, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget) continue
const override = appModeStore.widgetOverrides[key]
const value = clampToOverride(preset.values[key], override)
widget.value = value as typeof widget.value
}
}
/** Update an existing preset with current widget values. */
function updatePreset(id: string) {
const preset = appModeStore.presets.find((p) => p.id === id)
if (!preset) return
preset.values = snapshotValues()
appModeStore.persistLinearData()
}
return {
presets,
savePreset,
deletePreset,
renamePreset,
applyPreset,
applyBuiltin,
updatePreset
}
}

View File

@@ -128,6 +128,7 @@
"save": "Save",
"saveAnyway": "Save Anyway",
"saving": "Saving",
"yes": "Yes",
"no": "No",
"cancel": "Cancel",
"close": "Close",
@@ -1193,6 +1194,7 @@
"maskEditor": {
"title": "Mask Editor",
"openMaskEditor": "Open in Mask Editor",
"editMask": "Edit Mask",
"invert": "Invert",
"clear": "Clear",
"undo": "Undo",
@@ -3282,6 +3284,7 @@
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"dragAndDropVideo": "Click to browse or drag a video",
"mobileControls": "Edit & Run",
"runCount": "Number of runs",
"rerun": "Rerun",
@@ -3321,7 +3324,62 @@
"outputExamples": "Examples: 'Save Image' or 'Save Video'",
"switchToOutputsButton": "Switch to Outputs",
"outputs": "Outputs",
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app",
"layout": "Layout",
"dropHere": "Drop inputs here",
"outputZone": "Output",
"shiftClickPriority": "Shift+click to prioritize",
"queueFailed": "Failed to queue prompt"
},
"groups": {
"createGroup": "Create group",
"untitled": "Unnamed Group",
"confirmUngroup": "Ungroup these inputs?",
"ungroupDescription": "These inputs will no longer be grouped together.",
"confirmRemove": "Remove this input?",
"removeDescription": "This will remove the input from the app. You will need to re-add it in the inputs step."
},
"presets": {
"label": "Presets",
"empty": "No saved presets yet.",
"save": "Save current as preset",
"saveTitle": "Save preset",
"saveMessage": "Enter a name for this preset.",
"namePlaceholder": "Preset name",
"builtinMin": "Min",
"builtinMid": "Mid",
"builtinMax": "Max",
"builtinMinTip": "Set all inputs to minimum values",
"builtinMidTip": "Set all inputs to midpoint values",
"builtinMaxTip": "Set all inputs to maximum values",
"builtinSection": "Quick presets",
"savedSection": "Saved",
"displayAs": "Display as",
"displayTabs": "Tabs",
"displayButtons": "Buttons",
"displayMenu": "Menu",
"overwrite": "Save current values to this preset",
"presetCount": "{count} saved preset | {count} saved presets"
},
"layout": {
"templates": {
"single": "Single",
"singleDesc": "Single column sidebar",
"dual": "Dual",
"dualDesc": "Two-column sidebar with resize"
},
"zones": {
"main": "Main",
"left": "Left",
"right": "Right"
},
"group": "Group selected",
"ungroup": "Ungroup",
"moveToGroup": "Move to group",
"removeFromGroup": "Remove from group",
"newGroup": "New group...",
"groupName": "Group name",
"ungrouped": "Ungrouped"
},
"builder": {
"title": "App builder mode",

View File

@@ -121,7 +121,7 @@ describe('GtmTelemetryProvider', () => {
event: 'execution_error',
node_type: 'KSampler'
})
expect((entry?.error as string).length).toBe(100)
expect((entry!.error as string).length).toBe(100)
})
it('pushes select_content for template events', () => {

View File

@@ -5,19 +5,98 @@ import type { ChangeTracker } from '@/scripts/changeTracker'
import type { AppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { UserFile } from '@/stores/userFileStore'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ComfyWorkflowJSON,
ModelFile
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
/** Display type override for a widget in app mode. */
type WidgetDisplayType = 'tabs' | 'menu' | 'number' | 'slider'
/** Per-widget overrides set by the workflow author in the builder. */
export interface WidgetOverride {
min?: number
max?: number
displayType?: WidgetDisplayType
}
/** An item within an input group. */
export interface InputGroupItem {
key: string
pairId?: string
}
/** A named group of inputs that renders as a collapsible accordion. */
export interface InputGroup {
id: string
name: string | null
items: InputGroupItem[]
/** Optional color name from LGraphCanvas.node_colors (e.g. 'red', 'blue'). */
color?: string | null
}
/** Scope determines which widgets a preset targets. */
type PresetScope = 'app' | 'graph'
/** How the preset switcher renders in app view. */
export type PresetDisplayMode = 'tabs' | 'buttons' | 'menu'
/** A named preset that captures widget values for selected inputs. */
export interface AppModePreset {
id: string
name: string
/** Map of `nodeId:widgetName` → serialised widget value. */
values: Record<string, unknown>
/** Defaults to 'app'. 'graph' presets target all graph widgets (future). */
scope?: PresetScope
}
export interface LinearData {
inputs: [NodeId, string][]
outputs: NodeId[]
layoutTemplateId?: string
/** @deprecated Use zoneAssignmentsPerTemplate instead */
zoneAssignments?: Record<string, string>
/** @deprecated Use gridOverridesPerTemplate instead */
gridOverrides?: {
zoneOrder?: string[]
columnFractions?: number[]
rowFractions?: number[]
}
/** @deprecated Use runControlsZoneIdPerTemplate instead */
runControlsZoneId?: string
zoneAssignmentsPerTemplate?: Record<string, Record<string, string>>
gridOverridesPerTemplate?: Record<
string,
{
zoneOrder?: string[]
columnFractions?: number[]
rowFractions?: number[]
}
>
runControlsZoneIdPerTemplate?: Record<string, string>
zoneItemOrderPerTemplate?: Record<string, Record<string, string[]>>
presetStripZoneIdPerTemplate?: Record<string, string>
/** Per-widget overrides (min/max constraints, display type). Keyed by `nodeId:widgetName`. */
widgetOverrides?: Record<string, WidgetOverride>
/** Saved presets for quick input value switching. */
presets?: AppModePreset[]
/** How the preset switcher renders in app view. Defaults to 'tabs'. */
presetDisplayMode?: PresetDisplayMode
/** Whether the preset strip is visible. Defaults to true. */
presetsEnabled?: boolean
/** Collapsible input groups per layout template. */
inputGroupsPerTemplate?: Record<string, InputGroup[]>
}
export interface PendingWarnings {
missingNodeTypes?: MissingNodeType[]
// TODO: Currently unused — missing models are surfaced directly on every
// graph load. Reserved for future per-workflow missing model state management.
missingModels?: {
missingModels: ModelFile[]
paths: Record<string, string[]>
}
missingModelCandidates?: MissingModelCandidate[]
}

View File

@@ -22,13 +22,19 @@ const {
dropIndicator?: {
iconClass?: string
imageUrl?: string
videoUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
onDownload?: () => void
onRemove?: () => void
}
forceHovered?: boolean
}>()
const actionButtonClass =
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-neutral-800 text-white shadow-md transition-colors hover:bg-neutral-700'
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const clickGuard = useClickDragGuard(5)
@@ -92,7 +98,8 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 block h-25 w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
'm-3 block w-[calc(100%-1.5rem)] resize-y appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator.imageUrl || dropIndicator.videoUrl ? 'h-52' : 'h-25',
dropIndicator.onClick && 'cursor-pointer'
)
"
@@ -104,18 +111,35 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
cn(
'flex h-full max-w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-[7px] p-3 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator.imageUrl &&
!(dropIndicator.imageUrl || dropIndicator.videoUrl) &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<div v-if="dropIndicator.imageUrl" class="max-h-full max-w-full">
<div
v-if="dropIndicator.imageUrl"
class="flex size-full items-center justify-center overflow-hidden"
>
<img
class="max-h-full max-w-full rounded-md object-contain"
:alt="dropIndicator.label ?? ''"
:src="dropIndicator.imageUrl"
/>
</div>
<div
v-else-if="dropIndicator.videoUrl"
class="flex size-full items-center justify-center overflow-hidden"
@click.stop
>
<video
class="max-h-full max-w-full rounded-md object-contain"
:src="dropIndicator.videoUrl"
preload="metadata"
controls
loop
playsinline
/>
</div>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i
@@ -130,31 +154,53 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
</template>
</div>
</component>
<template v-if="dropIndicator.imageUrl">
<template v-if="dropIndicator.imageUrl || dropIndicator.videoUrl">
<div
v-if="dropIndicator.imageUrl"
class="absolute top-2 right-5 z-10 flex gap-1 opacity-0 transition-opacity duration-200 group-focus-within/dropzone:opacity-100 group-hover/dropzone:opacity-100"
>
<button
type="button"
:class="actionButtonClass"
:aria-label="t('mediaAsset.actions.zoom')"
:title="t('mediaAsset.actions.zoom')"
@click.stop="lightboxOpen = true"
>
<i class="icon-[lucide--fullscreen] size-4" />
</button>
<button
v-if="dropIndicator.onMaskEdit"
type="button"
:aria-label="t('maskEditor.openMaskEditor')"
:title="t('maskEditor.openMaskEditor')"
class="flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background transition-colors hover:bg-base-foreground/90"
:class="actionButtonClass"
:aria-label="t('maskEditor.editMask')"
:title="t('maskEditor.editMask')"
@click.stop="dropIndicator.onMaskEdit()"
>
<i class="icon-[comfy--mask] size-4" />
</button>
<button
v-if="dropIndicator.onDownload"
type="button"
:aria-label="t('mediaAsset.actions.zoom')"
:title="t('mediaAsset.actions.zoom')"
class="flex cursor-pointer items-center justify-center rounded-lg bg-base-foreground p-2 text-base-background transition-colors hover:bg-base-foreground/90"
@click.stop="lightboxOpen = true"
:class="actionButtonClass"
:aria-label="t('g.downloadImage')"
:title="t('g.downloadImage')"
@click.stop="dropIndicator.onDownload()"
>
<i class="icon-[lucide--zoom-in] size-4" />
<i class="icon-[lucide--download] size-4" />
</button>
<button
v-if="dropIndicator.onRemove"
type="button"
:class="actionButtonClass"
:aria-label="t('g.removeImage')"
:title="t('g.removeImage')"
@click.stop="dropIndicator.onRemove()"
>
<i class="icon-[lucide--x] size-4" />
</button>
</div>
<ImageLightbox
v-if="dropIndicator.imageUrl"
v-model="lightboxOpen"
:src="dropIndicator.imageUrl"
:alt="dropIndicator.label ?? ''"

View File

@@ -2,29 +2,18 @@
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
import { cn } from '@/utils/tailwindUtil'
const { executionStatusMessage } = useExecutionStatus()
defineOptions({ inheritAttrs: false })
const { src, showSize = true } = defineProps<{
const { src } = defineProps<{
src: string
mobile?: boolean
label?: string
showSize?: boolean
}>()
const imageRef = useTemplateRef('imageRef')
const width = ref<number | null>(null)
const height = ref<number | null>(null)
function onImageLoad() {
if (!imageRef.value || !showSize) return
width.value = imageRef.value.naturalWidth
height.value = imageRef.value.naturalHeight
}
const width = ref('')
const height = ref('')
</script>
<template>
<ZoomPane
@@ -37,24 +26,31 @@ function onImageLoad() {
:src
v-bind="slotProps"
class="size-full object-contain"
@load="onImageLoad"
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
</ZoomPane>
<img
v-else
ref="imageRef"
class="grow object-contain contain-size"
class="min-h-0 flex-1 object-contain"
:src
@load="onImageLoad"
@load="
() => {
if (!imageRef) return
width = `${imageRef.naturalWidth}`
height = `${imageRef.naturalHeight}`
}
"
/>
<span
v-if="executionStatusMessage"
class="animate-pulse self-center text-muted md:z-10"
>
{{ executionStatusMessage }}
</span>
<span v-else-if="width && height" class="self-center md:z-10">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>
v-if="!mobile"
class="self-end pr-2 md:z-10"
v-text="`${width} x ${height}`"
/>
</template>

View File

@@ -7,9 +7,11 @@ import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import OutputGrid from '@/renderer/extensions/linearMode/OutputGrid.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ResultItemImpl } from '@/stores/queueStore'
const { t } = useI18n()
const { setMode } = useAppMode()
@@ -18,6 +20,23 @@ const { hasOutputs } = storeToRefs(appModeStore)
const nodeOutputStore = useNodeOutputStore()
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const isMultiOutput = computed(() => appModeStore.selectedOutputs.length > 1)
const outputsByNode = computed(() => {
const map = new Map<string, ResultItemImpl | undefined>()
for (const nodeId of appModeStore.selectedOutputs) {
const locatorId = nodeIdToNodeLocatorId(nodeId)
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
if (!nodeOutput) {
map.set(String(nodeId), undefined)
continue
}
const results = flattenNodeOutput([nodeId, nodeOutput])
map.set(String(nodeId), results[0])
}
return map
})
const existingOutput = computed(() => {
for (const nodeId of appModeStore.selectedOutputs) {
const locatorId = nodeIdToNodeLocatorId(nodeId)
@@ -28,11 +47,25 @@ const existingOutput = computed(() => {
}
return undefined
})
function handleReorder(fromIndex: number, toIndex: number) {
const outputs = [...appModeStore.selectedOutputs]
const [moved] = outputs.splice(fromIndex, 1)
outputs.splice(toIndex, 0, moved)
appModeStore.selectedOutputs = outputs
}
</script>
<template>
<OutputGrid
v-if="isMultiOutput && hasOutputs"
:outputs-by-node="outputsByNode"
:output-count="appModeStore.selectedOutputs.length"
builder-mode
@reorder="handleReorder"
/>
<MediaOutputPreview
v-if="existingOutput"
v-else-if="existingOutput"
:output="existingOutput"
class="px-12 py-24"
/>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, useTemplateRef } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
@@ -34,7 +34,7 @@ const { toastTo, mobile } = defineProps<{
mobile?: boolean
}>()
defineEmits<{ navigateOutputs: [] }>()
defineExpose({ runButtonClick, handleDragDrop })
defineExpose({ runButtonClick })
//NOTE: due to batching, will never be greater than 2
const pendingJobQueues = ref(0)
@@ -42,8 +42,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
8000,
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
//TODO: refactor out of this file.
//code length is small, but changes should propagate
async function runButtonClick(e: Event) {
@@ -71,9 +69,6 @@ async function runButtonClick(e: Event) {
pendingJobQueues.value -= 1
}
}
function handleDragDrop(e: DragEvent) {
return widgetListRef.value?.handleDragDrop(e)
}
</script>
<template>
<div
@@ -100,7 +95,7 @@ function handleDragDrop(e: DragEvent) {
data-testid="linear-widgets"
class="grow scroll-shadows-comfy-menu-bg overflow-y-auto contain-size"
>
<AppModeWidgetList ref="widgetListRef" :mobile />
<AppModeWidgetList :mobile />
</section>
<Teleport
v-if="!jobToastTimeout || pendingJobQueues > 0"

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import ImageLightbox from '@/components/common/ImageLightbox.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
@@ -16,14 +17,17 @@ import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import OutputGrid from '@/renderer/extensions/linearMode/OutputGrid.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { ResultItemImpl } from '@/stores/queueStore'
const { t } = useI18n()
const mediaActions = useMediaAssetActions()
const appModeStore = useAppModeStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
@@ -38,6 +42,37 @@ const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const latentPreview = ref<string>()
const showSkeleton = ref(false)
const lightboxUrl = ref('')
const lightboxOpen = ref(false)
function openLightbox(url: string) {
if (mobile) {
document
.querySelectorAll<HTMLMediaElement>('video, audio')
.forEach((el) => el.pause())
}
lightboxUrl.value = url
lightboxOpen.value = true
}
const isMultiOutput = computed(() => appModeStore.selectedOutputs.length > 1)
const outputsByNode = computed(() => {
const map = new Map<string, ResultItemImpl>()
if (!selectedItem.value) return map
const outputs = allOutputs(selectedItem.value)
const outputLookup = new Map<string, ResultItemImpl>()
for (const output of outputs) {
if (!outputLookup.has(String(output.nodeId))) {
outputLookup.set(String(output.nodeId), output)
}
}
for (const nodeId of appModeStore.selectedOutputs) {
const output = outputLookup.get(String(nodeId))
if (output) map.set(String(nodeId), output)
}
return map
})
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
@@ -132,8 +167,16 @@ async function rerun(e: Event) {
]"
/>
</section>
<OutputGrid
v-if="isMultiOutput && outputsByNode.size > 0"
:outputs-by-node="outputsByNode"
:output-count="appModeStore.selectedOutputs.length"
:show-skeleton="showSkeleton"
:mobile
@open-lightbox="openLightbox"
/>
<ImagePreview
v-if="canShowPreview && latentPreview"
v-else-if="canShowPreview && latentPreview"
:mobile
:src="latentPreview"
:show-size="false"
@@ -142,6 +185,10 @@ async function rerun(e: Event) {
v-else-if="selectedOutput"
:output="selectedOutput"
:mobile
@dblclick="
!mobile && selectedOutput.url && openLightbox(selectedOutput.url)
"
@click="mobile && selectedOutput.url && openLightbox(selectedOutput.url)"
/>
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearArrange v-else-if="isArrangeMode" />
@@ -159,6 +206,7 @@ async function rerun(e: Event) {
v-if="!isBuilderMode"
class="z-10 min-w-0"
@update-selection="handleSelection"
@open-lightbox="openLightbox"
/>
<LinearFeedback
v-if="typeformWidgetId"
@@ -169,5 +217,7 @@ async function rerun(e: Event) {
<OutputHistory
v-else-if="!isBuilderMode"
@update-selection="handleSelection"
@open-lightbox="openLightbox"
/>
<ImageLightbox v-model="lightboxOpen" :src="lightboxUrl" />
</template>

View File

@@ -0,0 +1,313 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const {
outputsByNode,
outputCount,
showSkeleton = false,
builderMode = false,
mobile = false
} = defineProps<{
outputsByNode: Map<string, ResultItemImpl | undefined>
outputCount: number
showSkeleton?: boolean
builderMode?: boolean
mobile?: boolean
}>()
const emit = defineEmits<{
reorder: [fromIndex: number, toIndex: number]
openLightbox: [url: string]
}>()
const AREA_NAMES = ['a', 'b', 'c', 'd']
const MEDIA_TYPE_META: Record<string, { label: string; icon: string }> = {
images: { label: 'Image', icon: 'icon-[lucide--image]' },
video: { label: 'Video', icon: 'icon-[lucide--film]' },
audio: { label: 'Audio', icon: 'icon-[lucide--volume-2]' },
text: { label: 'Text', icon: 'icon-[lucide--file-text]' },
gltf: { label: '3D', icon: 'icon-[lucide--box]' }
}
function getOutputLabel(
nodeId: string,
index: number
): { label: string; icon: string } {
const node = resolveNode(Number(nodeId))
if (!node)
return { label: `Output ${index + 1}`, icon: 'icon-[lucide--layout-grid]' }
const comfyClass = node.comfyClass ?? ''
if (comfyClass.toLowerCase().includes('image') || comfyClass === 'SaveImage')
return { label: node.title || 'Image', icon: MEDIA_TYPE_META.images.icon }
if (comfyClass.toLowerCase().includes('video'))
return { label: node.title || 'Video', icon: MEDIA_TYPE_META.video.icon }
if (comfyClass.toLowerCase().includes('audio'))
return { label: node.title || 'Audio', icon: MEDIA_TYPE_META.audio.icon }
if (
comfyClass.toLowerCase().includes('3d') ||
comfyClass.toLowerCase().includes('gltf')
)
return { label: node.title || '3D', icon: MEDIA_TYPE_META.gltf.icon }
if (comfyClass.toLowerCase().includes('text'))
return { label: node.title || 'Text', icon: MEDIA_TYPE_META.text.icon }
return {
label: node.title || `Output ${index + 1}`,
icon: 'icon-[lucide--layout-grid]'
}
}
// Matches p-2 and gap-2 on the grid container
const GRID_PADDING_PX = 8
const GRID_GAP_PX = 8
const MIN_RATIO = 0.2
const MAX_RATIO = 0.8
const rowRatio = ref(0.5)
const colRatio = ref(0.5)
const gridRef = useTemplateRef('gridRef')
const isResizing = ref(false)
/** CSS calc() — exactly centered in the gap between grid rows/columns. */
function cssSplitPos(ratio: number) {
const totalPad = GRID_PADDING_PX * 2
const pct = ratio * 100
return `calc(${GRID_PADDING_PX}px + (100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_GAP_PX / 2}px)`
}
const rowHandleCssTop = computed(() => cssSplitPos(rowRatio.value))
const colHandleCssLeft = computed(() => cssSplitPos(colRatio.value))
/** For 3 outputs, horizontal handle only spans the left column. */
const rowHandleWidth = computed(() => {
if (outputCount !== 3) return '100%'
const totalPad = GRID_PADDING_PX * 2
const pct = colRatio.value * 100
return `calc((100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_PADDING_PX}px)`
})
function gridStyleForCount(count: number) {
const r = rowRatio.value
const c = colRatio.value
switch (count) {
case 2:
return { gridTemplate: `"a" ${r}fr "b" ${1 - r}fr / 1fr` }
case 3:
return {
gridTemplate: `"a c" ${r}fr "b c" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
case 4:
return {
gridTemplate: `"a b" ${r}fr "c d" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
default:
return { gridTemplate: '"a" 1fr / 1fr' }
}
}
const gridStyle = computed(() => {
if (mobile) {
const rows = AREA_NAMES.slice(0, outputCount)
.map((a) => `"${a}" 1fr`)
.join(' ')
return { gridTemplate: `${rows} / 1fr` }
}
return gridStyleForCount(outputCount)
})
function startResize(
ratioRef: { value: number },
axis: 'row' | 'col',
e: MouseEvent
) {
e.preventDefault()
isResizing.value = true
const startPos = axis === 'row' ? e.clientY : e.clientX
const startRatio = ratioRef.value
const container = gridRef.value
if (!container) return
const size = axis === 'row' ? container.clientHeight : container.clientWidth
function onMouseMove(ev: MouseEvent) {
const pos = axis === 'row' ? ev.clientY : ev.clientX
const delta = pos - startPos
ratioRef.value = Math.max(
MIN_RATIO,
Math.min(MAX_RATIO, startRatio + delta / size)
)
}
function onMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
function onRowResizeStart(e: MouseEvent) {
startResize(rowRatio, 'row', e)
}
function onColResizeStart(e: MouseEvent) {
startResize(colRatio, 'col', e)
}
const cells = computed(() => {
const nodeIds = [...outputsByNode.keys()]
return nodeIds.slice(0, 4).map((nodeId, i) => {
const meta = getOutputLabel(nodeId, i)
return {
nodeId,
label: meta.label,
icon: meta.icon,
output: outputsByNode.get(nodeId),
area: AREA_NAMES[i]
}
})
})
const dragFromIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(index: number, e: DragEvent) {
dragFromIndex.value = index
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(index: number, e: DragEvent) {
e.preventDefault()
dragOverIndex.value = index
}
function onDragLeave() {
dragOverIndex.value = null
}
function onDrop(index: number) {
if (dragFromIndex.value !== null && dragFromIndex.value !== index) {
emit('reorder', dragFromIndex.value, index)
}
dragFromIndex.value = null
dragOverIndex.value = null
}
function onDragEnd() {
dragFromIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div
ref="gridRef"
:class="
cn(
'relative grid min-h-0 flex-1 gap-2 overflow-hidden p-2',
builderMode &&
'pt-[calc(var(--workflow-tabs-height)+var(--spacing)*18)]',
isResizing && 'select-none'
)
"
:style="gridStyle"
>
<div
v-for="(cell, index) in cells"
:key="cell.nodeId"
:class="
cn(
'relative flex min-h-0 min-w-0 flex-col items-center justify-center overflow-hidden rounded-lg',
builderMode
? 'border-2 border-dashed border-warning-background'
: 'border border-border-subtle',
dragOverIndex === index && 'ring-2 ring-primary-background'
)
"
:style="{ gridArea: cell.area }"
:draggable="builderMode"
@dragstart="builderMode && onDragStart(index, $event)"
@dragover="builderMode && onDragOver(index, $event)"
@dragleave="builderMode && onDragLeave()"
@drop="builderMode && onDrop(index)"
@dragend="builderMode && onDragEnd()"
@dblclick="
!mobile && cell.output?.url && emit('openLightbox', cell.output.url)
"
@click="
mobile && cell.output?.url && emit('openLightbox', cell.output.url)
"
>
<div
v-if="builderMode || !cell.output"
class="text-xxs absolute top-0 left-0 z-10 flex items-center gap-1.5 rounded-br-lg bg-base-background/80 px-2.5 py-1 text-muted-foreground"
>
<i :class="cn(cell.icon, 'size-3')" />
{{ cell.label }}
</div>
<MediaOutputPreview
v-if="cell.output"
:output="cell.output"
:mobile
class="size-full object-contain"
/>
<LatentPreview v-else-if="showSkeleton" />
<div
v-else
class="flex size-full flex-col items-center justify-center gap-2 text-muted-foreground"
>
<i :class="cn(cell.icon, 'size-8 opacity-30')" />
<span v-if="builderMode" class="text-xs opacity-50">
{{ t('linearMode.arrange.resultsLabel') }}
</span>
</div>
<div v-if="mobile && cell.output" class="absolute inset-0 z-10" />
</div>
<!-- Horizontal resize handle (row split) -->
<div
v-if="outputCount >= 2 && !builderMode && !mobile"
class="absolute left-0 z-20 h-2 cursor-row-resize"
:style="{ top: rowHandleCssTop, width: rowHandleWidth }"
@mousedown="onRowResizeStart"
>
<div
:class="
cn(
'mx-auto h-px w-full bg-border-subtle/30 transition-colors',
isResizing && 'bg-border-subtle'
)
"
/>
</div>
<!-- Vertical resize handle (column split) -->
<div
v-if="outputCount >= 3 && !builderMode && !mobile"
class="absolute top-0 z-20 h-full w-2 cursor-col-resize"
:style="{ left: colHandleCssLeft }"
@mousedown="onColResizeStart"
>
<div
:class="
cn(
'my-auto h-full w-px bg-border-subtle/30 transition-colors',
isResizing && 'bg-border-subtle'
)
"
/>
</div>
</div>
</template>

View File

@@ -40,6 +40,7 @@ const workflowStore = useWorkflowStore()
const emit = defineEmits<{
updateSelection: [selection: OutputSelection]
openLightbox: [url: string]
}>()
const queueCount = computed(
@@ -361,6 +362,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
:class="itemClass"
@click="store.select(`history:${asset.id}:${key}`)"
@dblclick="output.url && $emit('openLightbox', output.url)"
>
<OutputHistoryItem :output="output" />
</div>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
const { src } = defineProps<{
const { src, mobile = false } = defineProps<{
src: string
mobile?: boolean
label?: string
}>()
@@ -24,7 +25,7 @@ const height = ref('')
}
"
/>
<span class="z-10 self-center">
<span v-if="!mobile" class="z-10 self-end pr-2">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>

View File

@@ -1,6 +1,12 @@
import { t } from '@/i18n'
import type { ResultItemImpl } from '@/stores/queueStore'
export interface MediaOutputItem {
url: string
content?: string
isVideo: boolean
isImage: boolean
mediaType: string
}
type StatItem = { content?: string; iconClass?: string }
export const mediaTypes: Record<string, StatItem> = {
@@ -26,7 +32,7 @@ export const mediaTypes: Record<string, StatItem> = {
}
}
export function getMediaType(output?: ResultItemImpl) {
export function getMediaType(output?: MediaOutputItem) {
if (!output) return ''
if (output.isVideo) return 'video'
if (output.isImage) return 'images'

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { cssSplitPos, gridStyleForCount } from './outputGridUtil'
describe('cssSplitPos', () => {
it('returns calc expression for ratio 0.5', () => {
const result = cssSplitPos(0.5)
expect(result).toBe('calc(8px + (100% - 24px) * 0.5 + 4px)')
})
it('returns calc expression for ratio 0', () => {
const result = cssSplitPos(0)
expect(result).toBe('calc(8px + (100% - 24px) * 0 + 4px)')
})
it('returns calc expression for ratio 1', () => {
const result = cssSplitPos(1)
expect(result).toBe('calc(8px + (100% - 24px) * 1 + 4px)')
})
})
describe('gridStyleForCount', () => {
it('returns single column for count 1', () => {
expect(gridStyleForCount(1, 0.5, 0.5)).toEqual({
gridTemplate: '"a" 1fr / 1fr'
})
})
it('returns two rows for count 2', () => {
const result = gridStyleForCount(2, 0.5, 0.5)
expect(result.gridTemplate).toBe('"a" 0.5fr "b" 0.5fr / 1fr')
})
it('returns L-shape for count 3', () => {
const result = gridStyleForCount(3, 0.5, 0.5)
expect(result.gridTemplate).toBe('"a c" 0.5fr "b c" 0.5fr / 0.5fr 0.5fr')
})
it('returns 2x2 grid for count 4', () => {
const result = gridStyleForCount(4, 0.5, 0.5)
expect(result.gridTemplate).toBe('"a b" 0.5fr "c d" 0.5fr / 0.5fr 0.5fr')
})
it('respects custom row ratio', () => {
const result = gridStyleForCount(2, 0.7, 0.5)
expect(result.gridTemplate).toContain('0.7fr')
expect(result.gridTemplate).toContain(`${1 - 0.7}fr`)
})
it('respects custom column ratio', () => {
const result = gridStyleForCount(4, 0.5, 0.3)
expect(result.gridTemplate).toContain('0.3fr')
expect(result.gridTemplate).toContain(`${1 - 0.3}fr`)
})
it('defaults to single for count 0', () => {
expect(gridStyleForCount(0, 0.5, 0.5)).toEqual({
gridTemplate: '"a" 1fr / 1fr'
})
})
})

View File

@@ -0,0 +1,34 @@
// Matches p-2 and gap-2 on the grid container
const GRID_PADDING_PX = 8
const GRID_GAP_PX = 8
/** CSS calc() — exactly centered in the gap between grid rows/columns. */
export function cssSplitPos(ratio: number): string {
const totalPad = GRID_PADDING_PX * 2
const pct = ratio * 100
return `calc(${GRID_PADDING_PX}px + (100% - ${totalPad + GRID_GAP_PX}px) * ${pct / 100} + ${GRID_GAP_PX / 2}px)`
}
/** Build CSS grid-template for a given output count. */
export function gridStyleForCount(
count: number,
rowRatio: number,
colRatio: number
): { gridTemplate: string } {
const r = rowRatio
const c = colRatio
switch (count) {
case 2:
return { gridTemplate: `"a" ${r}fr "b" ${1 - r}fr / 1fr` }
case 3:
return {
gridTemplate: `"a c" ${r}fr "b c" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
case 4:
return {
gridTemplate: `"a b" ${r}fr "c d" ${1 - r}fr / ${c}fr ${1 - c}fr`
}
default:
return { gridTemplate: '"a" 1fr / 1fr' }
}
}

View File

@@ -53,13 +53,6 @@ export function useOutputHistory(): {
return hasActiveWorkflowJobs()
})
// True when the active workflow has running/pending jobs or in-progress items.
const isWorkflowActive = computed(
() =>
linearStore.activeWorkflowInProgressItems.length > 0 ||
hasActiveWorkflowJobs()
)
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
if (!nodeIds.length) return []
@@ -68,6 +61,13 @@ export function useOutputHistory(): {
)
}
// True when the active workflow has running/pending jobs or in-progress items.
const isWorkflowActive = computed(
() =>
linearStore.activeWorkflowInProgressItems.length > 0 ||
hasActiveWorkflowJobs()
)
const sessionMedia = computed(() => {
const path = workflowStore.activeWorkflow?.path
if (!path) return []
@@ -147,16 +147,26 @@ export function useOutputHistory(): {
[]
).state
asyncRefs.set(item.id, outputRef)
return filterByOutputNodes(outputRef.value)
return outputRef.value
}
function selectFirstHistory() {
const first = outputs.media.value[0]
if (first) {
linearStore.selectAsLatest(`history:${first.id}:0`)
} else {
if (!first) {
linearStore.selectAsLatest(null)
return
}
// Prefer the first output that matches a user-selected output node
const selectedNodeIds = useAppModeStore().selectedOutputs
const outs = allOutputs(first)
const preferredIdx = selectedNodeIds.length
? outs.findIndex((o) =>
selectedNodeIds.some((id) => String(id) === String(o.nodeId))
)
: -1
linearStore.selectAsLatest(
`history:${first.id}:${preferredIdx >= 0 ? preferredIdx : 0}`
)
}
// Resolve in-progress items when history outputs are loaded.

View File

@@ -216,12 +216,14 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
store.loadSelections({
inputs: [
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
[99, 'width']
]
})
],
[]
)
await nextTick()
expect(store.selectedInputs).toEqual([[1, 'prompt']])
})
@@ -232,12 +234,14 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
store.loadSelections({
inputs: [
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
[1, 'deleted_widget']
]
})
],
[]
)
await nextTick()
expect(store.selectedInputs).toEqual([
[1, 'prompt'],
@@ -251,7 +255,8 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
store.loadSelections({ outputs: [1, 99] })
workflowStore.activeWorkflow = workflowWithLinearData([], [1, 99])
await nextTick()
expect(store.selectedOutputs).toEqual([1])
})
@@ -285,7 +290,8 @@ describe('appModeStore', () => {
it('hasOutputs is false when all output nodes are deleted', async () => {
mockResolveNode.mockReturnValue(undefined)
store.loadSelections({ outputs: [10, 20] })
workflowStore.activeWorkflow = workflowWithLinearData([], [10, 20])
await nextTick()
expect(store.selectedOutputs).toEqual([])
expect(store.hasOutputs).toBe(false)
@@ -302,7 +308,17 @@ describe('appModeStore', () => {
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [],
outputs: [1]
outputs: [1],
layoutTemplateId: 'single',
zoneAssignmentsPerTemplate: {},
gridOverridesPerTemplate: {},
runControlsZoneIdPerTemplate: {},
presetStripZoneIdPerTemplate: {},
zoneItemOrderPerTemplate: {},
widgetOverrides: undefined,
presets: undefined,
presetDisplayMode: undefined
})
})
@@ -347,6 +363,7 @@ describe('appModeStore', () => {
it('calls checkState when input is deselected', async () => {
const workflow = createBuilderWorkflow()
workflowStore.activeWorkflow = workflow
await nextTick()
store.selectedInputs.push([42, 'prompt'])
await nextTick()
vi.mocked(workflow.changeTracker!.checkState).mockClear()
@@ -366,11 +383,435 @@ describe('appModeStore', () => {
expect(app.rootGraph.extra.linearData).toEqual({
inputs: [[42, 'prompt']],
outputs: []
outputs: [],
layoutTemplateId: 'single',
zoneAssignmentsPerTemplate: {},
gridOverridesPerTemplate: {},
runControlsZoneIdPerTemplate: {},
presetStripZoneIdPerTemplate: {},
zoneItemOrderPerTemplate: {},
widgetOverrides: undefined,
presets: undefined,
presetDisplayMode: undefined
})
})
})
describe('autoAssignInputs', () => {
it('distributes inputs evenly across dual template zones', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.switchTemplate('dual')
store.selectedInputs.push([1, 'a'], [2, 'b'], [3, 'c'], [4, 'd'])
store.autoAssignInputs()
const zones = new Map<string, number>()
for (const [nodeId, widgetName] of store.selectedInputs) {
const z = store.getZone(nodeId, widgetName)
if (z) zones.set(z, (zones.get(z) ?? 0) + 1)
}
// 4 inputs / 2 zones = 2 each
expect(zones.get('left')).toBe(2)
expect(zones.get('right')).toBe(2)
})
it('skips already-assigned inputs', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.switchTemplate('dual')
store.selectedInputs.push([1, 'a'], [2, 'b'])
store.setZone(1, 'a', 'left')
store.autoAssignInputs()
expect(store.getZone(1, 'a')).toBe('left')
expect(store.getZone(2, 'b')).toBeDefined()
})
it('assigns all to single zone in single template', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([1, 'a'], [2, 'b'])
store.autoAssignInputs()
expect(store.getZone(1, 'a')).toBe('main')
expect(store.getZone(2, 'b')).toBe('main')
})
it('does nothing with empty inputs', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.autoAssignInputs()
expect(Object.keys(store.zoneAssignments)).toHaveLength(0)
})
})
describe('switchTemplate', () => {
it('clears stale zone assignments from old template', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.switchTemplate('dual')
store.selectedInputs.push([1, 'a'])
store.setZone(1, 'a', 'right')
store.switchTemplate('single')
// 'right' is not a valid zone in single template, so cleared + re-assigned
const zone = store.getZone(1, 'a')
expect(zone).toBeDefined()
expect(zone).not.toBe('right')
})
it('preserves valid zone assignments across templates with shared zone ids', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.switchTemplate('dual')
store.selectedInputs.push([1, 'a'])
store.setZone(1, 'a', 'left')
// Switch back to dual — 'left' is still valid
store.switchTemplate('single')
store.switchTemplate('dual')
expect(store.getZone(1, 'a')).toBe('left')
})
it('calls autoAssign after clearing', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
store.selectedInputs.push([1, 'a'])
store.switchTemplate('dual')
expect(store.getZone(1, 'a')).toBeDefined()
})
})
describe('getZoneItems', () => {
it('returns default order: outputs then inputs then run-controls', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const result = store.getZoneItems(
'z1',
[{ nodeId: 10 }],
[{ nodeId: 20, widgetName: 'seed' }],
true
)
expect(result).toEqual(['output:10', 'input:20:seed', 'run-controls'])
})
it('includes preset-strip when requested', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const result = store.getZoneItems('z1', [{ nodeId: 10 }], [], false, true)
expect(result[0]).toBe('preset-strip')
})
it('omits run-controls when not requested', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const result = store.getZoneItems('z1', [], [], false)
expect(result).not.toContain('run-controls')
})
it('returns empty array when no items', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const result = store.getZoneItems('z1', [], [], false, false)
expect(result).toEqual([])
})
it('restores saved order after reorder', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const outputs = [{ nodeId: 1 }, { nodeId: 2 }]
const widgets = [{ nodeId: 3, widgetName: 'cfg' }]
// First call establishes default order
const initial = store.getZoneItems('z1', outputs, widgets, true)
expect(initial).toEqual([
'output:1',
'output:2',
'input:3:cfg',
'run-controls'
])
// Reorder: move run-controls before output:1
store.reorderZoneItem('z1', 'run-controls', 'output:1', 'before', initial)
// Subsequent call should return saved order, not default
const restored = store.getZoneItems('z1', outputs, widgets, true)
expect(restored).toEqual([
'run-controls',
'output:1',
'output:2',
'input:3:cfg'
])
})
it('filters stale keys from saved order and appends new ones', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const outputs = [{ nodeId: 1 }]
const widgets = [{ nodeId: 2, widgetName: 'seed' }]
// Establish and save an order
const initial = store.getZoneItems('z1', outputs, widgets, true)
store.reorderZoneItem('z1', 'run-controls', 'output:1', 'before', initial)
// Now call with different items (node 2 removed, node 3 added)
const newOutputs = [{ nodeId: 1 }]
const newWidgets = [{ nodeId: 3, widgetName: 'steps' }]
const result = store.getZoneItems('z1', newOutputs, newWidgets, true)
// Saved order preserved for surviving keys, stale removed, new appended
expect(result).toEqual(['run-controls', 'output:1', 'input:3:steps'])
})
})
describe('reorderZoneItem', () => {
const outputs = [{ nodeId: 1 }, { nodeId: 2 }]
const widgets = [{ nodeId: 3, widgetName: 'steps' }]
it('moves item before target', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const order = ['output:1', 'output:2', 'input:3:steps']
store.reorderZoneItem('z1', 'input:3:steps', 'output:1', 'before', order)
const result = store.getZoneItems('z1', outputs, widgets, false)
expect(result).toEqual(['input:3:steps', 'output:1', 'output:2'])
})
it('moves item after target', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const order = ['output:1', 'output:2', 'input:3:steps']
store.reorderZoneItem('z1', 'output:1', 'input:3:steps', 'after', order)
const result = store.getZoneItems('z1', outputs, widgets, false)
expect(result).toEqual(['output:2', 'input:3:steps', 'output:1'])
})
it('does not modify order when fromKey equals toKey', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const order = ['output:1', 'output:2', 'input:3:steps']
store.reorderZoneItem('z1', 'output:1', 'output:1', 'before', order)
// getZoneItems should return default order since no saved order was created
const result = store.getZoneItems('z1', outputs, widgets, false)
expect(result).toEqual(['output:1', 'output:2', 'input:3:steps'])
})
it('does not modify order when key is not in order', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const order = ['output:1', 'output:2', 'input:3:steps']
store.reorderZoneItem('z1', 'output:99', 'output:1', 'before', order)
const result = store.getZoneItems('z1', outputs, widgets, false)
expect(result).toEqual(['output:1', 'output:2', 'input:3:steps'])
})
})
describe('moveWidgetItem', () => {
it('moves item from zone to group', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const widgets = [
{ nodeId: 1, widgetName: 'steps' },
{ nodeId: 2, widgetName: 'cfg' }
]
// Establish zone order
store.getZoneItems('z1', [], widgets, false)
store.reorderZoneItem('z1', 'input:1:steps', 'input:2:cfg', 'before', [
'input:1:steps',
'input:2:cfg'
])
const groupId = store.createGroup('z1')
store.moveWidgetItem('input:1:steps', {
kind: 'group',
zoneId: 'z1',
groupId
})
const group = store.inputGroups.find((g) => g.id === groupId)
expect(group?.items).toHaveLength(1)
expect(group?.items[0].key).toBe('input:1:steps')
// Item should no longer be in the zone order
const order = store.getZoneItems('z1', [], widgets, false)
expect(order).not.toContain('input:1:steps')
expect(order).toContain(`group:${groupId}`)
})
it('moves item from group back to zone', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const widgets = [
{ nodeId: 1, widgetName: 'steps' },
{ nodeId: 2, widgetName: 'cfg' }
]
store.reorderZoneItem('z1', 'input:1:steps', 'input:2:cfg', 'before', [
'input:1:steps',
'input:2:cfg'
])
const groupId = store.createGroup('z1')
store.addItemToGroup(groupId, 'input:1:steps', 'z1')
store.moveWidgetItem('input:1:steps', {
kind: 'zone-relative',
zoneId: 'z1',
targetKey: 'input:2:cfg',
edge: 'before'
})
// Group should be deleted (was emptied)
expect(store.inputGroups.find((g) => g.id === groupId)).toBeUndefined()
const order = store.getZoneItems('z1', [], widgets, false)
expect(order).toContain('input:1:steps')
})
it('reorders within same group without duplication', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const groupId = store.createGroup('z1')
store.addItemToGroup(groupId, 'input:1:steps', 'z1')
store.addItemToGroup(groupId, 'input:2:cfg', 'z1')
store.moveWidgetItem('input:2:cfg', {
kind: 'group-relative',
zoneId: 'z1',
groupId,
targetKey: 'input:1:steps',
edge: 'before'
})
const group = store.inputGroups.find((g) => g.id === groupId)
expect(group?.items).toHaveLength(2)
expect(group?.items[0].key).toBe('input:2:cfg')
expect(group?.items[1].key).toBe('input:1:steps')
})
it('creates paired group from zone-pair drop', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const widgets = [
{ nodeId: 1, widgetName: 'steps' },
{ nodeId: 2, widgetName: 'cfg' }
]
store.reorderZoneItem('z1', 'input:1:steps', 'input:2:cfg', 'before', [
'input:1:steps',
'input:2:cfg'
])
store.moveWidgetItem('input:2:cfg', {
kind: 'zone-pair',
zoneId: 'z1',
targetKey: 'input:1:steps'
})
expect(store.inputGroups).toHaveLength(1)
const group = store.inputGroups[0]
expect(group.items).toHaveLength(2)
expect(group.items[0].pairId).toBeDefined()
expect(group.items[0].pairId).toBe(group.items[1].pairId)
// Both items should be out of the zone order
const order = store.getZoneItems('z1', [], widgets, false)
expect(order).not.toContain('input:1:steps')
expect(order).not.toContain('input:2:cfg')
expect(order).toContain(`group:${group.id}`)
})
it('adds 3rd item to group via group-relative', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const groupId = store.createGroup('z1')
store.addItemToGroup(groupId, 'input:1:steps', 'z1')
store.addItemToGroup(groupId, 'input:2:cfg', 'z1')
store.moveWidgetItem('input:3:seed', {
kind: 'group-relative',
zoneId: 'z1',
groupId,
targetKey: 'input:2:cfg',
edge: 'after'
})
const group = store.inputGroups.find((g) => g.id === groupId)
expect(group?.items).toHaveLength(3)
expect(group?.items[2].key).toBe('input:3:seed')
})
it('dropping into empty group keeps the group', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const widgets = [
{ nodeId: 1, widgetName: 'steps' },
{ nodeId: 2, widgetName: 'cfg' }
]
// Establish zone order so items exist
store.reorderZoneItem('z1', 'input:1:steps', 'input:2:cfg', 'before', [
'input:1:steps',
'input:2:cfg'
])
// Create empty group via + button
const groupId = store.createGroup('z1')
expect(store.inputGroups.find((g) => g.id === groupId)).toBeDefined()
expect(store.getZoneItems('z1', [], widgets, false)).toContain(
`group:${groupId}`
)
// Drop item into the empty group
store.moveWidgetItem('input:1:steps', {
kind: 'group',
zoneId: 'z1',
groupId
})
// Group must still exist with the item
const group = store.inputGroups.find((g) => g.id === groupId)
expect(group).toBeDefined()
expect(group?.items).toHaveLength(1)
expect(group?.items[0].key).toBe('input:1:steps')
// Group key must still be in zone order
const order = store.getZoneItems('z1', [], widgets, false)
expect(order).toContain(`group:${groupId}`)
// The moved item must NOT appear as a top-level zone item
expect(order).not.toContain('input:1:steps')
})
it('getZoneItems never returns duplicates', () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
const widgets = [{ nodeId: 1, widgetName: 'steps' }]
// Manually inject duplicate into zone order
store.reorderZoneItem('z1', 'input:1:steps', 'input:1:steps', 'before', [
'input:1:steps',
'input:1:steps'
])
const result = store.getZoneItems('z1', [], widgets, false)
const counts = result.reduce(
(acc, k) => {
acc[k] = (acc[k] ?? 0) + 1
return acc
},
{} as Record<string, number>
)
for (const [key, count] of Object.entries(counts)) {
expect(count, `${key} appears ${count} times`).toBe(1)
}
})
})
describe('setZone', () => {
it('stores assignment and persists', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
store.setZone(1, 'prompt', 'z1')
expect(store.getZone(1, 'prompt')).toBe('z1')
const linearData = app.rootGraph.extra.linearData as Record<
string,
unknown
>
const perTemplate = linearData?.zoneAssignmentsPerTemplate as Record<
string,
Record<string, string>
>
expect(perTemplate?.[store.layoutTemplateId]).toHaveProperty(
'1:prompt',
'z1'
)
})
it('overwrites previous assignment', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
store.setZone(1, 'prompt', 'z1')
store.setZone(1, 'prompt', 'z2')
expect(store.getZone(1, 'prompt')).toBe('z2')
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false

File diff suppressed because it is too large Load Diff

View File

@@ -323,8 +323,9 @@ export function resolveNode(
export function resolveNodeWidget(
nodeId: NodeId,
widgetName?: string,
graph: LGraph = app.rootGraph
graph: LGraph | null | undefined = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
if (!graph) return []
const node = graph.getNodeById(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {

View File

@@ -5,7 +5,6 @@ import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDialogService } from '@/services/dialogService'
@@ -105,22 +104,3 @@ export async function promptWidgetLabel(
placeholder: widget.name
})
}
export async function promptRenameWidget(
widget: IBaseWidget,
node: LGraphNode,
t: (key: string) => string,
parents?: SubgraphNode[]
): Promise<string | null> {
const rawLabel = await promptWidgetLabel(widget, t)
if (rawLabel === null) return null
const normalizedLabel = rawLabel.trim()
if (!normalizedLabel) return null
if (!renameWidget(widget, node, normalizedLabel, parents)) return null
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
return normalizedLabel
}

View File

@@ -13,6 +13,11 @@
<GraphCanvas @ready="onGraphReady" />
</div>
<LinearView v-if="linearMode" />
<LayoutTemplateSelector
v-if="isBuilderMode"
:model-value="appModeStore.layoutTemplateId"
@update:model-value="appModeStore.switchTemplate"
/>
<template v-if="isBuilderMode">
<BuilderToolbar />
<BuilderMenu />
@@ -95,6 +100,8 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { electronAPI } from '@/utils/envUtil'
import BuilderFooterToolbar from '@/components/builder/BuilderFooterToolbar.vue'
import BuilderMenu from '@/components/builder/BuilderMenu.vue'
import LayoutTemplateSelector from '@/components/builder/LayoutTemplateSelector.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
@@ -112,6 +119,7 @@ const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const appModeStore = useAppModeStore()
const { isBuilderMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
@@ -144,18 +152,11 @@ watch(
{ immediate: true }
)
/**
* Reports task completion telemetry to Electron analytics when tasks
* transition from running to history.
*
* No `deep: true` needed — `queueStore.tasks` is a computed that spreads
* three `shallowRef` arrays into a new array on every change, and
* `TaskItemImpl` instances are immutable (replaced, never mutated).
*/
if (isDesktop) {
watch(
() => queueStore.tasks,
(newTasks, oldTasks) => {
// Report tasks that previously running but are now completed (i.e. in history)
const oldRunningTaskIds = new Set(
oldTasks.filter((task) => task.isRunning).map((task) => task.jobId)
)
@@ -170,7 +171,8 @@ if (isDesktop) {
status: task.displayStatus.toLowerCase()
})
})
}
},
{ deep: true }
)
}

View File

@@ -1,78 +1,60 @@
<script setup lang="ts">
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import type { MaybeElement } from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import SidebarAppLayout from '@/components/builder/SidebarAppLayout.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppMode } from '@/composables/useAppMode'
import { useStablePrimeVueSplitterSizer } from '@/composables/useStablePrimeVueSplitterSizer'
import { cn } from '@/utils/tailwindUtil'
import {
BUILDER_MIN_SIZE,
CENTER_PANEL_SIZE,
SIDEBAR_MIN_SIZE,
SIDE_PANEL_SIZE
} from '@/constants/splitterConstants'
import { useAppModeStore } from '@/stores/appModeStore'
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { isBuilderMode, isAppMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
const showLeftBuilder = computed(
() => !sidebarOnLeft.value && isArrangeMode.value
const hasLeftPanel = computed(() => sidebarOnLeft.value && activeTab.value)
const hasRightPanel = computed(() => !sidebarOnLeft.value && activeTab.value)
const hasAppInputsPanel = computed(
() => (isAppMode.value && appModeStore.hasOutputs) || isBuilderMode.value
)
const showRightBuilder = computed(
() => sidebarOnLeft.value && isArrangeMode.value
const isDualLayout = computed(() => appModeStore.layoutTemplateId === 'dual')
const appInputsPanelSize = computed(() =>
isDualLayout.value ? 33 : SIDE_PANEL_SIZE
)
const hasLeftPanel = computed(
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && activeTab.value) ||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
)
const hasRightPanel = computed(
() =>
isArrangeMode.value ||
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
(!sidebarOnLeft.value && activeTab.value)
const appInputsMinSize = computed(() =>
isDualLayout.value ? 20 : SIDEBAR_MIN_SIZE
)
function sidePanelMinSize(isBuilder: boolean, isHidden: boolean) {
if (isBuilder) return BUILDER_MIN_SIZE
if (isHidden) return undefined
return SIDEBAR_MIN_SIZE
}
// Remount splitter when panel structure changes so initializePanels()
// properly sets flexBasis for the current set of panels.
const splitterKey = computed(() => {
const left = hasLeftPanel.value ? 'L' : ''
const right = hasRightPanel.value ? 'R' : ''
return isArrangeMode.value ? 'arrange' : `app-${left}${right}`
const inputs = hasAppInputsPanel.value ? 'I' : ''
const dual = isDualLayout.value ? 'D' : 'S'
return `app-${left}${right}${inputs}${dual}`
})
const leftPanelRef = useTemplateRef<MaybeElement>('leftPanel')
@@ -85,23 +67,10 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
],
[activeTab, splitterKey]
)
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
function dragDrop(e: DragEvent) {
const { dataTransfer } = e
if (!dataTransfer) return
linearWorkflowRef.value?.handleDragDrop(e)
}
</script>
<template>
<MobileDisplay v-if="mobileDisplay" />
<div v-else class="absolute size-full" @dragover.prevent>
<div v-else class="absolute size-full">
<div
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
>
@@ -121,78 +90,52 @@ function dragDrop(e: DragEvent) {
v-if="hasLeftPanel"
ref="leftPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
:style="
showRightBuilder && !activeTab ? { display: 'none' } : undefined
"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
:min-size="SIDEBAR_MIN_SIZE"
class="min-w-78 overflow-hidden outline-none"
>
<AppBuilder v-if="showLeftBuilder" />
<div
v-else-if="sidebarOnLeft && activeTab"
class="size-full overflow-x-hidden border-r border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
<div class="size-full overflow-x-hidden border-r border-border-subtle">
<ExtensionSlot v-if="activeTab" :extension="activeTab" />
</div>
<LinearControls
v-else-if="!isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
/>
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
@drop="dragDrop"
>
<LinearProgressBar
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<LinearPreview
:run-button-click="linearWorkflowRef?.runButtonClick"
:typeform-widget-id="TYPEFORM_WIDGET_ID"
/>
<div class="absolute top-2 left-4.5 z-21">
<AppModeToolbar v-if="!isBuilderMode" />
<LinearPreview />
<div class="pointer-events-none absolute top-2 left-4.5 z-21">
<AppModeToolbar v-if="!isBuilderMode" class="pointer-events-auto" />
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"
ref="rightPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
:min-size="SIDEBAR_MIN_SIZE"
class="min-w-78 overflow-hidden outline-none"
>
<div class="h-full overflow-x-hidden border-l border-border-subtle">
<ExtensionSlot v-if="activeTab" :extension="activeTab" />
</div>
</SplitterPanel>
<SplitterPanel
v-if="hasAppInputsPanel"
:size="appInputsPanelSize"
:min-size="appInputsMinSize"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
'overflow-hidden outline-none',
isDualLayout
? 'max-w-[min(50vw,936px)] min-w-156'
: 'max-w-117 min-w-78'
)
"
>
<AppBuilder v-if="showRightBuilder" />
<LinearControls
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomRightRef) ?? undefined"
/>
<div
v-else-if="activeTab"
class="h-full overflow-x-hidden border-l border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
<div class="h-full overflow-x-hidden border-l border-border-subtle">
<SidebarAppLayout />
</div>
</SplitterPanel>
</Splitter>