Compare commits

...

21 Commits

Author SHA1 Message Date
dante01yoon
652d45f03a Tighten node menu color picker layout 2026-03-09 22:41:58 +09:00
Dante
e653a4326b Merge branch 'main' into feat/node-color-persistence-v1 2026-03-09 22:31:32 +09:00
dante01yoon
e15af715ea Remove PR screenshots 2026-03-09 22:05:50 +09:00
dante01yoon
9244b97fab Use PrimeVue color picker in node menus 2026-03-09 22:04:34 +09:00
dante01yoon
abee586cae Harden legacy color menu handling 2026-03-09 21:38:29 +09:00
jaeone94
76fd80aa98 fix: hide empty actionbar container and relocate error border to floating actionbar (#9657)
## Summary
When the actionbar is floating and has no docked buttons, the container
is now hidden (zero-width, transparent border) to avoid showing an empty
rounded box. Additionally, the error/destructive border is now applied
to the floating actionbar panel itself (via `ComfyActionbar`) instead of
the container, so it appears in the correct location when floating.

## Changes
- **TopMenuSection**: Added `hasDockedButtons` and
`isActionbarContainerEmpty` computed properties to detect when the
docked container has no visible buttons; `actionbarContainerClass`
computed hides the container by collapsing it when empty and floating,
while preserving the legacy drop zone via `:has(.border-dashed)` CSS
selector
- **TopMenuSection**: Error border
(`border-destructive-background-hover`) is now only applied to the
docked container when the actionbar is **not** floating
- **ComfyActionbar**: Accepts new `hasAnyError` prop and applies the
error border to the floating panel's `panelClass` when floating

## Review Focus
- The `has-[.border-dashed]` CSS selector restores the container visuals
when a legacy drag-target element is present inside it — verify this
works as expected
- Error border placement: docked mode shows border on container,
floating mode shows border on the fixed panel

## Screenshots


https://github.com/user-attachments/assets/75caabac-e391-4bfd-b4dc-62d564e55d37
2026-03-09 21:24:00 +09:00
dante01yoon
920159ecf2 Fix menu update type errors 2026-03-09 16:19:56 +09:00
dante01yoon
cef6bed5e3 Fix node menu custom color state 2026-03-09 16:07:00 +09:00
dante01yoon
046827fab5 Limit custom node colors to Node 2 menus 2026-03-09 15:46:00 +09:00
dante01yoon
5e60b1a2a0 Fix test import type annotations 2026-03-09 15:28:51 +09:00
dante01yoon
ed05e589bf Fix legacy group color menu scoping 2026-03-09 15:23:41 +09:00
dante01yoon
e6be6fd921 Fix legacy node color menu behavior 2026-03-09 14:35:09 +09:00
Hunter
63c36d3f2f feat: display original asset names instead of hashes in assets panel (#9626)
## Problem
Output assets in the assets panel show content hashes (e.g.,
`a1b2c3d4.png`) instead of display names (e.g., `ComfyUI_00001_.png`).

## Root Cause
Cloud inference replaces `filename` with the content hash in the output
transform pipeline. The hashed filename gets stored in the jobs table's
`preview_output` JSONB. The frontend uses this hash as the display name.

## Solution
- Add `display_name` field to `AssetItem` schema and `ResultItemImpl`
- Backend (cloud PR) joins job→assets table to resolve the original name
and injects `display_name` into job responses
- Frontend prefers `display_name` over `name` **only for display text
and download filenames**
- `asset.name` remains unchanged (the hash) for URLs, drag-to-canvas,
export filters, and output key dedup

## Backwards Compatible
- OSS: `display_name` is undefined, falls back to `asset.name` (which is
already the real filename in OSS)
- Cloud pre-deploy: `display_name` absent from API, falls back
gracefully
- Old jobs with no assets: `display_name` not injected, no change

## Cloud PR
https://github.com/Comfy-Org/cloud/pull/2747



https://github.com/user-attachments/assets/8a4c9cac-4ade-4ea2-9a70-9af240a56602
2026-03-09 01:06:28 -04:00
dante01yoon
327aeda027 Fix node color menu edge cases 2026-03-09 13:26:26 +09:00
dante01yoon
e5480c3a4c Use PrimeVue color picker for node colors 2026-03-09 12:36:28 +09:00
dante01yoon
aa97d176c2 Add PR screenshots for node color persistence 2026-03-09 12:26:00 +09:00
dante01yoon
36930a683a Add native custom node color persistence 2026-03-09 10:47:09 +09:00
pythongosssss
892a9cf2c5 fix: prevent showing outputs in app mode when no output nodes configured (#9625)
## Summary

After a user runs the workflow once in graph mode, switching to app mode
with no app built, incorrectly showed the app mode outputs view instead
of the intro screen

## Changes

- **What**: don't try and select outputs if no outputs & filter out all
outputs when nothing chosen
2026-03-08 17:36:15 -07:00
Comfy Org PR Bot
308c22efc6 1.42.2 (#9629)
Patch version increment to 1.42.2

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9629-1-42-2-31e6d73d365081faa106d97ae431e2e6)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-03-08 17:24:52 -07:00
Hunter
5728d240da fix: restore backend outputs_count for asset sidebar multi-output badge (#9627)
## Summary

Fix regression from PR #9535 where the multi-output count badge stopped
appearing in the asset sidebar.

## Root Cause

PR #9535 changed `outputCount` in `mapHistoryToAssets` from
`job.outputs_count` (backend-provided total) to
`task.previewableOutputs.length`. However, `TaskItemImpl` constructed
from a job listing only has the single `preview_output`, so
`previewableOutputs.length` is always **1** — the multi-output badge
never appears.

## Fix

Use the backend-provided `outputs_count` (via `task.outputsCount`) with
fallback to `task.previewableOutputs.length` when unavailable. This
restores the correct count while preserving the fallback for jobs that
don't have `outputs_count` from the server.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9627-fix-restore-backend-outputs_count-for-asset-sidebar-multi-output-badge-31d6d73d36508160b93fd03af4a01aa3)
by [Unito](https://www.unito.io)
2026-03-08 13:17:22 -07:00
Kelly Yang
acf2f4280c fix(maskeditor): make brush size slider logarithmic (#8097) (#9534)
## Summary
fix #8097.

This PR shifts the Mask Editor Brush Size slider from a linear scale to
a logarithmic (exponential) scale. Previously, the linear 1-250 range
heavily clumped the usable, small "fine-detail" brush sizes (e.g., 1px
to 20px) into the very first 10% of the slider, making it extremely
difficult to select precise sizes with the mouse.

This update borrows UX paradigms from other standard image editors like
Photoshop and GIMP, which map their scale entry widgets on an
exponential curve.

## GIMP Source
By inspecting the official **GIMP** source code under
`libgimpwidgets/gimpscaleentry.c`, we can see this exact mathematical
relationship being utilized when the logarithmic property is marked TRUE
on a brush radius adjustment widget:

```
// Mapping visual slider to internal value
value = gtk_adjustment_get_lower(...) + exp(t);
// Mapping internal value to visual slider
t = log (value - gtk_adjustment_get_lower(...) + 0.1);
```


https://github.com/user-attachments/assets/6d59ff12-f623-42cc-a52b-84147e9bb90b

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9534-fix-maskeditor-make-brush-size-slider-logarithmic-8097-31c6d73d365081118508e8363e0c5312)
by [Unito](https://www.unito.io)
2026-03-08 09:11:19 -07:00
40 changed files with 1998 additions and 164 deletions

View File

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

View File

@@ -34,17 +34,7 @@
</Button>
</div>
<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'
)
"
>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
@@ -55,6 +45,7 @@
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
:has-any-error="hasAnyError"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
@@ -123,7 +114,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -145,6 +136,7 @@ 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'
@@ -168,6 +160,7 @@ 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 } =
@@ -182,6 +175,43 @@ 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'
)
@@ -233,6 +263,25 @@ 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,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
const {
topMenuContainer,
queueOverlayExpanded = false,
hasAnyError = false
} = defineProps<{
topMenuContainer?: HTMLElement | null
queueOverlayExpanded?: boolean
hasAnyError?: boolean
}>()
const emit = defineEmits<{
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
isDragging.value && 'pointer-events-none select-none',
isDocked.value
? 'static border-none bg-transparent p-0'
: 'fixed shadow-interface'
: [
'fixed shadow-interface',
hasAnyError
? 'border-destructive-background-hover'
: 'border-interface-stroke'
]
)
)
</script>

View File

@@ -5,6 +5,7 @@ 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'
@@ -62,9 +63,14 @@ vi.mock('@/lib/litegraph/src/litegraph', async () => {
})
// Mock the colorUtil module
vi.mock('@/utils/colorUtil', () => ({
adjustColor: vi.fn((color: string) => color + '_light')
}))
vi.mock('@/utils/colorUtil', async () => {
const actual = await vi.importActual<typeof ColorUtilModule>('@/utils/colorUtil')
return {
...actual,
adjustColor: vi.fn((color: string) => color + '_light')
}
})
// Mock the litegraphUtil module
vi.mock('@/utils/litegraphUtil', () => ({
@@ -83,11 +89,25 @@ 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'
}
}
}
@@ -138,4 +158,17 @@ 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 -translate-x-1/2"
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"
>
<SelectButton
:model-value="selectedColorOption"
@@ -41,11 +41,70 @@
/>
</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'
@@ -61,16 +120,26 @@ 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 } from '@/utils/colorUtil'
import { adjustColor, toHexFromFormat } 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
)
@@ -129,16 +198,25 @@ 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
: null
: currentAppliedColor.value
)
const localizedCurrentColorName = computed(() => {
if (!currentColorOption.value?.bgcolor) return null
if (currentAppliedColor.value) {
return currentAppliedColor.value.toUpperCase()
}
if (!currentColorOption.value?.bgcolor) {
return null
}
const colorOption = colorOptions.find(
(option) =>
option.value.dark === currentColorOption.value?.bgcolor ||
@@ -146,6 +224,26 @@ 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

@@ -0,0 +1,99 @@
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,49 +20,135 @@
}"
>
<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'
"
>
<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)"
>
<template v-for="subOption in option.submenu" :key="subOption.label">
<div
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"
: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 }"
/>
<div v-else class="w-4 shrink-0" />
<span>{{ subOption.label }}</span>
</template>
</div>
<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>
</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'
@@ -102,6 +188,43 @@ 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

@@ -72,12 +72,12 @@
/>
</div>
<SliderControl
v-model="brushSize"
v-model="brushSizeSliderValue"
class="flex-1"
label=""
:min="1"
:max="250"
:step="1"
:min="0"
:max="1"
:step="0.001"
/>
</div>
@@ -182,6 +182,26 @@ 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

@@ -1,12 +1,22 @@
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
ColorOption,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
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'
@@ -16,7 +26,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 = Pick<LGraphNode, 'getColorOption' | 'setColorOption'>
type PickedNode = LGraphNode | LGraphGroup
const { nodes } = defineProps<{ nodes: PickedNode[] }>()
const emit = defineEmits<{ (e: 'changed'): void }>()
@@ -24,6 +34,14 @@ const emit = defineEmits<{ (e: 'changed'): void }>()
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
const {
darkerHeader,
favoriteColors,
isFavoriteColor,
recentColors,
rememberRecentColor,
toggleFavoriteColor
} = useCustomNodeColorSettings()
type NodeColorOption = {
name: string
@@ -102,43 +120,127 @@ 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="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"
<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
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':
<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
? isLightTheme
? option.value.ringLight
: option.value.ringDark
: undefined
? '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'
}
}"
:data-testid="option.name"
@update:model-value="onCustomColorUpdate"
/>
</button>
<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>
</div>
</LayoutField>
</template>

View File

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

View File

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,159 @@
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,10 +1,16 @@
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'
@@ -19,7 +25,24 @@ export function useGroupMenuOptions() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const canvasRefresh = useCanvasRefresh()
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
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 getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
label: 'Fit Group To Nodes',
@@ -65,19 +88,68 @@ export function useGroupMenuOptions() {
label: t('contextMenu.Color'),
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark,
action: () => {
groupContext.color = isLightTheme.value
submenu: (() => {
const currentAppliedColor = getSharedAppliedColor([groupContext])
const presetEntries = colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
: colorOption.value.dark
canvasRefresh.refreshCanvas()
bump()
}
}))
: 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: () => {}
}
]
})()
})
const getGroupModeOptions = (

View File

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

View File

@@ -11,7 +11,12 @@ 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 {
@@ -36,6 +41,12 @@ 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
@@ -101,6 +112,19 @@ 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
@@ -155,13 +179,20 @@ 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

@@ -0,0 +1,93 @@
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,6 +1,8 @@
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'
@@ -11,8 +13,17 @@ import type { NodeSelectionState } from './useSelectionState'
*/
export function useNodeMenuOptions() {
const { t } = useI18n()
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
useNodeCustomization()
const {
shapeOptions,
applyShape,
applyColor,
applyCustomColor,
colorOptions,
favoriteColors,
recentColors,
getCurrentAppliedColor,
isLightTheme
} = useNodeCustomization()
const {
adjustNodeSize,
toggleNodeCollapse,
@@ -29,7 +40,8 @@ export function useNodeMenuOptions() {
)
const colorSubmenu = computed(() => {
return colorOptions.map((colorOption) => ({
const currentAppliedColor = getCurrentAppliedColor()
const presetEntries = colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
@@ -37,6 +49,48 @@ 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

@@ -0,0 +1,400 @@
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,6 +2,7 @@ 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'
@@ -156,6 +157,52 @@ 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: (
@@ -1649,61 +1696,70 @@ 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
node: LGraphNode | LGraphGroup
): boolean {
if (!node) throw 'no node for color'
const values: IContextMenuValue<
string | null,
unknown,
{ value: string | null }
>[] = []
values.push({
value: null,
content:
"<span style='display: block; padding-left: 4px;'>No color</span>"
})
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>`
if (!node || !isLegacyColorTarget(node)) throw 'no node for color'
const values: (IContextMenuValue<string | null> | null)[] = [
{
value: null,
content: createLegacyColorMenuContent(st('color.noColor', 'No color'))
}
values.push(value)
]
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: inner_clicked,
callback: (value) => {
try {
innerClicked(value)
} catch (error) {
console.error('Failed to apply legacy node color selection.', error)
}
},
parentMenu: menu,
node
...(node instanceof LGraphNode ? { node } : {})
})
function inner_clicked(v: IContextMenuValue<string>) {
if (!node) return
const fApplyColor = function (item: IColorable) {
const colorOption = v.value ? LGraphCanvas.node_colors[v.value] : null
item.setColorOption(colorOption)
}
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
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])
const targets = getLegacyColorTargets(node)
const graphInfo = node instanceof LGraphNode ? node : undefined
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)
}
canvas.setDirty(true, true)
}

View File

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

View File

@@ -5,7 +5,7 @@
:aria-label="
asset
? $t('assetBrowser.ariaLabel.assetCard', {
name: asset.name,
name: asset.display_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?.name || '').filename
return getFilenameDetails(asset?.display_name || asset?.name || '').filename
})
// Adapt AssetItem to legacy AssetMeta format for existing components
@@ -234,6 +234,7 @@ 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.name"
:alt="asset.display_name || 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.name
alt: asset.display_name || asset.name
})
whenever(

View File

@@ -39,6 +39,7 @@ 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.name
const filename = targetAsset.display_name || targetAsset.name
// Prefer preview_url (already includes subfolder) with getAssetUrl as fallback
const downloadUrl = targetAsset.preview_url || getAssetUrl(targetAsset)
@@ -109,7 +109,7 @@ export function useMediaAssetActions() {
try {
assets.forEach((asset) => {
const filename = asset.name
const filename = asset.display_name || asset.name
const downloadUrl = asset.preview_url || getAssetUrl(asset)
downloadFile(downloadUrl, filename)
})

View File

@@ -9,6 +9,7 @@ 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

@@ -20,6 +20,7 @@ type OutputOverrides = Partial<{
subfolder: string
nodeId: string
url: string
display_name: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
@@ -32,7 +33,8 @@ function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
}
return {
...merged,
previewUrl: merged.url
previewUrl: merged.url,
display_name: merged.display_name
} as ResultItemImpl
}
@@ -125,6 +127,48 @@ 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,6 +69,7 @@ function mapOutputsToAssetItems({
items.push({
id: `${jobId}-${outputKey}`,
name: output.filename,
display_name: output.display_name,
size: 0,
created_at: createdAtValue,
tags: ['output'],

View File

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

View File

@@ -922,6 +922,27 @@ 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

@@ -4,6 +4,7 @@ import {
useInfiniteScroll,
useResizeObserver
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { ComponentPublicInstance } from 'vue'
import {
computed,
@@ -26,11 +27,13 @@ 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()
@@ -156,8 +159,10 @@ watch(
const inProgress = store.activeWorkflowInProgressItems
if (inProgress.length > 0) {
store.selectAsLatest(`slot:${inProgress[0].id}`)
} else {
} else if (hasOutputs.value) {
selectFirstHistory()
} else {
store.selectAsLatest(null)
}
},
{ immediate: true }
@@ -180,13 +185,13 @@ watch(
: undefined
if (!sv || sv.kind !== 'history') {
selectFirstHistory()
if (hasOutputs.value) selectFirstHistory()
return
}
const wasFirst = sv.assetId === oldAssets[0]?.id
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
selectFirstHistory()
if (hasOutputs.value) selectFirstHistory()
}
}
)

View File

@@ -219,6 +219,7 @@ 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,
@@ -255,7 +256,7 @@ describe(useOutputHistory, () => {
expect(outputs[0].filename).toBe('b.png')
})
it('returns all outputs when no output nodes are selected', () => {
it('returns empty when no output nodes are selected', () => {
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
@@ -265,7 +266,7 @@ describe(useOutputHistory, () => {
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
expect(outputs).toHaveLength(2)
expect(outputs).toHaveLength(0)
})
it('returns consistent filtered outputs across repeated calls', () => {
@@ -288,6 +289,7 @@ describe(useOutputHistory, () => {
})
it('returns in-progress outputs for pending resolve jobs', () => {
useAppModeStore().selectedOutputs.push('1')
pendingResolveRef.value = new Set(['job-1'])
inProgressItemsRef.value = [
{
@@ -314,6 +316,7 @@ describe(useOutputHistory, () => {
})
it('fetches full job detail for multi-output jobs', async () => {
useAppModeStore().selectedOutputs.push('1')
jobDetailResults.set('job-1', {
outputs: {
'1': {
@@ -342,6 +345,7 @@ 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,
@@ -360,6 +364,7 @@ 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 items
if (!nodeIds.length) return []
return items.filter((r) =>
nodeIds.some((id) => String(id) === String(r.nodeId))
)

View File

@@ -19,7 +19,8 @@ export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n>
const zResultItem = z.object({
filename: z.string().optional(),
subfolder: z.string().optional(),
type: resultItemType.optional()
type: resultItemType.optional(),
display_name: z.string().optional()
})
export type ResultItem = z.infer<typeof zResultItem>
const zOutputs = z
@@ -293,6 +294,9 @@ export type PreviewMethod = z.infer<typeof zPreviewMethod>
const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.NodeColor.Favorites': z.array(z.string()),
'Comfy.NodeColor.Recents': z.array(z.string()),
'Comfy.NodeColor.DarkerHeader': z.boolean(),
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),

View File

@@ -255,6 +255,35 @@ describe('jobOutputCache', () => {
expect(video?.mediaType).toBe('video')
})
it('preserves display_name from output items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-display-name',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-1': {
images: [
{
filename: 'abc123hash.png',
subfolder: '',
type: 'output',
display_name: 'ComfyUI_00001_.png'
}
]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result).toHaveLength(1)
expect(result[0].filename).toBe('abc123hash.png')
expect(result[0].display_name).toBe('ComfyUI_00001_.png')
})
it('filters non-previewable outputs and non-object items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')

View File

@@ -70,7 +70,7 @@ function mapHistoryToAssets(historyItems: JobListItem[]): AssetItem[] {
assetItem.user_metadata = {
...assetItem.user_metadata,
outputCount: task.previewableOutputs.length,
outputCount: task.outputsCount ?? task.previewableOutputs.length,
allOutputs: task.previewableOutputs
}

View File

@@ -37,6 +37,7 @@ interface ResultItemInit extends ResultItem {
mediaType: string
format?: string
frame_rate?: number
display_name?: string
}
export class ResultItemImpl {
@@ -48,6 +49,8 @@ export class ResultItemImpl {
// 'audio' | 'images' | ...
mediaType: string
display_name?: string
// VHS output specific fields
format?: string
frame_rate?: number
@@ -60,6 +63,8 @@ export class ResultItemImpl {
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
this.display_name = obj.display_name
this.format = obj.format
this.frame_rate = obj.frame_rate
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
applyCustomColorToItem,
getSharedAppliedColor,
getSharedCustomColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from './nodeColorCustomization'
describe('nodeColorCustomization', () => {
it('applies a custom color to nodes using a derived header color', () => {
const node = Object.assign(Object.create(LGraphNode.prototype), {
color: undefined,
bgcolor: undefined,
getColorOption: () => null
}) as LGraphNode
const applied = applyCustomColorToItem(node, '#abcdef', {
darkerHeader: true
})
expect(applied).toBe('#abcdef')
expect(node.bgcolor).toBe('#abcdef')
expect(node.color).not.toBe('#abcdef')
})
it('applies a custom color to groups without deriving a header color', () => {
const group = Object.assign(Object.create(LGraphGroup.prototype), {
color: undefined,
getColorOption: () => null
}) as LGraphGroup
const applied = applyCustomColorToItem(group, '#123456', {
darkerHeader: true
})
expect(applied).toBe('#123456')
expect(group.color).toBe('#123456')
})
it('returns a shared applied color for matching custom node colors', () => {
const nodeA = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#abcdef',
getColorOption: () => null
}) as LGraphNode
const nodeB = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#abcdef',
getColorOption: () => null
}) as LGraphNode
expect(getSharedAppliedColor([nodeA, nodeB])).toBe('#abcdef')
expect(getSharedCustomColor([nodeA, nodeB])).toBe('#abcdef')
})
it('returns null when selected items do not share the same color', () => {
const nodeA = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#abcdef',
getColorOption: () => null
}) as LGraphNode
const nodeB = Object.assign(Object.create(LGraphNode.prototype), {
bgcolor: '#123456',
getColorOption: () => null
}) as LGraphNode
expect(getSharedAppliedColor([nodeA, nodeB])).toBeNull()
expect(getSharedCustomColor([nodeA, nodeB])).toBeNull()
})
it('keeps recent colors unique and most-recent-first', () => {
const updated = upsertRecentNodeColor(
['#111111', '#222222', '#333333'],
'#222222'
)
expect(updated).toEqual(['#222222', '#111111', '#333333'])
})
it('toggles favorite colors on and off', () => {
const added = toggleFavoriteNodeColor(['#111111'], '#222222')
const removed = toggleFavoriteNodeColor(added, '#111111')
expect(added).toEqual(['#111111', '#222222'])
expect(removed).toEqual(['#222222'])
})
})

View File

@@ -0,0 +1,113 @@
import type { ColorOption } from '@/lib/litegraph/src/interfaces'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { isColorable } from '@/lib/litegraph/src/utils/type'
import {
deriveCustomNodeHeaderColor,
getDefaultCustomNodeColor as getDefaultCustomNodeColorValue,
normalizeNodeColor
} from '@/utils/nodeColorPersistence'
function isColorableNodeOrGroup(
item: unknown
): item is (LGraphNode | LGraphGroup) & {
getColorOption(): ColorOption | null
} {
return (
isColorable(item) &&
(item instanceof LGraphNode || item instanceof LGraphGroup)
)
}
export function getDefaultCustomNodeColor(): string {
return getDefaultCustomNodeColorValue()
}
export function applyCustomColorToItem(
item: LGraphNode | LGraphGroup,
color: string,
options: { darkerHeader: boolean }
): string {
const normalized = normalizeNodeColor(color)
if (item instanceof LGraphGroup) {
item.color = normalized
return normalized
}
item.bgcolor = normalized
item.color = deriveCustomNodeHeaderColor(normalized, options.darkerHeader)
return normalized
}
export function applyCustomColorToItems(
items: Iterable<unknown>,
color: string,
options: { darkerHeader: boolean }
): string {
const normalized = normalizeNodeColor(color)
for (const item of items) {
if (item instanceof LGraphNode || item instanceof LGraphGroup) {
applyCustomColorToItem(item, normalized, options)
}
}
return normalized
}
function getAppliedColorFromItem(
item: (LGraphNode | LGraphGroup) & {
getColorOption(): ColorOption | null
}
): string | null {
const presetColor = item.getColorOption()
if (presetColor) {
return item instanceof LGraphGroup ? presetColor.groupcolor : presetColor.bgcolor
}
return item instanceof LGraphGroup ? item.color ?? null : item.bgcolor ?? null
}
function getCustomColorFromItem(
item: (LGraphNode | LGraphGroup) & {
getColorOption(): ColorOption | null
}
): string | null {
if (item.getColorOption()) return null
return item instanceof LGraphGroup ? item.color ?? null : item.bgcolor ?? null
}
function getSharedColor(
items: unknown[],
selector: (
item: (LGraphNode | LGraphGroup) & { getColorOption(): ColorOption | null }
) => string | null
): string | null {
const validItems = items.filter(isColorableNodeOrGroup)
if (validItems.length === 0) return null
const firstColor = selector(validItems[0])
return validItems.every((item) => selector(item) === firstColor) ? firstColor : null
}
export function getSharedAppliedColor(items: unknown[]): string | null {
return getSharedColor(items, getAppliedColorFromItem)
}
export function getSharedCustomColor(items: unknown[]): string | null {
return getSharedColor(items, getCustomColorFromItem)
}
export {
NODE_COLOR_DARKER_HEADER_SETTING_ID,
NODE_COLOR_FAVORITES_SETTING_ID,
NODE_COLOR_RECENTS_SETTING_ID,
NODE_COLOR_SWATCH_LIMIT,
deriveCustomNodeHeaderColor,
normalizeNodeColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from '@/utils/nodeColorPersistence'

View File

@@ -0,0 +1,61 @@
import {
adjustColor,
parseToRgb,
rgbToHex,
toHexFromFormat
} from '@/utils/colorUtil'
export const DEFAULT_CUSTOM_NODE_COLOR = '#353535'
export const NODE_COLOR_FAVORITES_SETTING_ID = 'Comfy.NodeColor.Favorites'
export const NODE_COLOR_RECENTS_SETTING_ID = 'Comfy.NodeColor.Recents'
export const NODE_COLOR_DARKER_HEADER_SETTING_ID =
'Comfy.NodeColor.DarkerHeader'
export const NODE_COLOR_SWATCH_LIMIT = 8
export function getDefaultCustomNodeColor(): string {
return rgbToHex(parseToRgb(DEFAULT_CUSTOM_NODE_COLOR)).toLowerCase()
}
export function normalizeNodeColor(color: string | null | undefined): string {
if (!color) return getDefaultCustomNodeColor()
return toHexFromFormat(color, 'hex').toLowerCase()
}
export function deriveCustomNodeHeaderColor(
backgroundColor: string,
darkerHeader: boolean
): string {
const normalized = normalizeNodeColor(backgroundColor)
if (!darkerHeader) return normalized
return rgbToHex(
parseToRgb(adjustColor(normalized, { lightness: -0.18 }))
).toLowerCase()
}
export function upsertRecentNodeColor(
colors: string[],
color: string,
limit: number = NODE_COLOR_SWATCH_LIMIT
): string[] {
const normalized = normalizeNodeColor(color)
return [normalized, ...colors.filter((value) => value !== normalized)].slice(
0,
limit
)
}
export function toggleFavoriteNodeColor(
colors: string[],
color: string,
limit: number = NODE_COLOR_SWATCH_LIMIT
): string[] {
const normalized = normalizeNodeColor(color)
if (colors.includes(normalized)) {
return colors.filter((value) => value !== normalized)
}
return [...colors, normalized].slice(-limit)
}