mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
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:
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
|
||||
@@ -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 *,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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
|
||||
|
||||
61
src/components/builder/PresetMenu.stories.ts
Normal file
61
src/components/builder/PresetMenu.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
176
src/components/builder/PresetMenu.vue
Normal file
176
src/components/builder/PresetMenu.vue
Normal 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>
|
||||
237
src/components/builder/PresetStrip.vue
Normal file
237
src/components/builder/PresetStrip.vue
Normal 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>
|
||||
93
src/components/builder/dropIndicatorUtil.ts
Normal file
93
src/components/builder/dropIndicatorUtil.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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!)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
85
src/components/ui/tooltip/Tooltip.stories.ts
Normal file
85
src/components/ui/tooltip/Tooltip.stories.ts
Normal 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>
|
||||
`
|
||||
})
|
||||
}
|
||||
47
src/components/ui/tooltip/Tooltip.vue
Normal file
47
src/components/ui/tooltip/Tooltip.vue
Normal 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>
|
||||
307
src/composables/useAppPresets.test.ts
Normal file
307
src/composables/useAppPresets.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
193
src/composables/useAppPresets.ts
Normal file
193
src/composables/useAppPresets.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ?? ''"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user