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
This commit is contained in:
Koshi
2026-03-20 23:47:53 +01:00
parent 2a788f1f52
commit 6fb8004829
32 changed files with 1730 additions and 149 deletions

View File

@@ -113,10 +113,18 @@ export class AppModeHelper {
* @param widgetName Text shown in the widget label (e.g. "seed").
*/
getAppModeWidgetMenu(widgetName: string): Locator {
// In the zone-based layout, widgets render inside NodeWidgets within zones.
// Fall back to searching the entire linear-widgets container by text.
return this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.locator(`[aria-label*="${widgetName}"]`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
.or(
this.linearWidgets
.locator(`div:has(> div > span:text-is("${widgetName}"))`)
.getByTestId(TestIds.builder.widgetActionsMenu)
.first()
)
}
/**

View File

@@ -124,13 +124,14 @@ 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 from builder preview step', async ({ comfyPage }) => {
const { appMode } = comfyPage
await setupSubgraphBuilder(comfyPage)
await appMode.goToPreview()
// In the new zone layout, rename is done from the inputs step
await appMode.goToInputs()
const menu = appMode.getBuilderPreviewWidgetMenu('seed — New Subgraph')
const menu = appMode.getBuilderInputItemMenu('seed')
await expect(menu).toBeVisible({ timeout: 5000 })
await appMode.renameWidget(menu, 'Preview Seed')

View File

@@ -27,11 +27,11 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
).toBeVisible({ timeout: 5000 })
})
test('Workflow info section visible', async ({ comfyPage }) => {
test('Run controls visible in app mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
comfyPage.page.locator('[data-testid="linear-run-button"]')
).toBeVisible({ timeout: 5000 })
})

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

@@ -264,6 +264,41 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
/>
</DraggableList>
</PropertiesAccordionItem>
<!-- Presets toggle — listed alongside inputs -->
<div
v-if="isSelectInputsMode"
:class="
cn(
'my-2 flex items-center gap-2 rounded-lg p-2',
appModeStore.presetsEnabled
? 'bg-primary-background/30'
: 'bg-primary-background/10 opacity-50'
)
"
>
<i class="icon-[lucide--layers] size-4 shrink-0" />
<span class="flex-1 truncate text-sm">
{{ t('linearMode.presets.label') }}
</span>
<button
class="flex size-6 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 text-muted-foreground hover:text-base-foreground"
@click="
() => {
appModeStore.presetsEnabled = !appModeStore.presetsEnabled
appModeStore.persistLinearData()
}
"
>
<i
:class="
appModeStore.presetsEnabled
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
"
class="size-4"
/>
</button>
</div>
<div
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"

View File

@@ -5,6 +5,7 @@ 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,15 +13,11 @@ 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'
@@ -101,30 +98,11 @@ const mappedSelections = computed((): WidgetEntry[] => {
})
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) {

View File

@@ -11,6 +11,7 @@ import {
import type { ResolvedArrangeWidget } from '@/components/builder/useZoneWidgets'
import {
vOutputDraggable,
vPresetStripDraggable,
vRunControlsDraggable,
vWidgetDraggable,
vZoneDropTarget
@@ -51,6 +52,13 @@ const runControlsZoneId = computed(() => {
return inputZones.at(-1)?.id ?? zones.at(-1)?.id ?? ''
})
const presetStripZoneId = computed(() => {
if (appModeStore.presetStripZoneId) return appModeStore.presetStripZoneId
const zones = template.value.zones
const inputZones = zones.filter((z) => !z.isOutput)
return inputZones.at(0)?.id ?? zones.at(0)?.id ?? ''
})
onMounted(() => appModeStore.autoAssignInputs())
async function runPrompt(e: Event) {
@@ -124,7 +132,9 @@ function getOrderedItems(zoneId: string) {
const outputs = zoneOutputs.value.get(zoneId) ?? []
const widgets = zoneWidgets.value.get(zoneId) ?? []
const hasRun = zoneId === runControlsZoneId.value
return appModeStore.getZoneItems(zoneId, outputs, widgets, hasRun)
const hasPreset =
appModeStore.presetsEnabled && zoneId === presetStripZoneId.value
return appModeStore.getZoneItems(zoneId, outputs, widgets, hasRun, hasPreset)
}
</script>
@@ -181,7 +191,7 @@ function getOrderedItems(zoneId: string) {
@click="appModeStore.toggleZoneAlign(zone.id)"
>
<i
class="icon-[lucide--triangle] size-4 text-muted-foreground/50 transition-transform"
class="icon-[lucide--triangle] size-4 text-muted-foreground/50 transition-transform duration-300 ease-in-out"
:class="
(appModeStore.zoneAlign[zone.id] ?? 'top') === 'bottom'
? 'rotate-180'
@@ -244,6 +254,20 @@ function getOrderedItems(zoneId: string) {
/>
</div>
</div>
<!-- Preset strip -->
<div
v-else-if="itemKey === 'preset-strip'"
v-preset-strip-draggable="zone.id"
v-zone-item-reorder-target="{
itemKey,
zone: zone.id,
order: getOrderedItems(zone.id)
}"
class="flex cursor-grab items-center gap-2 rounded-lg border border-dashed border-primary-background/30 px-3 py-2 text-sm text-muted-foreground [&.reorder-after]:border-b-2 [&.reorder-after]:border-b-primary-background [&.reorder-before]:border-t-2 [&.reorder-before]:border-t-primary-background"
>
<i class="icon-[lucide--layers] size-4" />
{{ t('linearMode.presets.label') }}
</div>
<!-- Run controls -->
<div
v-else-if="itemKey === 'run-controls'"

View File

@@ -220,8 +220,7 @@ function onRowResizeEnd(fractions: number[]) {
:style="{ gridArea: zone.gridArea }"
:class="
cn(
'relative flex flex-col rounded-xl transition-colors',
'overflow-y-auto',
'relative flex flex-col overflow-y-auto rounded-xl transition-colors',
dashed
? 'border-2 border-dashed border-border-subtle'
: filledZones

View File

@@ -0,0 +1,61 @@
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'
}
} satisfies Meta<typeof PresetMenu>
export default meta
type Story = StoryObj<typeof meta>
export const Empty: Story = {
render: () => ({
components: { PresetMenu },
setup() {
return {}
},
template: `
<div class="p-8">
<p class="mb-4 text-sm text-muted-foreground">No presets saved — shows empty state:</p>
<PresetMenu />
</div>
`
})
}
export const WithPresets: Story = {
render: () => ({
components: { PresetMenu },
setup() {
return {}
},
template: `
<div class="p-8">
<p class="mb-4 text-sm text-muted-foreground">Click to see built-in quick presets (Min/Mid/Max) and saved presets:</p>
<PresetMenu />
</div>
`
})
}
export const InToolbar: Story = {
render: () => ({
components: { PresetMenu },
setup() {
return {}
},
template: `
<div class="flex h-12 items-center gap-2 rounded-lg border border-border-subtle bg-comfy-menu-bg px-4 py-2 min-w-80">
<span class="truncate font-bold">my_workflow.json</span>
<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,237 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import Tooltip from '@/components/ui/tooltip/Tooltip.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 { displayMode = 'tabs' } = defineProps<{
displayMode?: PresetDisplayMode
}>()
const { t } = useI18n()
const {
presets,
savePreset,
deletePreset,
renamePreset,
applyPreset,
updatePreset
} = useAppPresets()
const appModeStore = useAppModeStore()
const { presetDisplayMode } = storeToRefs(appModeStore)
const activePresetId = ref<string>('')
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') }
]
const allPresets = computed(() => [
{
id: BUILTIN_PRESET_IDS.min,
name: t('linearMode.presets.builtinMin'),
tooltip: t('linearMode.presets.builtinMinTip'),
builtin: true
},
{
id: BUILTIN_PRESET_IDS.mid,
name: t('linearMode.presets.builtinMid'),
tooltip: t('linearMode.presets.builtinMidTip'),
builtin: true
},
{
id: BUILTIN_PRESET_IDS.max,
name: t('linearMode.presets.builtinMax'),
tooltip: t('linearMode.presets.builtinMaxTip'),
builtin: true
},
...presets.value.map((p) => ({
id: p.id,
name: p.name,
tooltip: p.name,
builtin: false
}))
])
function handleSelect(id: string) {
activePresetId.value = id
applyPreset(id)
}
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())
}
async function handleRename(id: string, currentName: string) {
const name = await useDialogService().prompt({
title: t('g.rename'),
message: '',
defaultValue: currentName
})
if (name?.trim()) renamePreset(id, name.trim())
}
</script>
<template>
<div class="rounded-lg pb-2">
<!-- Label row: "Presets" + hamburger menu matches widget label pattern -->
<div class="mt-1.5 flex min-h-8 items-center gap-1 px-3">
<span class="truncate text-sm/8">
{{ t('linearMode.presets.label') }}
</span>
<div class="flex-1" />
<Popover>
<template #button>
<Button
variant="textonly"
size="icon"
data-testid="preset-actions-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
<template #default="{ close }">
<div class="flex flex-col p-1">
<div
class="my-1 flex cursor-pointer flex-row gap-4 rounded-sm p-2 hover:bg-secondary-background-hover"
@click="
() => {
handleSave()
close()
}
"
>
<i class="icon-[lucide--plus]" />
{{ t('linearMode.presets.save') }}
</div>
<template v-for="preset in presets" :key="preset.id">
<div class="my-1 flex flex-row items-center gap-4 rounded-sm p-2">
<span class="flex-1 truncate text-sm" v-text="preset.name" />
<i
v-tooltip.top="t('linearMode.presets.overwrite')"
class="icon-[lucide--save] cursor-pointer text-muted-foreground transition-transform duration-150 ease-in-out hover:text-base-foreground active:scale-75"
@click="updatePreset(preset.id)"
/>
<i
class="icon-[lucide--pencil] cursor-pointer text-muted-foreground hover:text-base-foreground"
@click="handleRename(preset.id, preset.name)"
/>
<i
class="hover:text-danger icon-[lucide--x] cursor-pointer text-muted-foreground"
@click="
() => {
deletePreset(preset.id)
close()
}
"
/>
</div>
</template>
<div
v-if="presets.length > 0"
class="w-full border-b border-border-subtle"
/>
<div
class="my-1 px-2 text-xs font-medium text-muted-foreground"
v-text="t('linearMode.presets.displayAs')"
/>
<div class="flex gap-1 px-1 pb-1">
<div
v-for="dm in displayModes"
:key="dm.value"
:class="
cn(
'my-1 flex-1 cursor-pointer rounded-sm p-2 text-center text-xs',
presetDisplayMode === dm.value
? 'bg-secondary-background-hover'
: 'hover:bg-secondary-background-hover'
)
"
@click="setDisplayMode(dm.value)"
v-text="dm.label()"
/>
</div>
</div>
</template>
</Popover>
</div>
<!-- Preset switcher -->
<div class="px-3">
<!-- Tabs mode -->
<ToggleGroup
v-if="displayMode === 'tabs'"
type="single"
:model-value="activePresetId"
class="rounded-lg border border-border-subtle p-0.5"
@update:model-value="(v: unknown) => v && handleSelect(String(v))"
>
<Tooltip
v-for="p in allPresets"
:key="p.id"
:text="p.tooltip"
side="bottom"
>
<ToggleGroupItem :value="p.id" size="sm">
{{ p.name }}
</ToggleGroupItem>
</Tooltip>
</ToggleGroup>
<!-- Buttons mode -->
<div v-else-if="displayMode === 'buttons'" class="flex flex-wrap gap-1">
<Button
v-for="p in allPresets"
:key="p.id"
size="sm"
:variant="activePresetId === p.id ? 'secondary' : 'textonly'"
:class="
cn('text-xs', activePresetId === p.id && 'ring-1 ring-primary')
"
@click="handleSelect(p.id)"
>
{{ p.name }}
</Button>
</div>
<!-- Menu/dropdown mode -->
<select
v-else-if="displayMode === 'menu'"
:value="activePresetId"
class="w-full rounded-md border border-border-subtle bg-comfy-menu-bg px-2 py-1 text-sm"
@change="(e) => handleSelect((e.target as HTMLSelectElement).value)"
>
<option value="" disabled>
{{ t('linearMode.presets.label') }}
</option>
<option
v-for="p in allPresets"
:key="p.id"
:value="p.id"
v-text="p.name"
/>
</select>
</div>
</div>
</template>

View File

@@ -0,0 +1,93 @@
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'
export interface DropIndicatorData {
iconClass: string
imageUrl?: string
videoUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => 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
}
}
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

@@ -17,6 +17,12 @@ export interface LayoutTemplate {
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
/** Zone IDs that default to bottom alignment */
defaultBottomAlignZones?: string[]
}
export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
@@ -47,7 +53,10 @@ export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
label: 'linearMode.layout.zones.bottomRight',
gridArea: 'side2'
}
]
],
defaultRunControlsZone: 'side2',
defaultPresetStripZone: 'side1',
defaultBottomAlignZones: ['side2']
},
{
id: 'grid',
@@ -71,7 +80,10 @@ export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
isOutput: true
},
{ id: 'z6', label: 'linearMode.layout.zones.zone6', gridArea: 'z6' }
]
],
defaultRunControlsZone: 'z6',
defaultPresetStripZone: 'z3',
defaultBottomAlignZones: ['z6']
},
{
id: 'sidebar',
@@ -98,7 +110,10 @@ export const LAYOUT_TEMPLATES: LayoutTemplate[] = [
label: 'linearMode.layout.zones.sidebar',
gridArea: 'sb'
}
]
],
defaultRunControlsZone: 'sb',
defaultPresetStripZone: 'sb',
defaultBottomAlignZones: ['sb']
}
]

View File

@@ -24,6 +24,7 @@ function getDragKey(data: Record<string | symbol, unknown>): string | null {
return `input:${data.nodeId}:${data.widgetName}`
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'
return null
}

View File

@@ -26,6 +26,11 @@ interface RunControlsDragData {
sourceZone: string
}
interface PresetStripDragData {
type: 'zone-preset-strip'
sourceZone: string
}
function isWidgetDragData(
data: Record<string | symbol, unknown>
): data is Record<string | symbol, unknown> & WidgetDragData {
@@ -44,6 +49,12 @@ function isRunControlsDragData(
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 DragBindingValue {
nodeId: NodeId
widgetName: string
@@ -135,6 +146,31 @@ export const vRunControlsDraggable: Directive<HTMLElement, string> = {
}
}
type PresetStripDragEl = HTMLElement & {
__dragCleanup?: () => void
__sourceZone?: string
}
export const vPresetStripDraggable: Directive<HTMLElement, string> = {
mounted(el, { value: sourceZone }) {
const typedEl = el as PresetStripDragEl
typedEl.__sourceZone = sourceZone
typedEl.__dragCleanup = draggable({
element: el,
getInitialData: () => ({
type: 'zone-preset-strip',
sourceZone: typedEl.__sourceZone!
})
})
},
updated(el, { value: sourceZone }) {
;(el as PresetStripDragEl).__sourceZone = sourceZone
},
unmounted(el) {
;(el as PresetStripDragEl).__dragCleanup?.()
}
}
export const vZoneDropTarget: Directive<HTMLElement, string> = {
mounted(el, { value: zoneId }) {
const typedEl = el as DragEl
@@ -148,6 +184,8 @@ export const vZoneDropTarget: Directive<HTMLElement, string> = {
if (isOutputDragData(data)) return data.sourceZone !== typedEl.__zoneId
if (isRunControlsDragData(data))
return data.sourceZone !== typedEl.__zoneId
if (isPresetStripDragData(data))
return data.sourceZone !== typedEl.__zoneId
return false
},
onDragEnter: () => el.classList.add('zone-drag-over'),
@@ -161,6 +199,8 @@ export const vZoneDropTarget: Directive<HTMLElement, string> = {
appModeStore.setZone(data.nodeId, OUTPUT_ZONE_KEY, typedEl.__zoneId!)
} else if (isRunControlsDragData(data)) {
appModeStore.setRunControlsZone(typedEl.__zoneId!)
} else if (isPresetStripDragData(data)) {
appModeStore.setPresetStripZone(typedEl.__zoneId!)
}
}
})

View File

@@ -8,7 +8,8 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
vi.mock('@/core/graph/subgraph/promotedWidgetTypes', () => ({
isPromotedWidgetView: vi.fn()
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({
vi.mock('@/lib/litegraph/src/types/globalEnums', async (importOriginal) => ({
...(await importOriginal()),
LGraphEventMode: { ALWAYS: 0 }
}))
vi.mock('@/utils/litegraphUtil', () => ({

View File

@@ -1,11 +1,15 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { DropIndicatorData } from '@/components/builder/dropIndicatorUtil'
import { buildDropIndicator } from '@/components/builder/dropIndicatorUtil'
import { getTemplate } from '@/components/builder/layoutTemplates'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
@@ -23,8 +27,9 @@ export interface ResolvedArrangeWidget {
widget: IBaseWidget
}
interface EnrichedNodeData extends VueNodeData {
export interface EnrichedNodeData extends VueNodeData {
hasErrors: boolean
dropIndicator?: DropIndicatorData
onDragDrop?: LGraphNode['onDragDrop']
onDragOver?: LGraphNode['onDragOver']
}
@@ -82,11 +87,19 @@ export function useArrangeZoneWidgets() {
export function useAppZoneWidgets() {
const appModeStore = useAppModeStore()
const executionErrorStore = useExecutionErrorStore()
const maskEditor = useMaskEditor()
const { t } = useI18n()
const template = computed(
() => getTemplate(appModeStore.layoutTemplateId) ?? getTemplate('sidebar')!
)
const dropIndicatorOptions = computed(() => ({
imageLabel: t('linearMode.dragAndDropImage'),
videoLabel: t('linearMode.dragAndDropVideo'),
openMaskEditor: maskEditor.openMaskEditor
}))
return computed(() => {
const map = new Map<string, EnrichedNodeData[]>()
@@ -96,7 +109,14 @@ export function useAppZoneWidgets() {
appModeStore.getZone,
zone.id
)
map.set(zone.id, resolveAppWidgets(inputs, executionErrorStore))
map.set(
zone.id,
resolveAppWidgets(
inputs,
executionErrorStore,
dropIndicatorOptions.value
)
)
}
return map
@@ -110,7 +130,8 @@ export function useAppZoneWidgets() {
*/
function resolveAppWidgets(
inputs: [NodeId, string][],
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
dropIndicatorOptions?: Parameters<typeof buildDropIndicator>[1]
): EnrichedNodeData[] {
const nodeWidgetMap = new Map<LGraphNode, IBaseWidget[]>()
@@ -130,6 +151,9 @@ function resolveAppWidgets(
const enriched: EnrichedNodeData = {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator: dropIndicatorOptions
? buildDropIndicator(node, dropIndicatorOptions)
: undefined,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}

View File

@@ -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

@@ -0,0 +1,85 @@
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']
}
}
} 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'
}
}
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 LongText: Story = {
render: () => ({
components: { Tooltip, Button },
template: `
<Tooltip text="The random seed used for creating the noise. This controls the reproducibility of generated images." side="top">
<Button>Hover for details</Button>
</Tooltip>
`
})
}
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,47 @@
<script setup lang="ts">
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger
} from 'reka-ui'
const {
text,
side = 'top',
sideOffset = 6,
delayDuration = 400,
disabled = false
} = defineProps<{
text?: string
side?: 'top' | 'bottom' | 'left' | 'right'
sideOffset?: number
delayDuration?: number
disabled?: boolean
}>()
</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="z-1700 max-w-75 rounded-md border border-zinc-700 bg-black px-4 py-2 text-sm/tight font-normal text-white shadow-none"
>
{{ text }}
<TooltipArrow class="fill-black" />
</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

@@ -1188,6 +1188,7 @@
"maskEditor": {
"title": "Mask Editor",
"openMaskEditor": "Open in Mask Editor",
"editMask": "Edit Mask",
"invert": "Invert",
"clear": "Clear",
"undo": "Undo",
@@ -3242,6 +3243,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",
@@ -3291,6 +3293,28 @@
"shiftClickPriority": "Shift+click to prioritize",
"queueFailed": "Failed to queue prompt"
},
"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": {
"focus": "Focus",

View File

@@ -12,6 +12,32 @@ import type {
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
}
/** 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[]
@@ -38,6 +64,15 @@ export interface LinearData {
runControlsZoneIdPerTemplate?: Record<string, string>
zoneItemOrderPerTemplate?: Record<string, Record<string, string[]>>
zoneAlignPerTemplate?: Record<string, Record<string, 'top' | 'bottom'>>
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
}
export interface PendingWarnings {

View File

@@ -4,8 +4,10 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import LayoutZoneGrid from '@/components/builder/LayoutZoneGrid.vue'
import PresetStrip from '@/components/builder/PresetStrip.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import { getTemplate } from '@/components/builder/layoutTemplates'
import type { EnrichedNodeData } from '@/components/builder/useZoneWidgets'
import {
OUTPUT_ZONE_KEY,
useAppZoneWidgets
@@ -48,6 +50,13 @@ const runControlsZoneId = computed(() => {
return inputZones.at(-1)?.id ?? zones.at(-1)?.id ?? ''
})
const presetStripZoneId = computed(() => {
if (appModeStore.presetStripZoneId) return appModeStore.presetStripZoneId
const zones = template.value.zones
const inputZones = zones.filter((z) => !z.isOutput)
return inputZones.at(0)?.id ?? zones.at(0)?.id ?? ''
})
/** Per-zone output results — each zone gets its own assigned outputs. */
const zoneOutputs = computed(() => {
const map = new Map<string, ReturnType<typeof flattenNodeOutput>>()
@@ -99,8 +108,15 @@ function outputItemsForZone(zoneId: string) {
.map((nodeId) => ({ nodeId }))
}
/** Get ordered item keys for a zone respecting builder reorder. */
function getOrderedItems(zoneId: string) {
interface ZoneRenderItem {
key: string
type: 'output' | 'input' | 'run-controls' | 'preset-strip'
/** For input items: the nodeData with widgets filtered to only this group. */
nodeData?: EnrichedNodeData
}
/** Build deduplicated render items for a zone. Groups input keys by node. */
function getZoneRenderItems(zoneId: string): ZoneRenderItem[] {
const outputs = outputItemsForZone(zoneId)
const widgets = (zoneWidgets.value.get(zoneId) ?? []).flatMap((nd) =>
(nd.widgets ?? []).map((w) => ({
@@ -109,7 +125,60 @@ function getOrderedItems(zoneId: string) {
}))
)
const hasRun = zoneId === runControlsZoneId.value
return appModeStore.getZoneItems(zoneId, outputs, widgets, hasRun)
const hasPreset =
appModeStore.presetsEnabled && zoneId === presetStripZoneId.value
const orderedKeys = appModeStore.getZoneItems(
zoneId,
outputs,
widgets,
hasRun,
hasPreset
)
const nodeDataMap = new Map<string, EnrichedNodeData>()
for (const nd of zoneWidgets.value.get(zoneId) ?? []) {
nodeDataMap.set(String(nd.id), nd)
}
const items: ZoneRenderItem[] = []
const renderedNodes = new Set<string>()
for (const key of orderedKeys) {
if (key === 'preset-strip') {
items.push({ key, type: 'preset-strip' })
} else if (key === 'run-controls') {
items.push({ key, type: 'run-controls' })
} else if (key.startsWith('output:')) {
items.push({ key, type: 'output' })
} else if (key.startsWith('input:')) {
const nodeId = key.split(':')[1]
if (renderedNodes.has(nodeId)) continue
renderedNodes.add(nodeId)
const nd = nodeDataMap.get(nodeId)
if (!nd) continue
// Collect all widget names for this node from the ordered keys
const nodeWidgetNames = new Set(
orderedKeys
.filter((k) => k.startsWith(`input:${nodeId}:`))
.map((k) => k.split(':').slice(2).join(':'))
)
// Filter nodeData widgets to only the ones in this zone
const filteredWidgets = (nd.widgets ?? []).filter((w) =>
nodeWidgetNames.has(w.name)
)
items.push({
key: `input-group:${nodeId}`,
type: 'input',
nodeData: { ...nd, widgets: filteredWidgets }
})
}
}
return items
}
/** Zones that have any content (inputs, outputs, or run controls). */
@@ -120,34 +189,17 @@ const filledZones = computed(() => {
const hasOutputs =
zoneOutputs.value.has(zone.id) || zoneOutputNodeCount.value.has(zone.id)
const hasRun = zone.id === runControlsZoneId.value
if (hasWidgets || hasOutputs || hasRun) filled.add(zone.id)
const hasPreset =
appModeStore.presetsEnabled && zone.id === presetStripZoneId.value
if (hasWidgets || hasOutputs || hasRun || hasPreset) filled.add(zone.id)
}
return filled
})
/** Zone IDs that appear in the bottom row of the grid template. */
const bottomRowZoneIds = computed(() => {
const lines = template.value.gridTemplate
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('"'))
if (lines.length === 0) return new Set<string>()
const lastRow = lines[lines.length - 1]
const match = lastRow.match(/"([^"]+)"/)
if (!match) return new Set<string>()
return new Set(match[1].split(/\s+/))
})
/** Border style for a zone using ring (inset, no overflow clipping). */
function zoneBorderClass(zoneId: string): string {
if (!filledZones.value.has(zoneId)) return ''
const isOutput =
zoneOutputs.value.has(zoneId) || zoneOutputNodeCount.value.has(zoneId)
const isBottom = bottomRowZoneIds.value.has(zoneId)
if (isOutput || isBottom)
return 'rounded-xl ring-2 ring-border-subtle ring-inset'
return 'rounded-t-xl ring-2 ring-border-subtle ring-inset'
return 'rounded-xl ring-2 ring-border-subtle ring-inset'
}
async function runPrompt(e: Event) {
@@ -215,6 +267,7 @@ async function runPrompt(e: Event) {
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="nodeData.dropIndicator"
>
<NodeWidgets
:node-data
@@ -242,6 +295,7 @@ async function runPrompt(e: Event) {
</div>
<Button
v-tooltip.top="t('linearMode.arrange.shiftClickPriority')"
data-testid="linear-run-button"
variant="primary"
size="lg"
class="w-full"
@@ -255,13 +309,17 @@ async function runPrompt(e: Event) {
</template>
</div>
<!-- Desktop: grid layout -->
<div v-else class="mx-auto flex size-full max-w-[90%] flex-col py-4">
<div
v-else
data-testid="linear-widgets"
class="mx-auto flex size-full min-h-0 max-w-[90%] flex-col overflow-hidden px-4 pt-12 pb-4"
>
<LayoutZoneGrid
:template="template"
:dashed="false"
:grid-overrides="appModeStore.gridOverrides"
:filled-zones="filledZones"
class="min-h-0 overflow-visible"
class="min-h-0"
>
<template #zone="{ zone }">
<!-- Outer: full cell height, alignment controlled per zone -->
@@ -275,7 +333,7 @@ async function runPrompt(e: Event) {
)
"
>
<!-- Inner: border wraps only the content -->
<!-- Inner: border wraps content, scrolls when needed -->
<div
:class="
cn(
@@ -287,14 +345,14 @@ async function runPrompt(e: Event) {
)
"
>
<!-- Unified item order respects builder reorder -->
<!-- Unified item order deduplicated by node -->
<template
v-for="itemKey in getOrderedItems(zone.id)"
:key="itemKey"
v-for="item in getZoneRenderItems(zone.id)"
:key="item.key"
>
<!-- Output node -->
<div
v-if="itemKey.startsWith('output:')"
v-if="item.type === 'output'"
:class="
cn(
'min-h-0 flex-1 overflow-hidden rounded-lg border border-warning-background/50',
@@ -326,37 +384,32 @@ async function runPrompt(e: Event) {
</p>
</div>
</div>
<!-- Input widget group -->
<template v-else-if="itemKey.startsWith('input:')">
<template
v-for="nodeData of zoneWidgets.get(zone.id) ?? []"
:key="nodeData.id"
>
<DropZone
v-if="
(nodeData.widgets ?? []).some(
(w) => itemKey === `input:${nodeData.id}:${w.name}`
)
"
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors &&
'ring-2 ring-node-stroke-error ring-inset'
)
"
/>
</DropZone>
</template>
</template>
<!-- Input widget group (one render per node, filtered widgets) -->
<DropZone
v-else-if="item.type === 'input' && item.nodeData"
:on-drag-over="item.nodeData.onDragOver"
:on-drag-drop="item.nodeData.onDragDrop"
:drop-indicator="item.nodeData.dropIndicator"
>
<NodeWidgets
:node-data="item.nodeData"
:class="
cn(
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
item.nodeData.hasErrors &&
'ring-2 ring-node-stroke-error ring-inset'
)
"
/>
</DropZone>
<!-- Preset strip -->
<PresetStrip
v-else-if="item.type === 'preset-strip'"
:display-mode="appModeStore.presetDisplayMode"
/>
<!-- Run controls -->
<div
v-else-if="itemKey === 'run-controls'"
v-else-if="item.type === 'run-controls'"
class="flex flex-col gap-2 border-t border-border-subtle pt-3"
>
<div class="flex w-full flex-col gap-1">
@@ -373,6 +426,8 @@ async function runPrompt(e: Event) {
/>
</div>
<Button
v-tooltip.top="t('linearMode.arrange.shiftClickPriority')"
data-testid="linear-run-button"
variant="primary"
size="lg"
class="w-full"

View File

@@ -21,6 +21,7 @@ const {
dropIndicator?: {
iconClass?: string
imageUrl?: string
videoUrl?: string
label?: string
onClick?: (e: MouseEvent) => void
onMaskEdit?: () => void
@@ -100,7 +101,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'
)
"
@@ -112,18 +114,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
@@ -138,20 +157,11 @@ 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
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"
@click.stop="dropIndicator.onMaskEdit()"
>
<i class="icon-[comfy--mask] size-4" />
</button>
<button
type="button"
:aria-label="t('mediaAsset.actions.zoom')"
@@ -162,7 +172,17 @@ const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
<i class="icon-[lucide--zoom-in] size-4" />
</button>
</div>
<button
v-if="dropIndicator.onMaskEdit"
type="button"
class="mx-3 flex w-[calc(100%-1.5rem)] cursor-pointer items-center justify-center gap-2 rounded-lg border border-node-component-border bg-component-node-widget-background p-2 text-sm text-component-node-foreground transition-colors hover:bg-component-node-widget-background-hovered"
@click.stop="dropIndicator.onMaskEdit()"
>
<i class="icon-[comfy--mask] size-4" />
{{ t('maskEditor.editMask') }}
</button>
<ImageLightbox
v-if="dropIndicator.imageUrl"
v-model="lightboxOpen"
:src="dropIndicator.imageUrl"
:alt="dropIndicator.label ?? ''"

View File

@@ -28,7 +28,11 @@ const { t } = useI18n()
const mediaActions = useMediaAssetActions()
const { isBuilderMode, isArrangeMode } = useAppMode()
const appModeStore = useAppModeStore()
const hasLayoutTemplate = computed(() => !!appModeStore.layoutTemplateId)
const hasAppContent = computed(
() =>
appModeStore.selectedInputs.length > 0 ||
appModeStore.selectedOutputs.length > 0
)
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
@@ -148,7 +152,7 @@ async function rerun(e: Event) {
/>
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearArrange v-else-if="isArrangeMode" />
<AppTemplateView v-else-if="hasLayoutTemplate" />
<AppTemplateView v-else-if="hasAppContent" />
<LinearWelcome v-else />
<div
v-if="!mobile"

View File

@@ -103,8 +103,17 @@ const stepValue = computed(() => {
// precision 1 → 0.1, precision 2 → 0.01, etc.
return Number((1 / Math.pow(10, precision.value)).toFixed(precision.value))
}
// Default to 'any' for unrestricted stepping
return 0
// Infer step from the value range — use 0.01 for small ranges (likely floats),
// 1 for integers. This ensures +/- buttons always increment.
const { min: rangeMin, max: rangeMax } = filteredProps.value
if (
rangeMin !== undefined &&
rangeMax !== undefined &&
rangeMax - rangeMin <= 100
) {
return rangeMax - rangeMin <= 1 ? 0.01 : 0.1
}
return 1
})
// Disable grouping separators by default unless explicitly enabled by the node author

View File

@@ -268,8 +268,12 @@ describe('appModeStore', () => {
zoneAssignmentsPerTemplate: {},
gridOverridesPerTemplate: {},
runControlsZoneIdPerTemplate: {},
presetStripZoneIdPerTemplate: {},
zoneItemOrderPerTemplate: {},
zoneAlignPerTemplate: {}
zoneAlignPerTemplate: {},
widgetOverrides: undefined,
presets: undefined,
presetDisplayMode: undefined
})
})
@@ -313,8 +317,12 @@ describe('appModeStore', () => {
zoneAssignmentsPerTemplate: {},
gridOverridesPerTemplate: {},
runControlsZoneIdPerTemplate: {},
presetStripZoneIdPerTemplate: {},
zoneItemOrderPerTemplate: {},
zoneAlignPerTemplate: {}
zoneAlignPerTemplate: {},
widgetOverrides: undefined,
presets: undefined,
presetDisplayMode: undefined
})
})
})

View File

@@ -10,7 +10,12 @@ import type {
import { OUTPUT_ZONE_KEY } from '@/components/builder/useZoneWidgets'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import type {
AppModePreset,
LinearData,
PresetDisplayMode,
WidgetOverride
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -36,6 +41,7 @@ export const useAppModeStore = defineStore('appMode', () => {
>({})
const gridOverridesPerTemplate = reactive<Record<string, GridOverride>>({})
const runControlsZoneIdPerTemplate = reactive<Record<string, string>>({})
const presetStripZoneIdPerTemplate = reactive<Record<string, string>>({})
/** Per-zone item order — unified list of item keys per zone, per template. */
const zoneItemOrderPerTemplate = reactive<
Record<string, Record<string, string[]>>
@@ -44,6 +50,14 @@ export const useAppModeStore = defineStore('appMode', () => {
const zoneAlignPerTemplate = reactive<
Record<string, Record<string, 'top' | 'bottom'>>
>({})
/** Per-widget overrides (min/max, display type). Keyed by `nodeId:widgetName`. */
const widgetOverrides = reactive<Record<string, WidgetOverride>>({})
/** Saved presets for quick input value switching. */
const presets = reactive<AppModePreset[]>([])
/** Whether the preset strip is visible in app mode. */
const presetsEnabled = ref(true)
/** How the preset switcher renders in app view. */
const presetDisplayMode = ref<PresetDisplayMode>('tabs')
const zoneAssignments = computed(
() => zoneAssignmentsPerTemplate[layoutTemplateId.value] ?? {}
@@ -51,15 +65,31 @@ export const useAppModeStore = defineStore('appMode', () => {
const gridOverrides = computed<GridOverride | undefined>(
() => gridOverridesPerTemplate[layoutTemplateId.value]
)
const runControlsZoneId = computed(
() => runControlsZoneIdPerTemplate[layoutTemplateId.value]
)
const runControlsZoneId = computed(() => {
const stored = runControlsZoneIdPerTemplate[layoutTemplateId.value]
if (stored) return stored
const tmpl = getTemplate(layoutTemplateId.value)
return tmpl?.defaultRunControlsZone
})
const presetStripZoneId = computed(() => {
const stored = presetStripZoneIdPerTemplate[layoutTemplateId.value]
if (stored) return stored
const tmpl = getTemplate(layoutTemplateId.value)
return tmpl?.defaultPresetStripZone
})
const zoneItemOrder = computed(
() => zoneItemOrderPerTemplate[layoutTemplateId.value] ?? {}
)
const zoneAlign = computed(
() => zoneAlignPerTemplate[layoutTemplateId.value] ?? {}
)
const zoneAlign = computed(() => {
const stored = zoneAlignPerTemplate[layoutTemplateId.value] ?? {}
const tmpl = getTemplate(layoutTemplateId.value)
if (!tmpl?.defaultBottomAlignZones) return stored
const merged: Record<string, 'top' | 'bottom'> = {}
for (const zoneId of tmpl.defaultBottomAlignZones) {
merged[zoneId] = 'bottom'
}
return { ...merged, ...stored }
})
const hasOutputs = computed(() => !!selectedOutputs.length)
const hasNodes = computed(() => {
// Nodes are not reactive, so trigger recomputation when workflow changes
@@ -107,6 +137,11 @@ export const useAppModeStore = defineStore('appMode', () => {
persistLinearData()
}
function setPresetStripZone(zoneId: string) {
presetStripZoneIdPerTemplate[layoutTemplateId.value] = zoneId
persistLinearData()
}
/** O(n+m) computation of which zones should show outputs. */
const outputZoneIds = computed(() => {
const tmpl = getTemplate(layoutTemplateId.value)
@@ -204,8 +239,13 @@ export const useAppModeStore = defineStore('appMode', () => {
(data?.runControlsZoneId && data?.layoutTemplateId
? { [data.layoutTemplateId]: data.runControlsZoneId }
: undefined),
presetStripZoneIdPerTemplate: data?.presetStripZoneIdPerTemplate,
zoneItemOrderPerTemplate: data?.zoneItemOrderPerTemplate,
zoneAlignPerTemplate: data?.zoneAlignPerTemplate
zoneAlignPerTemplate: data?.zoneAlignPerTemplate,
widgetOverrides: data?.widgetOverrides,
presets: data?.presets,
presetDisplayMode: data?.presetDisplayMode,
presetsEnabled: data?.presetsEnabled
}
}
@@ -244,6 +284,13 @@ export const useAppModeStore = defineStore('appMode', () => {
runControlsZoneIdPerTemplate,
pruned.runControlsZoneIdPerTemplate ?? {}
)
Object.keys(presetStripZoneIdPerTemplate).forEach(
(k) => delete presetStripZoneIdPerTemplate[k]
)
Object.assign(
presetStripZoneIdPerTemplate,
pruned.presetStripZoneIdPerTemplate ?? {}
)
Object.keys(zoneItemOrderPerTemplate).forEach(
(k) => delete zoneItemOrderPerTemplate[k]
)
@@ -255,6 +302,13 @@ export const useAppModeStore = defineStore('appMode', () => {
(k) => delete zoneAlignPerTemplate[k]
)
Object.assign(zoneAlignPerTemplate, pruned.zoneAlignPerTemplate ?? {})
Object.keys(widgetOverrides).forEach((k) => delete widgetOverrides[k])
Object.assign(widgetOverrides, pruned.widgetOverrides ?? {})
presets.splice(0, presets.length, ...(pruned.presets ?? []))
presetDisplayMode.value = pruned.presetDisplayMode ?? 'tabs'
presetsEnabled.value = pruned.presetsEnabled ?? true
}
function resetSelectedToWorkflow() {
@@ -298,10 +352,20 @@ export const useAppModeStore = defineStore('appMode', () => {
JSON.stringify(gridOverridesPerTemplate)
),
runControlsZoneIdPerTemplate: { ...runControlsZoneIdPerTemplate },
presetStripZoneIdPerTemplate: { ...presetStripZoneIdPerTemplate },
zoneItemOrderPerTemplate: JSON.parse(
JSON.stringify(zoneItemOrderPerTemplate)
),
zoneAlignPerTemplate: JSON.parse(JSON.stringify(zoneAlignPerTemplate))
zoneAlignPerTemplate: JSON.parse(JSON.stringify(zoneAlignPerTemplate)),
widgetOverrides: Object.keys(widgetOverrides).length
? JSON.parse(JSON.stringify(widgetOverrides))
: undefined,
presets: presets.length ? JSON.parse(JSON.stringify(presets)) : undefined,
presetDisplayMode:
presetDisplayMode.value !== 'tabs'
? presetDisplayMode.value
: undefined,
presetsEnabled: presetsEnabled.value ? undefined : false
}
}
@@ -357,12 +421,14 @@ export const useAppModeStore = defineStore('appMode', () => {
zoneId: string,
outputs: { nodeId: NodeId }[],
widgets: { nodeId: NodeId; widgetName: string }[],
hasRunControls: boolean
hasRunControls: boolean,
hasPresetStrip: boolean = false
): string[] {
const existing = zoneItemOrder.value[zoneId]
if (existing && existing.length > 0) {
// Filter out stale keys that no longer exist, append new ones
const validKeys = new Set<string>()
if (hasPresetStrip) validKeys.add('preset-strip')
for (const o of outputs) validKeys.add(`output:${o.nodeId}`)
for (const w of widgets)
validKeys.add(`input:${w.nodeId}:${w.widgetName}`)
@@ -375,8 +441,9 @@ export const useAppModeStore = defineStore('appMode', () => {
}
return kept
}
// Default order: outputs, inputs, run controls
// Default order: preset strip, outputs, inputs, run controls
const keys: string[] = []
if (hasPresetStrip) keys.push('preset-strip')
for (const o of outputs) keys.push(`output:${o.nodeId}`)
for (const w of widgets) keys.push(`input:${w.nodeId}:${w.widgetName}`)
if (hasRunControls) keys.push('run-controls')
@@ -412,7 +479,7 @@ export const useAppModeStore = defineStore('appMode', () => {
function toggleZoneAlign(zoneId: string) {
const tid = layoutTemplateId.value
if (!zoneAlignPerTemplate[tid]) zoneAlignPerTemplate[tid] = {}
const current = zoneAlignPerTemplate[tid][zoneId] ?? 'top'
const current = zoneAlign.value[zoneId] ?? 'top'
zoneAlignPerTemplate[tid][zoneId] = current === 'top' ? 'bottom' : 'top'
persistLinearData()
}
@@ -428,6 +495,7 @@ export const useAppModeStore = defineStore('appMode', () => {
layoutTemplateId,
getZoneItems,
outputZoneIds,
persistLinearData,
pruneLinearData,
reorderZoneItem,
resetSelectedToWorkflow,
@@ -439,6 +507,12 @@ export const useAppModeStore = defineStore('appMode', () => {
setZone,
switchTemplate,
toggleZoneAlign,
widgetOverrides,
presets,
presetsEnabled,
presetDisplayMode,
presetStripZoneId,
setPresetStripZone,
zoneAlign,
zoneAssignments
}

View File

@@ -7,7 +7,6 @@ import { computed, useTemplateRef } from 'vue'
import ArrangeLayout from '@/components/builder/ArrangeLayout.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import AppTemplateView from '@/renderer/extensions/linearMode/AppTemplateView.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
@@ -17,7 +16,6 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
import MobileDisplay from '@/renderer/extensions/linearMode/MobileDisplay.vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useStablePrimeVueSplitterSizer } from '@/composables/useStablePrimeVueSplitterSizer'
import {
@@ -28,11 +26,7 @@ import {
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { isBuilderMode } = useAppMode()
const useTemplateLayout = computed(
() => Object.keys(appModeStore.zoneAssignments).length > 0
)
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
@@ -97,10 +91,9 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<ArrangeLayout v-if="isBuilderMode" />
<AppTemplateView v-else-if="useTemplateLayout" />
<LinearPreview v-else />
<div class="absolute top-2 left-4.5 z-21">
<AppModeToolbar v-if="!isBuilderMode" />
<div class="pointer-events-none absolute top-2 left-4.5 z-21">
<AppModeToolbar v-if="!isBuilderMode" class="pointer-events-auto" />
</div>
</SplitterPanel>
<SplitterPanel