Compare commits

...

15 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
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
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
19 changed files with 1781 additions and 124 deletions

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

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

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

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

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

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