Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
b2c1f1f51a fix: forward watcher cleanup into searcher
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9551#discussion_r2901111651
2026-03-07 18:39:05 -08:00
bymyself
eb1153d836 fix: update FormDropdown filteredItems when items prop changes
Replace computed itemsKey with a Symbol ref and add a watcher that
re-runs the searcher whenever the items prop changes. This fixes
the Load Video node not showing videos in the Media Assets panel
dropdown when switching between asset types with identical or empty
item sets.
2026-03-07 16:21:38 -08:00
113 changed files with 539 additions and 2201 deletions

View File

@@ -4,7 +4,7 @@
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="w-full text-center text-neutral-400">
<p class="text-neutral-400 w-full text-center">
{{ $t('maintenance.allOk') }}
</p>
</template>
@@ -25,7 +25,7 @@
<!-- Display: Cards -->
<template v-else>
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
@@ -45,8 +45,7 @@ import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { useI18n } from 'vue-i18n'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
@@ -56,7 +55,6 @@ import type {
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const { t } = useI18n()
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
@@ -82,7 +80,8 @@ const executeTask = async (task: MaintenanceTask) => {
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription')
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
})
}

View File

@@ -189,7 +189,8 @@ const completeValidation = async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('maintenance.error.cannotContinue')
detail: t('maintenance.error.cannotContinue'),
life: 5_000
})
}
}

View File

@@ -1,8 +1,8 @@
<template>
<BaseViewTemplate dark hide-language-selector>
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
<div
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
>
<h2 class="text-3xl font-semibold text-neutral-100">
{{ $t('install.helpImprove') }}
@@ -15,7 +15,7 @@
<a
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 underline hover:text-blue-300"
class="text-blue-400 hover:text-blue-300 underline"
>
{{ $t('install.privacyPolicy') }} </a
>.
@@ -33,7 +33,7 @@
}}
</span>
</div>
<div class="flex justify-end pt-6">
<div class="flex pt-6 justify-end">
<Button
:label="$t('g.ok')"
icon="pi pi-check"
@@ -72,7 +72,8 @@ const updateConsent = async () => {
toast.add({
severity: 'error',
summary: t('install.settings.errorUpdatingConsent'),
detail: t('install.settings.errorUpdatingConsentDetail')
detail: t('install.settings.errorUpdatingConsentDetail'),
life: 3000
})
} finally {
isUpdating.value = false

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.42.2",
"version": "1.42.0",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -34,7 +34,17 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<div
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
)
"
>
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -45,7 +55,6 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -114,7 +123,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -136,7 +145,6 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
import { useQueueUIStore } from '@/stores/queueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -160,7 +168,6 @@ const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { toastErrorHandler } = useErrorHandling()
const executionErrorStore = useExecutionErrorStore()
const actionBarButtonStore = useActionBarButtonStore()
const queueUIStore = useQueueUIStore()
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
const { shouldShowRedDot: shouldShowConflictRedDot } =
@@ -175,43 +182,6 @@ const isActionbarEnabled = computed(
const isActionbarFloating = computed(
() => isActionbarEnabled.value && !isActionbarDocked.value
)
/**
* Whether the actionbar container has any visible docked buttons
* (excluding ComfyActionbar, which uses position:fixed when floating
* and does not contribute to the container's visual layout).
*/
const hasDockedButtons = computed(() => {
if (actionBarButtonStore.buttons.length > 0) return true
if (hasLegacyContent.value) return true
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
if (isDesktop && !isIntegratedTabBar.value) return true
if (isCloud && flags.workflowSharingEnabled) return true
if (!isRightSidePanelOpen.value) return true
return false
})
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
const borderClass =
!isActionbarFloating.value && hasAnyError.value
? 'border-destructive-background-hover'
: 'border-interface-stroke'
return cn(base, 'px-2', borderClass)
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
@@ -263,25 +233,6 @@ const rightSidePanelTooltipConfig = computed(() =>
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)
function checkLegacyContent() {
const el = legacyCommandsContainerRef.value
if (!el) {
hasLegacyContent.value = false
return
}
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
hasLegacyContent.value =
el.querySelector(':scope > * > *:not(:empty)') !== null
}
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
childList: true,
subtree: true,
characterData: true
})
onMounted(() => {
if (legacyCommandsContainerRef.value) {
app.menu.element.style.width = 'fit-content'

View File

@@ -119,14 +119,9 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -440,12 +435,7 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
: 'fixed shadow-interface'
)
)
</script>

View File

@@ -10,6 +10,7 @@ import PropertiesAccordionItem from '@/components/rightSidePanel/layout/Properti
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import {
LGraphEventMode,
@@ -24,7 +25,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
@@ -72,12 +73,15 @@ const inputsWithState = computed(() =>
}
}
const input = node.inputs.find((i) => i.widget?.name === widget.name)
const rename = input && (() => renameWidget(widget, input))
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
rename: () => promptRenameWidget(widget, node, t)
rename
}
})
)
@@ -88,6 +92,20 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
const newLabel = await useDialogService().prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
widget.label = newLabel || undefined
input.label = newLabel || undefined
widget.callback?.(widget.value)
useCanvasStore().canvas?.setDirty(true)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -248,7 +266,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--info] bg-muted-foreground" />
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
@@ -303,7 +321,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--info] bg-muted-foreground" />
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>

View File

@@ -138,7 +138,8 @@ onMounted(async () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToFetchLogs')
detail: t('toastMessages.failedToFetchLogs'),
life: 5000
})
}
})

View File

@@ -275,7 +275,8 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false

View File

@@ -98,7 +98,8 @@ async function onConfirmCancel() {
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
isLoading.value = false

View File

@@ -5,7 +5,6 @@ import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type * as ColorUtilModule from '@/utils/colorUtil'
// Import after mocks
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
@@ -63,14 +62,9 @@ vi.mock('@/lib/litegraph/src/litegraph', async () => {
})
// Mock the colorUtil module
vi.mock('@/utils/colorUtil', async () => {
const actual = await vi.importActual<typeof ColorUtilModule>('@/utils/colorUtil')
return {
...actual,
adjustColor: vi.fn((color: string) => color + '_light')
}
})
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_light')
}))
// Mock the litegraphUtil module
vi.mock('@/utils/litegraphUtil', () => ({
@@ -89,25 +83,11 @@ describe('ColorPickerButton', () => {
locale: 'en',
messages: {
en: {
g: {
color: 'Color',
custom: 'Custom',
favorites: 'Favorites',
remove: 'Remove'
},
color: {
noColor: 'No Color',
red: 'Red',
green: 'Green',
blue: 'Blue'
},
shape: {
default: 'Default',
box: 'Box',
CARD: 'Card'
},
modelLibrary: {
sortRecent: 'Recent'
}
}
}
@@ -158,17 +138,4 @@ describe('ColorPickerButton', () => {
await button.trigger('click')
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
})
it('disables favoriting when the selection has no shared applied color', async () => {
canvasStore.selectedItems = [createMockPositionable()]
const wrapper = createWrapper()
await wrapper.find('[data-testid="color-picker-button"]').trigger('click')
expect(
wrapper.find('[data-testid="toggle-favorite-color"]').attributes(
'disabled'
)
).toBeDefined()
})
})

View File

@@ -21,7 +21,7 @@
</Button>
<div
v-if="showColorPicker"
class="absolute -top-10 left-1/2 z-10 min-w-44 -translate-x-1/2 rounded-lg border border-border-default bg-interface-panel-surface p-2 shadow-lg"
class="absolute -top-10 left-1/2 -translate-x-1/2"
>
<SelectButton
:model-value="selectedColorOption"
@@ -41,70 +41,11 @@
/>
</template>
</SelectButton>
<div class="mt-2 flex items-center gap-2">
<ColorPicker
data-testid="custom-color-trigger"
:model-value="currentPickerValue"
format="hex"
:aria-label="t('g.custom')"
class="h-8 w-8 overflow-hidden rounded-md border border-border-default bg-secondary-background"
:pt="{
preview: {
class: '!h-full !w-full !rounded-md !border-none'
}
}"
@update:model-value="onCustomColorUpdate"
/>
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover disabled:cursor-not-allowed disabled:opacity-50"
:title="isCurrentColorFavorite ? t('g.remove') : t('g.favorites')"
data-testid="toggle-favorite-color"
:disabled="!currentAppliedColor"
@click="toggleCurrentColorFavorite"
>
<i
:class="
isCurrentColorFavorite
? 'icon-[lucide--star] text-yellow-500'
: 'icon-[lucide--star-off]'
"
/>
</button>
</div>
<div v-if="favoriteColors.length" class="mt-2 flex flex-wrap gap-1">
<button
v-for="color in favoriteColors"
:key="`favorite-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('g.favorites')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
<div v-if="recentColors.length" class="mt-2 flex flex-wrap gap-1">
<button
v-for="color in recentColors"
:key="`recent-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import SelectButton from 'primevue/selectbutton'
import type { Raw } from 'vue'
import { computed, ref, watch } from 'vue'
@@ -120,26 +61,16 @@ import {
LiteGraph,
isColorable
} from '@/lib/litegraph/src/litegraph'
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor, toHexFromFormat } from '@/utils/colorUtil'
import { adjustColor } from '@/utils/colorUtil'
import { getItemsColorOption } from '@/utils/litegraphUtil'
import { getDefaultCustomNodeColor } from '@/utils/nodeColorCustomization'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const workflowStore = useWorkflowStore()
const { applyCustomColor, getCurrentAppliedColor } = useNodeCustomization()
const {
favoriteColors,
recentColors,
isFavoriteColor,
toggleFavoriteColor
} = useCustomNodeColorSettings()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
@@ -198,25 +129,16 @@ const applyColor = (colorOption: ColorOption | null) => {
}
const currentColorOption = ref<CanvasColorOption | null>(null)
const currentAppliedColor = computed(() => getCurrentAppliedColor())
const currentPickerValue = computed(() =>
(currentAppliedColor.value ?? getDefaultCustomNodeColor()).replace('#', '')
)
const currentColor = computed(() =>
currentColorOption.value
? isLightTheme.value
? toLightThemeColor(currentColorOption.value?.bgcolor)
: currentColorOption.value?.bgcolor
: currentAppliedColor.value
: null
)
const localizedCurrentColorName = computed(() => {
if (currentAppliedColor.value) {
return currentAppliedColor.value.toUpperCase()
}
if (!currentColorOption.value?.bgcolor) {
return null
}
if (!currentColorOption.value?.bgcolor) return null
const colorOption = colorOptions.find(
(option) =>
option.value.dark === currentColorOption.value?.bgcolor ||
@@ -224,26 +146,6 @@ const localizedCurrentColorName = computed(() => {
)
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
})
async function applySavedCustomColor(color: string) {
currentColorOption.value = null
await applyCustomColor(color)
showColorPicker.value = false
}
async function onCustomColorUpdate(value: string) {
await applySavedCustomColor(toHexFromFormat(value, 'hex'))
}
async function toggleCurrentColorFavorite() {
if (!currentAppliedColor.value) return
await toggleFavoriteColor(currentAppliedColor.value)
}
const isCurrentColorFavorite = computed(() =>
isFavoriteColor(currentAppliedColor.value)
)
const updateColorSelectionFromNode = (
newSelectedItems: Raw<Positionable[]>
) => {

View File

@@ -1,99 +0,0 @@
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import { describe, expect, it, vi } from 'vitest'
import type { MenuOption } from '@/composables/graph/useMoreOptionsMenu'
import ColorPickerMenu from './ColorPickerMenu.vue'
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
getCurrentShape: () => null
})
}))
describe('ColorPickerMenu', () => {
it('renders a compact PrimeVue picker panel for custom color submenu entries', async () => {
const onColorPick = vi.fn()
const option: MenuOption = {
label: 'Color',
hasSubmenu: true,
action: () => {},
submenu: [
{
label: 'Custom',
action: () => {},
pickerValue: '112233',
onColorPick
}
]
}
const wrapper = mount(ColorPickerMenu, {
props: { option },
global: {
plugins: [PrimeVue],
stubs: {
Popover: {
template: '<div><slot /></div>'
}
}
}
})
const picker = wrapper.findComponent(ColorPicker)
expect(picker.exists()).toBe(true)
expect(picker.props('modelValue')).toBe('112233')
expect(picker.props('inline')).toBe(true)
expect(wrapper.text()).toContain('#112233')
picker.vm.$emit('update:model-value', 'fedcba')
await wrapper.vm.$nextTick()
expect(onColorPick).toHaveBeenCalledWith('#fedcba')
})
it('shows preset swatches in a compact grid when color presets are available', () => {
const option: MenuOption = {
label: 'Color',
hasSubmenu: true,
action: () => {},
submenu: [
{
label: 'Custom',
action: () => {},
pickerValue: '112233',
onColorPick: vi.fn()
},
{
label: 'Red',
action: () => {},
color: '#ff0000'
},
{
label: 'Green',
action: () => {},
color: '#00ff00'
}
]
}
const wrapper = mount(ColorPickerMenu, {
props: { option },
global: {
plugins: [PrimeVue],
stubs: {
Popover: {
template: '<div><slot /></div>'
}
}
}
})
expect(wrapper.findAll('button[title]').map((node) => node.attributes('title'))).toEqual([
'Red',
'Green'
])
})
})

View File

@@ -20,135 +20,49 @@
}"
>
<div
v-if="isCompactColorPanel"
class="w-[15.5rem] rounded-2xl border border-border-default bg-interface-panel-surface p-2.5 shadow-lg"
>
<div class="mb-2 flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
{{ option.label }}
</p>
<p class="mt-0.5 truncate text-sm font-medium text-base-foreground">
{{ pickerOption?.label ?? 'Custom' }}
</p>
</div>
<div
class="rounded-md border border-border-default bg-secondary-background px-2 py-1 font-mono text-[10px] text-muted-foreground"
>
{{ selectedPickerColor }}
</div>
</div>
<ColorPicker
v-if="pickerOption"
data-testid="color-picker-inline"
:model-value="pickerOption.pickerValue"
inline
format="hex"
:aria-label="pickerOption.label"
class="w-full"
:pt="{
root: { class: '!w-full' },
content: {
class: '!border-none !bg-transparent !p-0 !shadow-none'
},
colorSelector: {
class: '!h-32 !w-full overflow-hidden !rounded-xl'
},
colorBackground: {
class: '!rounded-xl'
},
colorHandle: {
class:
'!h-3.5 !w-3.5 !rounded-full !border-2 !border-black/70 !shadow-sm'
},
hue: {
class:
'!mt-2 !h-3 !overflow-hidden !rounded-full !border !border-border-default'
},
hueHandle: {
class:
'!h-3.5 !w-3.5 !-translate-x-1/2 !rounded-full !border-2 !border-white !shadow-sm'
}
}"
@update:model-value="handleColorPickerUpdate(pickerOption, $event)"
/>
<div
v-if="swatchOptions.length"
class="mt-2 rounded-xl border border-border-default bg-secondary-background p-2"
>
<div class="-mx-0.5 flex gap-1.5 overflow-x-auto px-0.5 pb-0.5">
<button
v-for="subOption in swatchOptions"
:key="subOption.label"
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded-xl border border-transparent transition-transform hover:scale-[1.04] hover:border-border-default hover:bg-secondary-background-hover"
:title="subOption.label"
@click="handleSubmenuClick(subOption)"
>
<div
:class="
cn(
'size-5 rounded-full border transition-shadow',
isSelectedSwatch(subOption)
? 'border-white shadow-[0_0_0_2px_rgba(255,255,255,0.18)]'
: 'border-border-default'
)
"
:style="{ backgroundColor: subOption.color }"
/>
</button>
</div>
</div>
</div>
<div
v-else
:class="
isColorSubmenu
? 'flex flex-col gap-1 p-2'
: 'flex min-w-40 flex-col p-2'
"
>
<template v-for="subOption in option.submenu" :key="subOption.label">
<div
v-for="subOption in option.submenu"
:key="subOption.label"
:class="
cn(
'cursor-pointer rounded-sm hover:bg-secondary-background-hover',
isColorSubmenu
? 'flex size-7 items-center justify-center'
: 'flex items-center gap-2 px-3 py-1.5 text-sm',
subOption.disabled
? 'pointer-events-none cursor-not-allowed text-node-icon-disabled'
: 'hover:bg-secondary-background-hover'
)
"
:title="subOption.label"
@click="handleSubmenuClick(subOption)"
>
<div
:class="
cn(
'cursor-pointer rounded-sm hover:bg-secondary-background-hover',
isColorSubmenu
? 'flex size-7 items-center justify-center'
: 'flex items-center gap-2 px-3 py-1.5 text-sm',
subOption.disabled
? 'pointer-events-none cursor-not-allowed text-node-icon-disabled'
: 'hover:bg-secondary-background-hover'
)
"
:title="subOption.label"
@click="handleSubmenuClick(subOption)"
>
<div
v-if="subOption.color"
class="size-5 rounded-full border border-border-default"
:style="{ backgroundColor: subOption.color }"
v-if="subOption.color"
class="size-5 rounded-full border border-border-default"
:style="{ backgroundColor: subOption.color }"
/>
<template v-else-if="!subOption.color">
<i
v-if="isShapeSelected(subOption)"
class="icon-[lucide--check] size-4 shrink-0"
/>
<template v-else-if="!subOption.color">
<i
v-if="isShapeSelected(subOption)"
class="icon-[lucide--check] size-4 shrink-0"
/>
<div v-else class="w-4 shrink-0" />
<span>{{ subOption.label }}</span>
</template>
</div>
</template>
<div v-else class="w-4 shrink-0" />
<span>{{ subOption.label }}</span>
</template>
</div>
</div>
</Popover>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import ColorPicker from 'primevue/colorpicker'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
@@ -188,43 +102,6 @@ const handleSubmenuClick = (subOption: SubMenuOption) => {
popoverRef.value?.hide()
}
const isPickerOption = (subOption: SubMenuOption): boolean =>
typeof subOption.pickerValue === 'string' &&
typeof subOption.onColorPick === 'function'
const pickerOption = computed(
() => props.option.submenu?.find(isPickerOption) ?? null
)
const swatchOptions = computed(() =>
(props.option.submenu ?? []).filter(
(subOption) => Boolean(subOption.color) && !isPickerOption(subOption)
)
)
const selectedPickerColor = computed(() =>
pickerOption.value?.pickerValue
? `#${pickerOption.value.pickerValue.toUpperCase()}`
: '#000000'
)
const isCompactColorPanel = computed(() => Boolean(pickerOption.value))
async function handleColorPickerUpdate(
subOption: SubMenuOption,
value: string
) {
if (!isPickerOption(subOption) || !value) return
await subOption.onColorPick?.(`#${value}`)
}
function isSelectedSwatch(subOption: SubMenuOption): boolean {
return (
subOption.color?.toLowerCase() === selectedPickerColor.value.toLowerCase()
)
}
const isShapeSelected = (subOption: SubMenuOption): boolean => {
if (subOption.color) return false

View File

@@ -579,7 +579,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.value || t('helpCenter.updateComfyUIFailed')
detail: error.value || t('helpCenter.updateComfyUIFailed'),
life: 5000
})
return
}
@@ -596,7 +597,8 @@ const onUpdateComfyUI = async (): Promise<void> => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: err instanceof Error ? err.message : t('g.unknownError')
detail: err instanceof Error ? err.message : t('g.unknownError'),
life: 5000
})
}
}

View File

@@ -72,12 +72,12 @@
/>
</div>
<SliderControl
v-model="brushSizeSliderValue"
v-model="brushSize"
class="flex-1"
label=""
:min="0"
:max="1"
:step="0.001"
:min="1"
:max="250"
:step="1"
/>
</div>
@@ -182,26 +182,6 @@ const brushSize = computed({
set: (value: number) => store.setBrushSize(value)
})
const rawSliderValue = ref<number | null>(null)
const brushSizeSliderValue = computed({
get: () => {
if (rawSliderValue.value !== null) {
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
if (cachedSize === brushSize.value) {
return rawSliderValue.value
}
}
return Math.log(brushSize.value) / Math.log(250)
},
set: (value: number) => {
rawSliderValue.value = value
const size = Math.round(Math.pow(250, value))
store.setBrushSize(size)
}
})
const brushOpacity = computed({
get: () => store.brushSettings.opacity,
set: (value: number) => store.setBrushOpacity(value)

View File

@@ -15,9 +15,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
@@ -41,6 +42,7 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const dialogService = useDialogService()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -65,8 +67,15 @@ const isCurrentValueDefault = computed(() => {
})
async function handleRename() {
const newLabel = await promptWidgetLabel(widget, t)
if (newLabel !== null) label.value = newLabel
const newLabel = await dialogService.prompt({
title: t('g.rename'),
message: t('g.enterNewNamePrompt'),
defaultValue: widget.label,
placeholder: widget.name
})
if (newLabel === null) return
label.value = newLabel
}
function handleHideInput() {

View File

@@ -1,22 +1,12 @@
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
ColorOption,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ColorOption } from '@/lib/litegraph/src/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import {
applyCustomColorToItems,
getDefaultCustomNodeColor,
getSharedAppliedColor
} from '@/utils/nodeColorCustomization'
import { cn } from '@/utils/tailwindUtil'
import LayoutField from './LayoutField.vue'
@@ -26,7 +16,7 @@ import LayoutField from './LayoutField.vue'
* Here, we only care about the getColorOption and setColorOption methods,
* and do not concern ourselves with other methods.
*/
type PickedNode = LGraphNode | LGraphGroup
type PickedNode = Pick<LGraphNode, 'getColorOption' | 'setColorOption'>
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
@@ -34,14 +24,6 @@ const emit = defineEmits<{ (e: 'changed'): void }>()
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const {
darkerHeader,
favoriteColors,
isFavoriteColor,
recentColors,
rememberRecentColor,
toggleFavoriteColor
} = useCustomNodeColorSettings()
type NodeColorOption = {
name: string
@@ -120,127 +102,43 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
emit('changed')
}
})
const currentAppliedColor = computed(() => getSharedAppliedColor(nodes))
const currentPickerValue = computed(() =>
(currentAppliedColor.value ?? getDefaultCustomNodeColor()).replace('#', '')
)
async function applySavedCustomColor(color: string) {
applyCustomColorToItems(nodes, color, {
darkerHeader: darkerHeader.value
})
await rememberRecentColor(color)
emit('changed')
}
async function toggleCurrentColorFavorite() {
if (!currentAppliedColor.value) return
await toggleFavoriteColor(currentAppliedColor.value)
}
const isCurrentColorFavorite = computed(() =>
isFavoriteColor(currentAppliedColor.value)
)
async function onCustomColorUpdate(value: string) {
await applySavedCustomColor(`#${value}`)
}
</script>
<template>
<LayoutField :label="t('rightSidePanel.color')">
<div class="space-y-2">
<div
class="grid grid-cols-5 justify-items-center gap-1 rounded-lg border-none bg-secondary-background p-1"
<div
class="grid grid-cols-5 justify-items-center gap-1 rounded-lg border-none bg-secondary-background p-1"
>
<button
v-for="option of colorOptions"
:key="option.name"
:class="
cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-transparent text-left ring-0 outline-0',
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
>
<button
v-for="option of colorOptions"
:key="option.name"
:class="
cn(
'flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-transparent text-left ring-0 outline-0',
<div
v-tooltip.top="option.localizedName()"
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
:style="{
backgroundColor: isLightTheme
? option.value.light
: option.value.dark,
'--tw-ring-color':
option.name === nodeColor
? 'bg-interface-menu-component-surface-selected'
: 'hover:bg-interface-menu-component-surface-selected'
)
"
@click="nodeColor = option.name"
>
<div
v-tooltip.top="option.localizedName()"
:class="cn('size-4 rounded-full ring-2 ring-gray-500/10')"
:style="{
backgroundColor: isLightTheme
? option.value.light
: option.value.dark,
'--tw-ring-color':
option.name === nodeColor
? isLightTheme
? option.value.ringLight
: option.value.ringDark
: undefined
}"
:data-testid="option.name"
/>
</button>
</div>
<div class="flex items-center gap-2">
<ColorPicker
:model-value="currentPickerValue"
format="hex"
:aria-label="t('g.custom')"
class="h-8 w-8 overflow-hidden rounded-md border border-border-default bg-secondary-background"
:pt="{
preview: {
class: '!h-full !w-full !rounded-md !border-none'
}
? isLightTheme
? option.value.ringLight
: option.value.ringDark
: undefined
}"
@update:model-value="onCustomColorUpdate"
:data-testid="option.name"
/>
<button
class="flex size-8 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover disabled:cursor-not-allowed disabled:opacity-50"
:title="isCurrentColorFavorite ? t('g.remove') : t('g.favorites')"
:disabled="!currentAppliedColor"
@click="toggleCurrentColorFavorite"
>
<i
:class="
isCurrentColorFavorite
? 'icon-[lucide--star] text-yellow-500'
: 'icon-[lucide--star-off]'
"
/>
</button>
</div>
<div v-if="favoriteColors.length" class="flex flex-wrap gap-1">
<button
v-for="color in favoriteColors"
:key="`favorite-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('g.favorites')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
<div v-if="recentColors.length" class="flex flex-wrap gap-1">
<button
v-for="color in recentColors"
:key="`recent-${color}`"
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
:title="`${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`"
@click="applySavedCustomColor(color)"
>
<div
class="size-4 rounded-full border border-border-default"
:style="{ backgroundColor: color }"
/>
</button>
</div>
</button>
</div>
</LayoutField>
</template>

View File

@@ -33,7 +33,7 @@
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: getAssetDisplayName(item.asset),
name: item.asset.name,
type: getAssetMediaType(item.asset)
})
"
@@ -44,7 +44,7 @@
)
"
:preview-url="getAssetPreviewUrl(item.asset)"
:preview-alt="getAssetDisplayName(item.asset)"
:preview-alt="item.asset.name"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
@@ -133,12 +133,8 @@ const listGridStyle = {
gap: '0.5rem'
}
function getAssetDisplayName(asset: AssetItem): string {
return asset.display_name || asset.name
}
function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(getAssetDisplayName(asset))
return truncateFilename(asset.name)
}
function getAssetMediaType(asset: AssetItem) {

View File

@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
const dialogStore = useDialogStore()
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: asset.display_name || asset.name,
title: asset.name,
component: Load3dViewerContent,
props: {
modelUrl: asset.preview_url || ''
@@ -615,7 +615,8 @@ const enterFolderView = async (asset: AssetItem) => {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail')
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
})
exitFolderView()
}
@@ -661,7 +662,8 @@ const copyJobId = async () => {
toast.add({
severity: 'error',
summary: t('mediaAsset.jobIdToast.error'),
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
life: 3000
})
}
}

View File

@@ -10,9 +10,8 @@
@mouseup="handleMouseUp"
@click="handleClick"
>
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
<i
v-else-if="workflowOption.workflow.initialMode === 'app'"
v-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span
@@ -150,11 +149,6 @@ const shouldShowStatusIndicator = computed(() => {
return false
})
const isBuilderState = computed(() => {
const currentMode = props.workflowOption.workflow.activeMode
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
})
const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})

View File

@@ -1,49 +0,0 @@
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import {
NODE_COLOR_DARKER_HEADER_SETTING_ID,
NODE_COLOR_FAVORITES_SETTING_ID,
NODE_COLOR_RECENTS_SETTING_ID,
normalizeNodeColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from '@/utils/nodeColorCustomization'
export function useCustomNodeColorSettings() {
const settingStore = useSettingStore()
const favoriteColors = computed(() =>
settingStore.get(NODE_COLOR_FAVORITES_SETTING_ID) ?? []
)
const recentColors = computed(() =>
settingStore.get(NODE_COLOR_RECENTS_SETTING_ID) ?? []
)
const darkerHeader = computed(() =>
settingStore.get(NODE_COLOR_DARKER_HEADER_SETTING_ID) ?? true
)
async function rememberRecentColor(color: string) {
const nextColors = upsertRecentNodeColor(recentColors.value, color)
await settingStore.set(NODE_COLOR_RECENTS_SETTING_ID, nextColors)
}
async function toggleFavoriteColor(color: string) {
const nextColors = toggleFavoriteNodeColor(favoriteColors.value, color)
await settingStore.set(NODE_COLOR_FAVORITES_SETTING_ID, nextColors)
}
function isFavoriteColor(color: string | null | undefined) {
if (!color) return false
return favoriteColors.value.includes(normalizeNodeColor(color))
}
return {
favoriteColors,
recentColors,
darkerHeader,
rememberRecentColor,
toggleFavoriteColor,
isFavoriteColor
}
}

View File

@@ -1,159 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueI18nModule from 'vue-i18n'
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type * as NodeColorCustomizationModule from '@/utils/nodeColorCustomization'
const mocks = vi.hoisted(() => ({
refreshCanvas: vi.fn(),
rememberRecentColor: vi.fn().mockResolvedValue(undefined),
selectedItems: [] as unknown[]
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof VueI18nModule>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
selectedItems: mocks.selectedItems,
canvas: {
setDirty: vi.fn()
}
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({
refreshCanvas: mocks.refreshCanvas
})
}))
vi.mock('@/composables/graph/useCustomNodeColorSettings', () => ({
useCustomNodeColorSettings: () => ({
darkerHeader: { value: true },
favoriteColors: { value: ['#abcdef'] },
recentColors: { value: [] },
rememberRecentColor: mocks.rememberRecentColor
})
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
colorOptions: [
{
name: 'noColor',
localizedName: 'color.noColor',
value: {
dark: '#353535',
light: '#6f6f6f'
}
}
],
isLightTheme: { value: false },
shapeOptions: []
})
}))
vi.mock('@/utils/nodeColorCustomization', async () =>
vi.importActual<typeof NodeColorCustomizationModule>(
'@/utils/nodeColorCustomization'
)
)
function createNode() {
return Object.assign(Object.create(LGraphNode.prototype), {
color: undefined,
bgcolor: undefined,
getColorOption: () => null
}) as LGraphNode
}
function createGroup(color?: string) {
return Object.assign(Object.create(LGraphGroup.prototype), {
color,
getColorOption: () => null
}) as LGraphGroup
}
describe('useGroupMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.selectedItems = []
})
it('applies saved custom colors to the group context only', async () => {
const selectedNode = createNode()
const groupContext = createGroup()
mocks.selectedItems = [selectedNode, groupContext]
const { useGroupMenuOptions } = await import('./useGroupMenuOptions')
const { getGroupColorOptions } = useGroupMenuOptions()
const bump = vi.fn()
const colorMenu = getGroupColorOptions(groupContext, bump)
const favoriteEntry = colorMenu.submenu?.find((entry) =>
entry.label.includes('#ABCDEF')
)
expect(favoriteEntry).toBeDefined()
await favoriteEntry?.action()
expect(groupContext.color).toBe('#abcdef')
expect(selectedNode.bgcolor).toBeUndefined()
expect(mocks.refreshCanvas).toHaveBeenCalledOnce()
expect(mocks.rememberRecentColor).toHaveBeenCalledWith('#abcdef')
expect(bump).toHaveBeenCalledOnce()
expect(mocks.rememberRecentColor.mock.invocationCallOrder[0]).toBeLessThan(
bump.mock.invocationCallOrder[0]
)
})
it('seeds the PrimeVue custom picker from the clicked group color', async () => {
const selectedNode = createNode()
selectedNode.bgcolor = '#445566'
const groupContext = createGroup('#112233')
mocks.selectedItems = [selectedNode, groupContext]
const { useGroupMenuOptions } = await import('./useGroupMenuOptions')
const { getGroupColorOptions } = useGroupMenuOptions()
const bump = vi.fn()
const colorMenu = getGroupColorOptions(groupContext, bump)
const customEntry = colorMenu.submenu?.find(
(entry) => entry.label === 'g.custom'
)
expect(customEntry).toBeDefined()
expect(customEntry?.color).toBe('#112233')
expect(customEntry?.pickerValue).toBe('112233')
await customEntry?.onColorPick?.('#fedcba')
expect(groupContext.color).toBe('#fedcba')
expect(selectedNode.bgcolor).toBe('#445566')
expect(mocks.rememberRecentColor).toHaveBeenCalledWith('#fedcba')
expect(mocks.rememberRecentColor.mock.invocationCallOrder[0]).toBeLessThan(
bump.mock.invocationCallOrder[0]
)
})
})

View File

@@ -1,16 +1,10 @@
import { useI18n } from 'vue-i18n'
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
applyCustomColorToItems,
getDefaultCustomNodeColor,
getSharedAppliedColor
} from '@/utils/nodeColorCustomization'
import { useCanvasRefresh } from './useCanvasRefresh'
import type { MenuOption } from './useMoreOptionsMenu'
@@ -25,24 +19,7 @@ export function useGroupMenuOptions() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const canvasRefresh = useCanvasRefresh()
const { darkerHeader, favoriteColors, recentColors, rememberRecentColor } =
useCustomNodeColorSettings()
const {
colorOptions,
isLightTheme,
shapeOptions
} = useNodeCustomization()
const applyCustomColorToGroup = async (
groupContext: LGraphGroup,
color: string
) => {
applyCustomColorToItems([groupContext], color, {
darkerHeader: darkerHeader.value
})
canvasRefresh.refreshCanvas()
await rememberRecentColor(color)
}
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
label: 'Fit Group To Nodes',
@@ -88,68 +65,19 @@ export function useGroupMenuOptions() {
label: t('contextMenu.Color'),
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: (() => {
const currentAppliedColor = getSharedAppliedColor([groupContext])
const presetEntries = colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
submenu: colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () => {
groupContext.color = isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () => {
groupContext.color = isLightTheme.value
? colorOption.value.light
: colorOption.value.dark
canvasRefresh.refreshCanvas()
bump()
}
}))
const presetColors = new Set(
colorOptions.map((colorOption) => colorOption.value.dark.toLowerCase())
)
const customEntries = [
...favoriteColors.value.map((color) => ({
label: `${t('g.favorites')}: ${color.toUpperCase()}`,
color
})),
...recentColors.value.map((color) => ({
label: `${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`,
color
}))
]
.filter((entry, index, entries) => {
return (
entries.findIndex((candidate) => candidate.color === entry.color) ===
index
)
})
.filter((entry) => !presetColors.has(entry.color.toLowerCase()))
.map((entry) => ({
...entry,
action: async () => {
await applyCustomColorToGroup(groupContext, entry.color)
bump()
}
}))
return [
...presetEntries,
...customEntries,
{
label: t('g.custom'),
color: currentAppliedColor ?? undefined,
pickerValue: (currentAppliedColor ?? getDefaultCustomNodeColor()).replace(
'#',
''
),
onColorPick: async (color: string) => {
await applyCustomColorToGroup(groupContext, color)
bump()
},
action: () => {}
}
]
})()
: colorOption.value.dark
canvasRefresh.refreshCanvas()
bump()
}
}))
})
const getGroupModeOptions = (

View File

@@ -41,8 +41,6 @@ export interface SubMenuOption {
action: () => void
color?: string
disabled?: boolean
pickerValue?: string
onColorPick?: (color: string) => void | Promise<void>
}
export enum BadgeVariant {

View File

@@ -11,12 +11,7 @@ import {
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import {
applyCustomColorToItems,
getSharedAppliedColor
} from '@/utils/nodeColorCustomization'
import { useCustomNodeColorSettings } from './useCustomNodeColorSettings'
import { useCanvasRefresh } from './useCanvasRefresh'
interface ColorOption {
@@ -41,12 +36,6 @@ export function useNodeCustomization() {
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const {
favoriteColors,
recentColors,
darkerHeader,
rememberRecentColor
} = useCustomNodeColorSettings()
const canvasRefresh = useCanvasRefresh()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
@@ -112,19 +101,6 @@ export function useNodeCustomization() {
canvasRefresh.refreshCanvas()
}
const applyCustomColor = async (color: string) => {
const normalized = applyCustomColorToItems(
canvasStore.selectedItems,
color,
{
darkerHeader: darkerHeader.value
}
)
canvasRefresh.refreshCanvas()
await rememberRecentColor(normalized)
}
const applyShape = (shapeOption: ShapeOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
(item): item is LGraphNode => item instanceof LGraphNode
@@ -179,20 +155,13 @@ export function useNodeCustomization() {
)
}
const getCurrentAppliedColor = (): string | null =>
getSharedAppliedColor(Array.from(canvasStore.selectedItems))
return {
colorOptions,
shapeOptions,
applyColor,
applyCustomColor,
applyShape,
getCurrentColor,
getCurrentAppliedColor,
getCurrentShape,
favoriteColors,
recentColors,
isLightTheme
}
}

View File

@@ -1,93 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as VueI18nModule from 'vue-i18n'
const mocks = vi.hoisted(() => ({
applyShape: vi.fn(),
applyColor: vi.fn(),
applyCustomColor: vi.fn(),
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn(),
getCurrentAppliedColor: vi.fn<() => string | null>(() => null)
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof VueI18nModule>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key
})
}
})
vi.mock('./useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [],
applyShape: mocks.applyShape,
applyColor: mocks.applyColor,
applyCustomColor: mocks.applyCustomColor,
colorOptions: [
{
name: 'noColor',
localizedName: 'color.noColor',
value: {
dark: '#353535',
light: '#6f6f6f'
}
}
],
favoriteColors: { value: [] },
recentColors: { value: [] },
getCurrentAppliedColor: mocks.getCurrentAppliedColor,
isLightTheme: { value: false }
})
}))
vi.mock('./useSelectedNodeActions', () => ({
useSelectedNodeActions: () => ({
adjustNodeSize: mocks.adjustNodeSize,
toggleNodeCollapse: mocks.toggleNodeCollapse,
toggleNodePin: mocks.toggleNodePin,
toggleNodeBypass: mocks.toggleNodeBypass,
runBranch: mocks.runBranch
})
}))
describe('useNodeMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getCurrentAppliedColor.mockReturnValue(null)
})
it('keeps the custom node color entry unset when there is no shared applied color', async () => {
const { useNodeMenuOptions } = await import('./useNodeMenuOptions')
const { colorSubmenu } = useNodeMenuOptions()
const customEntry = colorSubmenu.value.find(
(entry) => entry.label === 'g.custom'
)
expect(customEntry).toBeDefined()
expect(customEntry?.color).toBeUndefined()
expect(customEntry?.pickerValue).toBe('353535')
})
it('preserves the shared applied color for the custom node color entry', async () => {
mocks.getCurrentAppliedColor.mockReturnValue('#abcdef')
const { useNodeMenuOptions } = await import('./useNodeMenuOptions')
const { colorSubmenu } = useNodeMenuOptions()
const customEntry = colorSubmenu.value.find(
(entry) => entry.label === 'g.custom'
)
expect(customEntry).toBeDefined()
expect(customEntry?.color).toBe('#abcdef')
expect(customEntry?.pickerValue).toBe('abcdef')
})
})

View File

@@ -1,8 +1,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getDefaultCustomNodeColor } from '@/utils/nodeColorCustomization'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
import { useSelectedNodeActions } from './useSelectedNodeActions'
@@ -13,17 +11,8 @@ import type { NodeSelectionState } from './useSelectionState'
*/
export function useNodeMenuOptions() {
const { t } = useI18n()
const {
shapeOptions,
applyShape,
applyColor,
applyCustomColor,
colorOptions,
favoriteColors,
recentColors,
getCurrentAppliedColor,
isLightTheme
} = useNodeCustomization()
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
useNodeCustomization()
const {
adjustNodeSize,
toggleNodeCollapse,
@@ -40,8 +29,7 @@ export function useNodeMenuOptions() {
)
const colorSubmenu = computed(() => {
const currentAppliedColor = getCurrentAppliedColor()
const presetEntries = colorOptions.map((colorOption) => ({
return colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
@@ -49,48 +37,6 @@ export function useNodeMenuOptions() {
action: () =>
applyColor(colorOption.name === 'noColor' ? null : colorOption)
}))
const presetColors = new Set(
colorOptions.map((colorOption) => colorOption.value.dark.toLowerCase())
)
const customEntries = [
...favoriteColors.value.map((color) => ({
label: `${t('g.favorites')}: ${color.toUpperCase()}`,
color
})),
...recentColors.value.map((color) => ({
label: `${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`,
color
}))
]
.filter((entry, index, entries) => {
return (
entries.findIndex((candidate) => candidate.color === entry.color) ===
index
)
})
.filter((entry) => !presetColors.has(entry.color.toLowerCase()))
.map((entry) => ({
...entry,
action: () => {
void applyCustomColor(entry.color)
}
}))
return [
...presetEntries,
...customEntries,
{
label: t('g.custom'),
color: currentAppliedColor ?? undefined,
pickerValue: (currentAppliedColor ?? getDefaultCustomNodeColor()).replace(
'#',
''
),
onColorPick: applyCustomColor,
action: () => {}
}
]
})
const getAdjustSizeOption = (): MenuOption => ({

View File

@@ -397,7 +397,8 @@ export function useCoreCommands(): ComfyCommand[] {
if (app.canvas.empty) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.emptyCanvas')
summary: t('toastMessages.emptyCanvas'),
life: 3000
})
return
}
@@ -556,7 +557,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToQueue'),
detail: t('toastMessages.pleaseSelectOutputNodes')
detail: t('toastMessages.pleaseSelectOutputNodes'),
life: 3000
})
return
}
@@ -569,7 +571,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.failedToQueue'),
detail: t('toastMessages.failedExecutionPathResolution')
detail: t('toastMessages.failedExecutionPathResolution'),
life: 3000
})
return
}
@@ -599,7 +602,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.nothingToGroup'),
detail: t('toastMessages.pleaseSelectNodesToGroup')
detail: t('toastMessages.pleaseSelectNodesToGroup'),
life: 3000
})
return
}
@@ -958,7 +962,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('manager.notAvailable')
detail: t('manager.notAvailable'),
life: 3000
})
return
}
@@ -1043,7 +1048,8 @@ export function useCoreCommands(): ComfyCommand[] {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph')
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
})
return
}
@@ -1252,7 +1258,8 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModels'
})
}),
life: 3000
})
return
}
@@ -1271,7 +1278,8 @@ export function useCoreCommands(): ComfyCommand[] {
summary: t('g.error'),
detail: t('g.commandProhibited', {
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
})
}),
life: 3000
})
return
}

View File

@@ -81,7 +81,8 @@ function getParentNodes(): SubgraphNode[] {
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('subgraphStore.promoteOutsideSubgraph')
detail: t('subgraphStore.promoteOutsideSubgraph'),
life: 2000
})
return []
}

View File

@@ -204,7 +204,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorInstallingUpdate')
detail: t('desktopUpdate.errorInstallingUpdate'),
life: 10_000
})
}
}
@@ -213,7 +214,8 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('desktopUpdate.errorCheckingUpdate')
detail: t('desktopUpdate.errorCheckingUpdate'),
life: 10_000
})
}
}

View File

@@ -1,400 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback
}))
import type { ContextMenu } from './ContextMenu'
import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import { LiteGraph } from './litegraph'
describe('LGraphCanvas.onMenuNodeColors', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('does not add a custom color entry to the legacy submenu', () => {
const node = Object.assign(Object.create(LGraphNode.prototype), {
color: undefined,
bgcolor: undefined
}) as LGraphNode
const canvas = {
selectedItems: new Set([node]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let capturedValues:
| ReadonlyArray<{ content?: string } | string | null>
| undefined
const originalContextMenu = LiteGraph.ContextMenu
class MockContextMenu {
constructor(values: ReadonlyArray<{ content?: string } | string | null>) {
capturedValues = values
}
}
LiteGraph.ContextMenu =
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
node
)
const contents = capturedValues
?.filter(
(value): value is { content?: string } =>
typeof value === 'object' && value !== null
)
.map((value) => value.content ?? '')
expect(contents).not.toEqual(
expect.arrayContaining([expect.stringContaining('Custom...')])
)
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
it('uses group preset colors for legacy group menu swatches', () => {
const group = Object.assign(Object.create(LGraphGroup.prototype), {
color: undefined
}) as LGraphGroup
const canvas = {
selectedItems: new Set([group]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let capturedValues:
| ReadonlyArray<{ content?: string } | string | null>
| undefined
const originalContextMenu = LiteGraph.ContextMenu
class MockContextMenu {
constructor(values: ReadonlyArray<{ content?: string } | string | null>) {
capturedValues = values
}
}
LiteGraph.ContextMenu =
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
group
)
const contents = capturedValues
?.filter(
(value): value is { content?: string } =>
typeof value === 'object' && value !== null
)
.map((value) => value.content ?? '')
expect(contents).toEqual(
expect.arrayContaining([
expect.stringContaining(LGraphCanvas.node_colors.red.groupcolor)
])
)
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
it('sanitizes legacy menu markup for extension-provided labels and colors', () => {
const node = Object.assign(Object.create(LGraphNode.prototype), {
color: undefined,
bgcolor: undefined
}) as LGraphNode
const canvas = {
selectedItems: new Set([node]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let capturedValues:
| ReadonlyArray<{ content?: string } | string | null>
| undefined
const originalContextMenu = LiteGraph.ContextMenu
const originalNodeColors = LGraphCanvas.node_colors
class MockContextMenu {
constructor(values: ReadonlyArray<{ content?: string } | string | null>) {
capturedValues = values
}
}
LiteGraph.ContextMenu =
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
LGraphCanvas.node_colors = {
...originalNodeColors,
'<img src=x onerror=1>': {
color: '#000',
bgcolor: 'not-a-color',
groupcolor: '#fff'
}
}
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
node
)
const escapedEntry = capturedValues
?.filter(
(value): value is { content?: string } =>
typeof value === 'object' && value !== null
)
.map((value) => value.content ?? '')
.find((content) => content.includes('&lt;img src=x onerror=1&gt;'))
expect(escapedEntry).toBeDefined()
expect(escapedEntry).not.toContain('<img src=x onerror=1>')
expect(escapedEntry).not.toContain('background-color:not-a-color')
} finally {
LiteGraph.ContextMenu = originalContextMenu
LGraphCanvas.node_colors = originalNodeColors
}
})
it('applies preset colors to selected nodes and groups in legacy mode', () => {
const graph = {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const node = Object.assign(Object.create(LGraphNode.prototype), {
graph,
color: undefined,
bgcolor: undefined
}) as LGraphNode
const group = Object.assign(Object.create(LGraphGroup.prototype), {
graph,
color: undefined
}) as LGraphGroup
const canvas = {
selectedItems: new Set([node, group]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let callback: ((value: { value?: unknown }) => void) | undefined
const originalContextMenu = LiteGraph.ContextMenu
class MockContextMenu {
constructor(
_values: ReadonlyArray<{ content?: string } | string | null>,
options: { callback?: (value: { value?: unknown }) => void }
) {
callback = options.callback
}
}
LiteGraph.ContextMenu =
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
node
)
callback?.({
value: 'red'
})
expect(node.bgcolor).toBe(LGraphCanvas.node_colors.red.bgcolor)
expect(group.color).toBe(LGraphCanvas.node_colors.red.groupcolor)
expect(graph.beforeChange).toHaveBeenCalled()
expect(graph.afterChange).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
it('does not fan out legacy preset actions to an unrelated single selection', () => {
const graph = {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const selectedNode = Object.assign(Object.create(LGraphNode.prototype), {
graph,
color: undefined,
bgcolor: undefined
}) as LGraphNode
const targetNode = Object.assign(Object.create(LGraphNode.prototype), {
graph,
color: undefined,
bgcolor: undefined
}) as LGraphNode
const canvas = {
selectedItems: new Set([selectedNode]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let callback: ((value: { value?: unknown }) => void) | undefined
const originalContextMenu = LiteGraph.ContextMenu
class MockContextMenu {
constructor(
_values: ReadonlyArray<{ content?: string } | string | null>,
options: { callback?: (value: { value?: unknown }) => void }
) {
callback = options.callback
}
}
LiteGraph.ContextMenu =
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
targetNode
)
callback?.({
value: 'red'
})
expect(targetNode.bgcolor).toBe(LGraphCanvas.node_colors.red.bgcolor)
expect(selectedNode.bgcolor).toBeUndefined()
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
it('keeps legacy group color actions scoped to the clicked group', () => {
const graph = {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const selectedNode = Object.assign(Object.create(LGraphNode.prototype), {
graph,
color: undefined,
bgcolor: undefined
}) as LGraphNode
const targetGroup = Object.assign(Object.create(LGraphGroup.prototype), {
graph,
color: undefined
}) as LGraphGroup
const canvas = {
selectedItems: new Set([selectedNode, targetGroup]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let callback: ((value: { value?: unknown }) => void) | undefined
const originalContextMenu = LiteGraph.ContextMenu
class MockContextMenu {
constructor(
_values: ReadonlyArray<{ content?: string } | string | null>,
options: { callback?: (value: { value?: unknown }) => void }
) {
callback = options.callback
}
}
LiteGraph.ContextMenu =
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
targetGroup
)
callback?.({
value: 'red'
})
expect(targetGroup.color).toBe(LGraphCanvas.node_colors.red.groupcolor)
expect(selectedNode.bgcolor).toBeUndefined()
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
it('balances graph change lifecycle if applying a legacy preset throws', () => {
const graph = {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const node = Object.assign(Object.create(LGraphNode.prototype), {
graph,
setColorOption: vi.fn(() => {
throw new Error('boom')
})
}) as LGraphNode
const canvas = {
selectedItems: new Set([node]),
setDirty: vi.fn()
}
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
let callback:
| ((value: string | { value?: unknown } | null) => void)
| undefined
const originalContextMenu = LiteGraph.ContextMenu
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
class MockContextMenu {
constructor(
_values: ReadonlyArray<{ content?: string } | string | null>,
options: {
callback?: (value: string | { value?: unknown } | null) => void
}
) {
callback = options.callback
}
}
LiteGraph.ContextMenu =
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
try {
LGraphCanvas.onMenuNodeColors(
{ content: 'Colors', value: null },
{} as never,
new MouseEvent('contextmenu'),
{} as ContextMenu<string | null>,
node
)
expect(() => callback?.('red')).not.toThrow()
expect(graph.beforeChange).toHaveBeenCalledOnce()
expect(graph.afterChange).toHaveBeenCalledOnce()
expect(consoleErrorSpy).toHaveBeenCalled()
} finally {
LiteGraph.ContextMenu = originalContextMenu
consoleErrorSpy.mockRestore()
}
})
})

View File

@@ -2,7 +2,6 @@ import { toString } from 'es-toolkit/compat'
import { toValue } from 'vue'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { st } from '@/i18n'
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
@@ -157,52 +156,6 @@ interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
posSizeFix?: Point
}
type LegacyColorTarget = (LGraphNode | LGraphGroup) & IColorable & Positionable
function isLegacyColorTarget(item: unknown): item is LegacyColorTarget {
return item instanceof LGraphNode || item instanceof LGraphGroup
}
function getLegacyColorTargets(target: LegacyColorTarget): LegacyColorTarget[] {
if (target instanceof LGraphGroup) {
return [target]
}
const selected = Array.from(LGraphCanvas.active_canvas.selectedItems).filter(
isLegacyColorTarget
)
return selected.length > 1 && selected.includes(target) ? selected : [target]
}
function createLegacyColorMenuContent(label: string, color?: string): string {
const escapedLabel = label
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
const safeColor = getSafeLegacyMenuColor(color)
if (!safeColor) {
return `<span style='display: block; padding-left: 4px;'>${escapedLabel}</span>`
}
return (
`<span style='display: block; color: #fff; padding-left: 4px;` +
` border-left: 8px solid ${safeColor}; background-color:${safeColor}'>${escapedLabel}</span>`
)
}
function getSafeLegacyMenuColor(color?: string): string | undefined {
if (!color) return undefined
const trimmed = color.trim()
return /^#(?:[\da-fA-F]{3,4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/.test(trimmed)
? trimmed
: undefined
}
interface HasShowSearchCallback {
/** See {@link LGraphCanvas.showSearchBox} */
showSearchBox: (
@@ -1696,70 +1649,61 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
/** @param value Parameter is never used */
static onMenuNodeColors(
_value: IContextMenuValue<string | null>,
value: IContextMenuValue<string | null>,
_options: IContextMenuOptions,
e: MouseEvent,
menu: ContextMenu<string | null>,
node: LGraphNode | LGraphGroup
node: LGraphNode
): boolean {
if (!node || !isLegacyColorTarget(node)) throw 'no node for color'
const values: (IContextMenuValue<string | null> | null)[] = [
{
value: null,
content: createLegacyColorMenuContent(st('color.noColor', 'No color'))
}
]
if (!node) throw 'no node for color'
for (const [presetName, colorOption] of Object.entries(
LGraphCanvas.node_colors
)) {
values.push({
value: presetName,
content: createLegacyColorMenuContent(
st(`color.${presetName}`, presetName),
node instanceof LGraphGroup
? (colorOption.groupcolor ?? colorOption.bgcolor)
: colorOption.bgcolor
)
})
}
new LiteGraph.ContextMenu<string | null>(values, {
event: e,
callback: (value) => {
try {
innerClicked(value)
} catch (error) {
console.error('Failed to apply legacy node color selection.', error)
}
},
parentMenu: menu,
...(node instanceof LGraphNode ? { node } : {})
const values: IContextMenuValue<
string | null,
unknown,
{ value: string | null }
>[] = []
values.push({
value: null,
content:
"<span style='display: block; padding-left: 4px;'>No color</span>"
})
function innerClicked(
value: string | IContextMenuValue<string | null> | null | undefined
) {
if (!node || !isLegacyColorTarget(node)) return
const presetName =
value == null ? null : typeof value === 'string' ? value : value.value
for (const i in LGraphCanvas.node_colors) {
const color = LGraphCanvas.node_colors[i]
value = {
value: i,
content:
`<span style='display: block; color: #999; padding-left: 4px;` +
` border-left: 8px solid ${color.color}; background-color:${color.bgcolor}'>${i}</span>`
}
values.push(value)
}
new LiteGraph.ContextMenu<string | null>(values, {
event: e,
callback: inner_clicked,
parentMenu: menu,
node
})
const canvas = LGraphCanvas.active_canvas
const targets = getLegacyColorTargets(node)
const graphInfo = node instanceof LGraphNode ? node : undefined
function inner_clicked(v: IContextMenuValue<string>) {
if (!node) return
node.graph?.beforeChange(graphInfo)
try {
const colorOption = presetName
? LGraphCanvas.node_colors[presetName]
: null
for (const target of targets) {
target.setColorOption(colorOption)
}
} finally {
node.graph?.afterChange(graphInfo)
const fApplyColor = function (item: IColorable) {
const colorOption = v.value ? LGraphCanvas.node_colors[v.value] : null
item.setColorOption(colorOption)
}
const canvas = LGraphCanvas.active_canvas
if (
!canvas.selected_nodes ||
Object.keys(canvas.selected_nodes).length <= 1
) {
fApplyColor(node)
} else {
for (const i in canvas.selected_nodes) {
fApplyColor(canvas.selected_nodes[i])
}
}
canvas.setDirty(true, true)
}

View File

@@ -952,7 +952,6 @@
"collapseAll": "طي الكل",
"color": "اللون",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "شعار ComfyOrg",
"comingSoon": "قريباً",
"command": "أمر",
@@ -1460,14 +1459,12 @@
"unknownWidget": "عنصر الواجهة غير مرئي"
},
"cancelThisRun": "إلغاء هذا التشغيل",
"deleteAllAssets": "حذف جميع الأصول من هذه الجلسة",
"downloadAll": "تنزيل الكل",
"dragAndDropImage": "اسحب وأسقط صورة",
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
"enterNodeGraph": "دخول مخطط العقد",
"giveFeedback": "إعطاء ملاحظات",
"graphMode": "وضع الرسم البياني",
"hasCreditCost": "يتطلب أرصدة إضافية",
"linearMode": "وضع التطبيق",
"loadTemplate": "تحميل قالب",
"mobileControls": "تعديل وتشغيل",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
"inviteAccepted": "تم قبول الدعوة",
"inviteFailed": "فشل في قبول الدعوة",
"switchFailed": "فشل في تبديل مساحة العمل. يرجى المحاولة مرة أخرى.",
"viewWorkspace": "عرض مساحة العمل"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "تخطيط شريط التبويبات",
"options": {
"Default": "افتراضي",
"Legacy": "تقليدي"
"Integrated": "مُدمج"
},
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
},

View File

@@ -398,10 +398,10 @@
},
"Comfy_UI_TabBarLayout": {
"name": "Tab Bar Layout",
"tooltip": "Controls the elements contained in the integrated tab bar.",
"tooltip": "Controls the layout of the tab bar. \"Integrated\" moves Help and User controls into the tab bar area.",
"options": {
"Default": "Default",
"Legacy": "Legacy"
"Integrated": "Integrated"
}
},
"Comfy_UseNewMenu": {

View File

@@ -952,7 +952,6 @@
"collapseAll": "Colapsar todo",
"color": "Color",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo de ComfyOrg",
"comingSoon": "Próximamente",
"command": "Comando",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Widget no visible"
},
"cancelThisRun": "Cancelar esta ejecución",
"deleteAllAssets": "Eliminar todos los recursos de esta ejecución",
"downloadAll": "Descargar todo",
"dragAndDropImage": "Arrastra y suelta una imagen",
"emptyWorkflowExplanation": "Tu flujo de trabajo está vacío. Necesitas algunos nodos primero para empezar a construir una aplicación.",
"enterNodeGraph": "Entrar al grafo de nodos",
"giveFeedback": "Enviar comentarios",
"graphMode": "Modo gráfico",
"hasCreditCost": "Requiere créditos adicionales",
"linearMode": "Modo App",
"loadTemplate": "Cargar una plantilla",
"mobileControls": "Editar y ejecutar",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "Has sido añadido a {workspaceName}",
"inviteAccepted": "Invitación aceptada",
"inviteFailed": "No se pudo aceptar la invitación",
"switchFailed": "No se pudo cambiar de espacio de trabajo. Por favor, inténtalo de nuevo.",
"viewWorkspace": "Ver espacio de trabajo"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Diseño de barra de pestañas",
"options": {
"Default": "Predeterminado",
"Legacy": "Clásico"
"Integrated": "Integrado"
},
"tooltip": "Controla el diseño de la barra de pestañas. \"Integrado\" mueve los controles de Ayuda y Usuario al área de la barra de pestañas."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "بستن همه",
"color": "رنگ",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "لوگوی ComfyOrg",
"comingSoon": "به‌زودی",
"command": "دستور",
@@ -1460,14 +1459,12 @@
"unknownWidget": "ویجت قابل مشاهده نیست"
},
"cancelThisRun": "لغو این اجرا",
"deleteAllAssets": "حذف همه دارایی‌ها از این اجرا",
"downloadAll": "دانلود همه",
"dragAndDropImage": "تصویر را بکشید و رها کنید",
"emptyWorkflowExplanation": "جریان کاری شما خالی است. ابتدا باید چند node اضافه کنید تا بتوانید یک برنامه بسازید.",
"enterNodeGraph": "ورود به گراف node",
"giveFeedback": "ارسال بازخورد",
"graphMode": "حالت گراف",
"hasCreditCost": "نیازمند اعتبار اضافی",
"linearMode": "حالت برنامه",
"loadTemplate": "بارگذاری قالب",
"mobileControls": "ویرایش و اجرا",
@@ -3388,7 +3385,6 @@
"addedToWorkspace": "شما به {workspaceName} اضافه شدید",
"inviteAccepted": "دعوت پذیرفته شد",
"inviteFailed": "پذیرش دعوت ناموفق بود",
"switchFailed": "تغییر ورک‌اسپیس ناموفق بود. لطفاً دوباره تلاش کنید.",
"viewWorkspace": "مشاهده workspace"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "چیدمان نوار تب",
"options": {
"Default": "پیش‌فرض",
"Legacy": "قدیمی"
"Integrated": "یکپارچه"
},
"tooltip": "چیدمان نوار تب را کنترل می‌کند. «یکپارچه» کنترل‌های راهنما و کاربر را به ناحیه نوار تب منتقل می‌کند."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "Tout réduire",
"color": "Couleur",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo ComfyOrg",
"comingSoon": "Bientôt disponible",
"command": "Commande",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Widget non visible"
},
"cancelThisRun": "Annuler cette exécution",
"deleteAllAssets": "Supprimer toutes les ressources de cette exécution",
"downloadAll": "Tout télécharger",
"dragAndDropImage": "Glissez-déposez une image",
"emptyWorkflowExplanation": "Votre workflow est vide. Vous devez d'abord ajouter des nodes pour commencer à créer une application.",
"enterNodeGraph": "Entrer dans le graphique de nœuds",
"giveFeedback": "Donner un avis",
"graphMode": "Mode graphique",
"hasCreditCost": "Nécessite des crédits supplémentaires",
"linearMode": "Mode App",
"loadTemplate": "Charger un modèle",
"mobileControls": "Éditer & Exécuter",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "Vous avez été ajouté à {workspaceName}",
"inviteAccepted": "Invitation acceptée",
"inviteFailed": "Échec de l'acceptation de l'invitation",
"switchFailed": "Échec du changement despace de travail. Veuillez réessayer.",
"viewWorkspace": "Voir lespace de travail"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Disposition de la barre donglets",
"options": {
"Default": "Par défaut",
"Legacy": "Héritage"
"Integrated": "Intégrée"
},
"tooltip": "Contrôle la disposition de la barre donglets. « Intégrée » déplace les contrôles Aide et Utilisateur dans la zone de la barre donglets."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "すべて折りたたむ",
"color": "色",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrgロゴ",
"comingSoon": "近日公開",
"command": "コマンド",
@@ -1460,14 +1459,12 @@
"unknownWidget": "ウィジェットが表示されていません"
},
"cancelThisRun": "この実行をキャンセル",
"deleteAllAssets": "この実行からすべてのアセットを削除",
"downloadAll": "すべてダウンロード",
"dragAndDropImage": "画像をドラッグ&ドロップ",
"emptyWorkflowExplanation": "ワークフローが空です。アプリを作成するには、まずノードを追加してください。",
"enterNodeGraph": "ノードグラフに入る",
"giveFeedback": "フィードバックを送る",
"graphMode": "グラフモード",
"hasCreditCost": "追加クレジットが必要です",
"linearMode": "アプリモード",
"loadTemplate": "テンプレートを読み込む",
"mobileControls": "編集と実行",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "{workspaceName}に追加されました",
"inviteAccepted": "招待を承諾しました",
"inviteFailed": "招待の承諾に失敗しました",
"switchFailed": "ワークスペースの切り替えに失敗しました。もう一度お試しください。",
"viewWorkspace": "ワークスペースを見る"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "タブバーのレイアウト",
"options": {
"Default": "デフォルト",
"Legacy": "レガシー"
"Integrated": "統合"
},
"tooltip": "タブバーのレイアウトを制御します。「統合」を選択すると、ヘルプとユーザーコントロールがタブバーエリアに移動します。"
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "모두 접기",
"color": "색상",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 로고",
"comingSoon": "곧 출시 예정",
"command": "명령",
@@ -1460,14 +1459,12 @@
"unknownWidget": "위젯이 표시되지 않습니다"
},
"cancelThisRun": "이 실행 취소",
"deleteAllAssets": "이 실행에서 모든 에셋 삭제",
"downloadAll": "모두 다운로드",
"dragAndDropImage": "이미지를 드래그 앤 드롭하세요",
"emptyWorkflowExplanation": "워크플로우가 비어 있습니다. 앱을 만들려면 먼저 노드를 추가해야 합니다.",
"enterNodeGraph": "노드 그래프로 진입",
"giveFeedback": "피드백 보내기",
"graphMode": "그래프 모드",
"hasCreditCost": "추가 크레딧 필요",
"linearMode": "앱 모드",
"loadTemplate": "템플릿 불러오기",
"mobileControls": "편집 및 실행",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "{workspaceName} 워크스페이스에 추가되었습니다",
"inviteAccepted": "초대 수락됨",
"inviteFailed": "초대 수락에 실패했습니다",
"switchFailed": "워크스페이스 전환에 실패했습니다. 다시 시도해 주세요.",
"viewWorkspace": "워크스페이스 보기"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "탭 바 레이아웃",
"options": {
"Default": "기본값",
"Legacy": "레거시"
"Integrated": "통합"
},
"tooltip": "탭 바의 레이아웃을 제어합니다. \"통합\"을 선택하면 도움말과 사용자 컨트롤이 탭 바 영역으로 이동합니다."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "Recolher tudo",
"color": "Cor",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Logo do ComfyOrg",
"comingSoon": "Em breve",
"command": "Comando",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Widget não visível"
},
"cancelThisRun": "Cancelar esta execução",
"deleteAllAssets": "Excluir todos os ativos desta execução",
"downloadAll": "Baixar tudo",
"dragAndDropImage": "Arraste e solte uma imagem",
"emptyWorkflowExplanation": "Seu fluxo de trabalho está vazio. Você precisa adicionar alguns nós primeiro para começar a construir um app.",
"enterNodeGraph": "Entrar no grafo de nós",
"giveFeedback": "Enviar feedback",
"graphMode": "Modo Gráfico",
"hasCreditCost": "Requer créditos adicionais",
"linearMode": "Modo App",
"loadTemplate": "Carregar um modelo",
"mobileControls": "Editar e Executar",
@@ -3388,7 +3385,6 @@
"addedToWorkspace": "Você foi adicionado ao {workspaceName}",
"inviteAccepted": "Convite aceito",
"inviteFailed": "Falha ao aceitar convite",
"switchFailed": "Falha ao alternar o workspace. Por favor, tente novamente.",
"viewWorkspace": "Ver workspace"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Layout da Barra de Abas",
"options": {
"Default": "Padrão",
"Legacy": "Legado"
"Integrated": "Integrado"
},
"tooltip": "Controla o layout da barra de abas. \"Integrado\" move os controles de Ajuda e Usuário para a área da barra de abas."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "Свернуть все",
"color": "Цвет",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "Логотип ComfyOrg",
"comingSoon": "Скоро будет",
"command": "Команда",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Виджет не отображается"
},
"cancelThisRun": "Отменить этот запуск",
"deleteAllAssets": "Удалить все ресурсы из этого запуска",
"downloadAll": "Скачать всё",
"dragAndDropImage": "Перетащите изображение",
"emptyWorkflowExplanation": "Ваш рабочий процесс пуст. Сначала добавьте несколько узлов, чтобы начать создавать приложение.",
"enterNodeGraph": "Войти в граф узлов",
"giveFeedback": "Оставить отзыв",
"graphMode": "Графовый режим",
"hasCreditCost": "Требуются дополнительные кредиты",
"linearMode": "Режим приложения",
"loadTemplate": "Загрузить шаблон",
"mobileControls": "Редактировать и запустить",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "Вы были добавлены в {workspaceName}",
"inviteAccepted": "Приглашение принято",
"inviteFailed": "Не удалось принять приглашение",
"switchFailed": "Не удалось переключить рабочее пространство. Пожалуйста, попробуйте еще раз.",
"viewWorkspace": "Просмотреть рабочее пространство"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Макет панели вкладок",
"options": {
"Default": "По умолчанию",
"Legacy": "Классический"
"Integrated": "Интегрированный"
},
"tooltip": "Управляет расположением панели вкладок. «Интегрированный» перемещает элементы управления Справкой и Пользователем в область панели вкладок."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "Hepsini daralt",
"color": "Renk",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg Logosu",
"comingSoon": "Çok Yakında",
"command": "Komut",
@@ -1460,14 +1459,12 @@
"unknownWidget": "Widget görünür değil"
},
"cancelThisRun": "Bu çalıştırmayı iptal et",
"deleteAllAssets": "Bu çalışmadaki tüm varlıkları sil",
"downloadAll": "Tümünü İndir",
"dragAndDropImage": "Bir görseli sürükleyip bırakın",
"emptyWorkflowExplanation": "Çalışma akışınız boş. Bir uygulama oluşturmaya başlamak için önce bazı düğümler eklemelisiniz.",
"enterNodeGraph": "Düğüm grafiğine gir",
"giveFeedback": "Geri bildirim ver",
"graphMode": "Grafik Modu",
"hasCreditCost": "Ekstra kredi gerektirir",
"linearMode": "Uygulama Modu",
"loadTemplate": "Şablon yükle",
"mobileControls": "Düzenle ve Çalıştır",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "{workspaceName} çalışma alanına eklendiniz",
"inviteAccepted": "Davet kabul edildi",
"inviteFailed": "Davet kabul edilemedi",
"switchFailed": "Çalışma alanı değiştirilemedi. Lütfen tekrar deneyin.",
"viewWorkspace": "Çalışma alanını görüntüle"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "Sekme Çubuğu Düzeni",
"options": {
"Default": "Varsayılan",
"Legacy": "Klasik"
"Integrated": "Entegre"
},
"tooltip": "Sekme çubuğu düzenini kontrol eder. \"Entegre\" seçeneği, Yardım ve Kullanıcı kontrollerini sekme çubuğu alanına taşır."
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "全部摺疊",
"color": "顏色",
"comfy": "Comfy",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 標誌",
"comingSoon": "即將推出",
"command": "指令",
@@ -1460,14 +1459,12 @@
"unknownWidget": "元件不可見"
},
"cancelThisRun": "取消本次執行",
"deleteAllAssets": "刪除本次運行的所有資產",
"downloadAll": "全部下載",
"dragAndDropImage": "拖曳圖片到此",
"emptyWorkflowExplanation": "您的工作流程目前是空的。您需要先新增一些節點,才能開始建立應用程式。",
"enterNodeGraph": "進入節點圖",
"giveFeedback": "提供回饋",
"graphMode": "圖形模式",
"hasCreditCost": "需要額外點數",
"linearMode": "App 模式",
"loadTemplate": "載入範本",
"mobileControls": "編輯與執行",
@@ -3376,7 +3373,6 @@
"addedToWorkspace": "你已被加入 {workspaceName}",
"inviteAccepted": "已接受邀請",
"inviteFailed": "接受邀請失敗",
"switchFailed": "切換工作區失敗。請再試一次。",
"viewWorkspace": "檢視工作區"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "分頁列佈局",
"options": {
"Default": "預設",
"Legacy": "傳統"
"Integrated": "整合"
},
"tooltip": "控制分頁列的佈局。「整合」會將說明和使用者控制項移至分頁列區域。"
},

View File

@@ -952,7 +952,6 @@
"collapseAll": "全部折叠",
"color": "颜色",
"comfy": "舒适",
"comfyCloud": "Comfy Cloud",
"comfyOrgLogoAlt": "ComfyOrg 徽标",
"comingSoon": "即将推出",
"command": "指令",
@@ -1460,14 +1459,12 @@
"unknownWidget": "组件不可见"
},
"cancelThisRun": "取消本次运行",
"deleteAllAssets": "删除本次运行的所有资源",
"downloadAll": "全部下载",
"dragAndDropImage": "拖拽图片到此处",
"emptyWorkflowExplanation": "你的工作流为空。你需要先添加一些节点,才能开始构建应用。",
"enterNodeGraph": "进入节点图",
"giveFeedback": "提供反馈",
"graphMode": "图形模式",
"hasCreditCost": "需要额外积分",
"linearMode": "App 模式",
"loadTemplate": "加载模板",
"mobileControls": "编辑与运行",
@@ -3388,7 +3385,6 @@
"addedToWorkspace": "您已被加入 {workspaceName}",
"inviteAccepted": "邀请已接受",
"inviteFailed": "接受邀请失败",
"switchFailed": "切换工作区失败。请重试。",
"viewWorkspace": "查看工作区"
},
"workspaceAuth": {

View File

@@ -400,7 +400,7 @@
"name": "标签栏布局",
"options": {
"Default": "默认",
"Legacy": "传统"
"Integrated": "集成"
},
"tooltip": "控制标签栏的布局。“集成”会将帮助和用户控件移动到标签栏区域。"
},

View File

@@ -186,7 +186,7 @@ const tooltipDelay = computed<number>(() =>
const { isLoading, error } = useImage({
src: asset.preview_url ?? '',
alt: asset.display_name || asset.name
alt: asset.name
})
function handleSelect() {

View File

@@ -5,7 +5,7 @@
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.display_name || asset.name,
name: asset.name,
type: fileKind
})
: $t('assetBrowser.ariaLabel.loadingAsset')
@@ -225,7 +225,7 @@ const canInspect = computed(() => isPreviewableMediaType(fileKind.value))
// Get filename without extension
const fileName = computed(() => {
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
return getFilenameDetails(asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
@@ -234,7 +234,6 @@ const adaptedAsset = computed(() => {
return {
id: asset.id,
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.preview_url || '',
size: asset.size,

View File

@@ -6,7 +6,7 @@
<img
v-if="!error"
:src="asset.src"
:alt="asset.display_name || asset.name"
:alt="asset.name"
class="size-full object-contain transition-transform duration-300 group-hover:scale-105 group-data-[selected=true]:scale-105"
/>
<div
@@ -34,7 +34,7 @@ const emit = defineEmits<{
const { state, error, isReady } = useImage({
src: asset.src ?? '',
alt: asset.display_name || asset.name
alt: asset.name
})
whenever(

View File

@@ -39,7 +39,6 @@ export function mapTaskOutputToAssetItem(
return {
id: taskItem.jobId,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: taskItem.executionStartTimestamp
? new Date(taskItem.executionStartTimestamp).toISOString()

View File

@@ -68,7 +68,7 @@ export function useMediaAssetActions() {
if (!targetAsset) return
try {
const filename = targetAsset.display_name || targetAsset.name
const filename = targetAsset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
@@ -84,7 +84,8 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
detail: t('g.failedToDownloadImage'),
life: 3000
})
}
}
@@ -109,7 +110,7 @@ export function useMediaAssetActions() {
try {
assets.forEach((asset) => {
const filename = asset.display_name || asset.name
const filename = asset.name
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})
@@ -125,7 +126,8 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage')
detail: t('g.failedToDownloadImage'),
life: 3000
})
}
}
@@ -180,7 +182,8 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('exportToast.exportFailedSingle')
detail: t('exportToast.exportFailedSingle'),
life: 3000
})
}
}
@@ -235,7 +238,8 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.nodeTypeNotFound', { nodeType })
detail: t('mediaAsset.nodeTypeNotFound', { nodeType }),
life: 3000
})
return
}
@@ -248,7 +252,8 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.failedToCreateNode')
detail: t('mediaAsset.failedToCreateNode'),
life: 3000
})
return
}
@@ -438,7 +443,8 @@ export function useMediaAssetActions() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.selection.failedToAddNodes')
detail: t('mediaAsset.selection.failedToAddNodes'),
life: 3000
})
} else {
toast.add({
@@ -670,7 +676,8 @@ export function useMediaAssetActions() {
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
})
} else {
// Partial success (only possible with multiple assets)
@@ -691,7 +698,8 @@ export function useMediaAssetActions() {
summary: t('g.error'),
detail: isSingle
? t('mediaAsset.failedToDeleteAsset')
: t('mediaAsset.selection.failedToDeleteAssets')
: t('mediaAsset.selection.failedToDeleteAssets'),
life: 3000
})
} finally {
// Hide loading overlay for all assets

View File

@@ -9,7 +9,6 @@ const zAsset = z.object({
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),

View File

@@ -73,7 +73,8 @@ export function createAssetWidget(
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidAsset'),
detail: t('assetBrowser.invalidAssetDetail')
detail: t('assetBrowser.invalidAssetDetail'),
life: 5000
})
return
}
@@ -91,7 +92,8 @@ export function createAssetWidget(
toastStore.add({
severity: 'error',
summary: t('assetBrowser.invalidFilename'),
detail: t('assetBrowser.invalidFilenameDetail')
detail: t('assetBrowser.invalidFilenameDetail'),
life: 5000
})
return
}

View File

@@ -20,7 +20,6 @@ type OutputOverrides = Partial<{
subfolder: string
nodeId: string
url: string
display_name: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
@@ -33,8 +32,7 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
}
return {
...merged,
previewUrl: merged.url,
display_name: merged.display_name
previewUrl: merged.url
} as ResultItemImpl
}
@@ -127,48 +125,6 @@ describe('resolveOutputAssetItems', () => {
])
})
it('propagates display_name from output to asset item', async () => {
const output = createOutput({
filename: 'abc123hash.png',
nodeId: '1',
url: 'https://example.com/abc123hash.png',
display_name: 'ComfyUI_00001_.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-dn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].name).toBe('abc123hash.png')
expect(results[0].display_name).toBe('ComfyUI_00001_.png')
})
it('omits display_name when not present in output', async () => {
const output = createOutput({
filename: 'file.png',
nodeId: '1',
url: 'https://example.com/file.png'
})
const metadata: OutputAssetMetadata = {
jobId: 'job-nodn',
nodeId: '1',
subfolder: 'sub',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(results).toHaveLength(1)
expect(results[0].display_name).toBeUndefined()
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',

View File

@@ -69,7 +69,6 @@ function mapOutputsToAssetItems({
items.push({
id: `${jobId}-${outputKey}`,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: createdAtValue,
tags: ['output'],

View File

@@ -317,7 +317,8 @@ export function useNodeReplacement() {
toastStore.add({
severity: 'error',
summary: t('g.error', 'Error'),
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes')
detail: t('nodeReplacement.replaceFailed', 'Failed to replace nodes'),
life: 5000
})
return replacedTypes
} finally {

View File

@@ -23,8 +23,7 @@ const zPreviewOutput = z.object({
subfolder: z.string(),
type: resultItemType,
nodeId: z.string(),
mediaType: z.string(),
display_name: z.string().optional()
mediaType: z.string()
})
/**

View File

@@ -83,7 +83,8 @@ describe('useSecrets', () => {
expect(mockAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'Network error'
detail: 'Network error',
life: 5000
})
})
})
@@ -129,7 +130,8 @@ describe('useSecrets', () => {
expect(mockAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'g.error',
detail: 'Delete failed'
detail: 'Delete failed',
life: 5000
})
})
})

View File

@@ -33,14 +33,16 @@ export function useSecrets() {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err.message
detail: err.message,
life: 5000
})
} else {
console.error('Unexpected error fetching secrets:', err)
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError')
detail: t('g.unknownError'),
life: 5000
})
}
} finally {
@@ -58,14 +60,16 @@ export function useSecrets() {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: err.message
detail: err.message,
life: 5000
})
} else {
console.error('Unexpected error deleting secret:', err)
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.unknownError')
detail: t('g.unknownError'),
life: 5000
})
}
} finally {

View File

@@ -922,27 +922,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: {} as ColorPalettes,
versionModified: '1.6.7'
},
{
id: 'Comfy.NodeColor.Favorites',
name: 'Favorite node colors',
type: 'hidden',
defaultValue: [] as string[],
versionAdded: '1.25.0'
},
{
id: 'Comfy.NodeColor.Recents',
name: 'Recent node colors',
type: 'hidden',
defaultValue: [] as string[],
versionAdded: '1.25.0'
},
{
id: 'Comfy.NodeColor.DarkerHeader',
name: 'Use a darker node header for custom colors',
type: 'hidden',
defaultValue: true,
versionAdded: '1.25.0'
},
{
id: 'Comfy.WidgetControlMode',
category: ['Comfy', 'Node Widget', 'WidgetControlMode'],

View File

@@ -370,7 +370,8 @@ export const useWorkflowService = () => {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft')
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
})
}
}

View File

@@ -112,7 +112,8 @@ export function useWorkflowPersistenceV2() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('toastMessages.failedToSaveDraft')
detail: t('toastMessages.failedToSaveDraft'),
life: 3000
})
return
}

View File

@@ -484,7 +484,8 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockToast.add).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Publish failed'
detail: 'Publish failed',
life: 5000
})
})

View File

@@ -352,7 +352,8 @@ const { isLoading: isSaving, execute: handleSave } = useAsyncState(
toast.add({
severity: 'error',
summary: t('shareWorkflow.saveFailedTitle'),
detail: t('shareWorkflow.saveFailedDescription')
detail: t('shareWorkflow.saveFailedDescription'),
life: 5000
})
}
}
@@ -390,7 +391,8 @@ const {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error')
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
})
}
}

View File

@@ -183,7 +183,8 @@ async function handleCreate() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : t('g.error')
detail: error instanceof Error ? error.message : t('g.error'),
life: 5000
})
} finally {
isCreating.value = false

View File

@@ -338,7 +338,8 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load shared workflow'
detail: 'Failed to load shared workflow',
life: 3000
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(

View File

@@ -118,7 +118,8 @@ export function useSharedWorkflowUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('shareWorkflow.loadFailed')
detail: t('shareWorkflow.loadFailed'),
life: 3000
})
cleanupUrlParams()
clearPreservedQuery(SHARE_NAMESPACE)
@@ -147,7 +148,8 @@ export function useSharedWorkflowUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('shareWorkflow.loadFailed')
detail: t('shareWorkflow.loadFailed'),
life: 5000
})
return 'failed'
}

View File

@@ -145,7 +145,8 @@ describe('useTemplateUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Template "invalid-template" not found'
detail: 'Template "invalid-template" not found',
life: 3000
})
})
@@ -238,7 +239,8 @@ describe('useTemplateUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Error',
detail: 'Failed to load template'
detail: 'Failed to load template',
life: 3000
})
})

View File

@@ -117,7 +117,8 @@ export function useTemplateUrlLoader() {
summary: t('g.error'),
detail: t('templateWorkflows.error.templateNotFound', {
templateName: templateParam
})
}),
life: 3000
})
} else if (modeParam === 'linear') {
// Set linear mode after successful template load
@@ -131,7 +132,8 @@ export function useTemplateUrlLoader() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.errorLoadingTemplate')
detail: t('g.errorLoadingTemplate'),
life: 3000
})
} finally {
cleanupUrlParams()

View File

@@ -428,7 +428,8 @@ async function handleResubscribe() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: message
detail: message,
life: 5000
})
} finally {
isResubscribing.value = false

View File

@@ -148,7 +148,8 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: 'This plan is not available'
detail: 'This plan is not available',
life: 5000
})
return
}
@@ -158,7 +159,8 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Unable to subscribe',
detail: response?.reason || 'This plan is not available'
detail: response?.reason || 'This plan is not available',
life: 5000
})
return
}
@@ -173,7 +175,8 @@ async function handleSubscribeClick(payload: {
toast.add({
severity: 'error',
summary: 'Error',
detail: message
detail: message,
life: 5000
})
} finally {
isLoadingPreview.value = false
@@ -233,7 +236,8 @@ async function handleAddCreditCard() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message
detail: message,
life: 5000
})
} finally {
isSubscribing.value = false
@@ -287,7 +291,8 @@ async function handleConfirmTransition() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message
detail: message,
life: 5000
})
} finally {
isSubscribing.value = false
@@ -311,7 +316,8 @@ async function handleResubscribe() {
toast.add({
severity: 'error',
summary: 'Error',
detail: message
detail: message,
life: 5000
})
} finally {
isResubscribing.value = false

View File

@@ -273,7 +273,8 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.unknownError')
detail: t('credits.topUp.unknownError'),
life: 5000
})
}
} catch (error) {
@@ -284,7 +285,8 @@ async function handleBuy() {
toast.add({
severity: 'error',
summary: t('credits.topUp.purchaseError'),
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
life: 5000
})
} finally {
loading.value = false

View File

@@ -102,7 +102,8 @@ async function onCreate() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToCreateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false

View File

@@ -79,7 +79,8 @@ async function onDelete() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToDeleteWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false

View File

@@ -94,7 +94,8 @@ async function onSave() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false

View File

@@ -138,7 +138,8 @@ async function onCreateLink() {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
detail: error instanceof Error ? error.message : undefined
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false
@@ -160,7 +161,8 @@ async function onCopyLink() {
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed')
summary: t('workspacePanel.inviteMemberDialog.linkCopyFailed'),
life: 3000
})
}
}

View File

@@ -68,7 +68,8 @@ async function onLeave() {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToLeaveWorkspace'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
loading.value = false

View File

@@ -73,7 +73,8 @@ async function onRemove() {
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.removeMemberDialog.error')
summary: t('workspacePanel.removeMemberDialog.error'),
life: 3000
})
} finally {
loading.value = false

View File

@@ -69,7 +69,8 @@ async function onRevoke() {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error instanceof Error ? error.message : undefined
detail: error instanceof Error ? error.message : undefined,
life: 3000
})
} finally {
loading.value = false

View File

@@ -543,7 +543,8 @@ async function handleCopyInviteLink(invite: PendingInvite) {
} catch {
toast.add({
severity: 'error',
summary: t('g.error')
summary: t('g.error'),
life: 3000
})
}
}

View File

@@ -151,7 +151,8 @@ describe('useInviteUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid invite'
detail: 'Invalid invite',
life: 5000
})
})
@@ -210,7 +211,8 @@ describe('useInviteUrlLoader', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'Failed to Accept Invite',
detail: 'Invalid token'
detail: 'Invalid token',
life: 5000
})
})

View File

@@ -97,7 +97,8 @@ export function useInviteUrlLoader() {
toast.add({
severity: 'error',
summary: t('workspace.inviteFailed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: error instanceof Error ? error.message : t('g.unknownError'),
life: 5000
})
} finally {
cleanupUrlParams()

View File

@@ -219,7 +219,8 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionFailed',
detail: errorMessage
detail: errorMessage,
life: 5000
})
})
@@ -238,7 +239,8 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupFailed',
detail: undefined
detail: undefined,
life: 5000
})
})
})
@@ -265,7 +267,8 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.subscriptionTimeout'
summary: 'billingOperation.subscriptionTimeout',
life: 5000
})
})
@@ -284,7 +287,8 @@ describe('billingOperationStore', () => {
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'error',
summary: 'billingOperation.topupTimeout'
summary: 'billingOperation.topupTimeout',
life: 5000
})
})
})

View File

@@ -173,7 +173,8 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
useToastStore().add({
severity: 'error',
summary: defaultMessage,
detail: errorMessage ?? undefined
detail: errorMessage ?? undefined,
life: 5000
})
}
@@ -191,7 +192,8 @@ export const useBillingOperationStore = defineStore('billingOperation', () => {
useToastStore().add({
severity: 'error',
summary: message
summary: message,
life: 5000
})
}

View File

@@ -4,7 +4,6 @@ import {
useInfiniteScroll,
useResizeObserver
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { ComponentPublicInstance } from 'vue'
import {
computed,
@@ -27,13 +26,11 @@ import type {
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
useOutputHistory()
const { hasOutputs } = storeToRefs(useAppModeStore())
const queueStore = useQueueStore()
const store = useLinearOutputStore()
const workflowStore = useWorkflowStore()
@@ -159,10 +156,8 @@ watch(
const inProgress = store.activeWorkflowInProgressItems
if (inProgress.length > 0) {
store.selectAsLatest(`slot:${inProgress[0].id}`)
} else if (hasOutputs.value) {
selectFirstHistory()
} else {
store.selectAsLatest(null)
selectFirstHistory()
}
},
{ immediate: true }
@@ -185,13 +180,13 @@ watch(
: undefined
if (!sv || sv.kind !== 'history') {
if (hasOutputs.value) selectFirstHistory()
selectFirstHistory()
return
}
const wasFirst = sv.assetId === oldAssets[0]?.id
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
if (hasOutputs.value) selectFirstHistory()
selectFirstHistory()
}
}
)

View File

@@ -219,7 +219,6 @@ describe(useOutputHistory, () => {
})
it('returns outputs from metadata allOutputs when count matches', () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png'), makeResult('b.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
@@ -256,7 +255,7 @@ describe(useOutputHistory, () => {
expect(outputs[0].filename).toBe('b.png')
})
it('returns empty when no output nodes are selected', () => {
it('returns all outputs when no output nodes are selected', () => {
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
@@ -266,7 +265,7 @@ describe(useOutputHistory, () => {
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
expect(outputs).toHaveLength(0)
expect(outputs).toHaveLength(2)
})
it('returns consistent filtered outputs across repeated calls', () => {
@@ -289,7 +288,6 @@ describe(useOutputHistory, () => {
})
it('returns in-progress outputs for pending resolve jobs', () => {
useAppModeStore().selectedOutputs.push('1')
pendingResolveRef.value = new Set(['job-1'])
inProgressItemsRef.value = [
{
@@ -316,7 +314,6 @@ describe(useOutputHistory, () => {
})
it('fetches full job detail for multi-output jobs', async () => {
useAppModeStore().selectedOutputs.push('1')
jobDetailResults.set('job-1', {
outputs: {
'1': {
@@ -345,7 +342,6 @@ describe(useOutputHistory, () => {
describe('watchEffect resolve loop', () => {
it('resolves pending jobs when history outputs load', async () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
@@ -364,7 +360,6 @@ describe(useOutputHistory, () => {
})
it('does not select first history when a selection exists', async () => {
useAppModeStore().selectedOutputs.push('1')
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,

View File

@@ -65,7 +65,7 @@ export function useOutputHistory(): {
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
if (!nodeIds.length) return []
if (!nodeIds.length) return items
return items.filter((r) =>
nodeIds.some((id) => String(id) === String(r.nodeId))
)

View File

@@ -203,6 +203,7 @@ const handleDownload = () => {
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadVideo'),
life: 3000,
group: 'video-preview'
})
}

View File

@@ -233,6 +233,7 @@ const handleDownload = () => {
severity: 'error',
summary: 'Error',
detail: t('g.failedToDownloadImage'),
life: 3000,
group: 'image-preview'
})
}

View File

@@ -208,7 +208,8 @@ const handleDownload = () => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadFile')
detail: t('g.failedToDownloadFile'),
life: 3000
})
}
}

Some files were not shown because too many files have changed in this diff Show More