Compare commits

...

3 Commits

Author SHA1 Message Date
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
16 changed files with 1087 additions and 97 deletions

BIN
.github/pr-assets/node-color-legacy.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
.github/pr-assets/node-color-nodes2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -62,9 +62,16 @@ 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 import('@/utils/colorUtil')>(
'@/utils/colorUtil'
)
return {
...actual,
adjustColor: vi.fn((color: string) => color + '_light')
}
})
// Mock the litegraphUtil module
vi.mock('@/utils/litegraphUtil', () => ({
@@ -83,11 +90,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'
}
}
}

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,69 @@
/>
</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"
:title="isCurrentColorFavorite ? t('g.remove') : t('g.favorites')"
data-testid="toggle-favorite-color"
@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 +119,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 +197,24 @@ const applyColor = (colorOption: ColorOption | null) => {
}
const currentColorOption = ref<CanvasColorOption | null>(null)
const currentAppliedColor = computed(
() => getCurrentAppliedColor() ?? getDefaultCustomNodeColor()
)
const currentPickerValue = computed(() =>
currentAppliedColor.value.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 (!currentColorOption.value?.bgcolor) {
return currentAppliedColor.value.toUpperCase()
}
const colorOption = colorOptions.find(
(option) =>
option.value.dark === currentColorOption.value?.bgcolor ||
@@ -146,6 +222,25 @@ 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() {
await toggleFavoriteColor(currentAppliedColor.value)
}
const isCurrentColorFavorite = computed(() =>
isFavoriteColor(currentAppliedColor.value)
)
const updateColorSelectionFromNode = (
newSelectedItems: Raw<Positionable[]>
) => {

View File

@@ -1,12 +1,19 @@
<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 { 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 +23,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 +31,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 +117,127 @@ const nodeColor = computed<NodeColorOption['name'] | null>({
emit('changed')
}
})
const currentAppliedColor = computed(
() => getSharedAppliedColor(nodes) ?? getDefaultCustomNodeColor()
)
const currentPickerValue = computed(() =>
currentAppliedColor.value.replace('#', '')
)
async function applySavedCustomColor(color: string) {
applyCustomColorToItems(nodes, color, {
darkerHeader: darkerHeader.value
})
await rememberRecentColor(color)
emit('changed')
}
async function toggleCurrentColorFavorite() {
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"
:title="isCurrentColorFavorite ? t('g.remove') : t('g.favorites')"
@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

@@ -19,7 +19,16 @@ export function useGroupMenuOptions() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const canvasRefresh = useCanvasRefresh()
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
const {
applyCustomColor,
colorOptions,
favoriteColors,
recentColors,
getCurrentAppliedColor,
isLightTheme,
openCustomColorPicker,
shapeOptions
} = useNodeCustomization()
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
label: 'Fit Group To Nodes',
@@ -65,19 +74,62 @@ 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 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: () => {
void applyCustomColor(entry.color)
bump()
}
}))
return [
...presetEntries,
...customEntries,
{
label: t('g.custom'),
color: getCurrentAppliedColor() ?? '#353535',
action: () => {
void openCustomColorPicker()
bump()
}
}
]
})()
})
const getGroupModeOptions = (

View File

@@ -11,7 +11,14 @@ import {
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import {
applyCustomColorToItems,
getDefaultCustomNodeColor,
getSharedAppliedColor,
pickHexColor
} from '@/utils/nodeColorCustomization'
import { useCustomNodeColorSettings } from './useCustomNodeColorSettings'
import { useCanvasRefresh } from './useCanvasRefresh'
interface ColorOption {
@@ -36,6 +43,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 +114,28 @@ 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 openCustomColorPicker = async () => {
const color = await pickHexColor(
getCurrentAppliedColor() ?? getDefaultCustomNodeColor()
)
if (!color) return
await applyCustomColor(color)
}
const applyShape = (shapeOption: ShapeOption) => {
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
(item): item is LGraphNode => item instanceof LGraphNode
@@ -155,13 +190,21 @@ export function useNodeCustomization() {
)
}
const getCurrentAppliedColor = (): string | null =>
getSharedAppliedColor(Array.from(canvasStore.selectedItems))
return {
colorOptions,
shapeOptions,
applyColor,
applyCustomColor,
applyShape,
getCurrentColor,
getCurrentAppliedColor,
getCurrentShape,
openCustomColorPicker,
favoriteColors,
recentColors,
isLightTheme
}
}

View File

@@ -11,8 +11,18 @@ 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,
openCustomColorPicker
} = useNodeCustomization()
const {
adjustNodeSize,
toggleNodeCollapse,
@@ -29,7 +39,7 @@ export function useNodeMenuOptions() {
)
const colorSubmenu = computed(() => {
return colorOptions.map((colorOption) => ({
const presetEntries = colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
? colorOption.value.light
@@ -37,6 +47,45 @@ 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: getCurrentAppliedColor() ?? '#353535',
action: () => {
void openCustomColorPicker()
}
}
]
})
const getAdjustSizeOption = (): MenuOption => ({

View File

@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
st: (_key: string, fallback: string) => fallback
}))
vi.mock('@/utils/nodeColorPersistence', async () => {
const actual = await vi.importActual<typeof import('@/utils/nodeColorPersistence')>(
'@/utils/nodeColorPersistence'
)
return {
...actual,
pickHexColor: vi.fn().mockResolvedValue('#abcdef')
}
})
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('adds a custom color entry to the legacy submenu', () => {
const graph = {
beforeChange: vi.fn(),
afterChange: vi.fn()
}
const node = Object.assign(Object.create(LGraphNode.prototype), {
graph,
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).toEqual(
expect.arrayContaining([
expect.stringContaining('Custom...')
])
)
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
it('applies a picked custom color to selected nodes and groups in legacy mode', async () => {
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: {
kind: 'custom-picker'
}
})
await Promise.resolve()
await Promise.resolve()
expect(node.bgcolor).toBe('#abcdef')
expect(node.color).not.toBe('#abcdef')
expect(group.color).toBe('#abcdef')
expect(graph.beforeChange).toHaveBeenCalled()
expect(graph.afterChange).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
} finally {
LiteGraph.ContextMenu = originalContextMenu
}
})
})

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'
@@ -9,6 +10,12 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { forEachNode } from '@/utils/graphTraversalUtil'
import {
deriveCustomNodeHeaderColor,
getDefaultCustomNodeColor,
normalizeNodeColor,
pickHexColor
} from '@/utils/nodeColorPersistence'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -156,6 +163,77 @@ interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
posSizeFix?: Point
}
type LegacyColorTarget = (LGraphNode | LGraphGroup) & IColorable & Positionable
type LegacyColorMenuAction =
| { kind: 'preset'; presetName: string | null }
| { kind: 'custom'; color: string }
| { kind: 'custom-picker' }
function isLegacyColorTarget(item: unknown): item is LegacyColorTarget {
return item instanceof LGraphNode || item instanceof LGraphGroup
}
function getLegacyColorTargets(target: LegacyColorTarget): LegacyColorTarget[] {
const selected = Array.from(LGraphCanvas.active_canvas.selectedItems).filter(
isLegacyColorTarget
)
return selected.length ? selected : [target]
}
function getAppliedColorForLegacyTarget(target: LegacyColorTarget): string | null {
const presetColor = target.getColorOption()
if (presetColor) {
return target instanceof LGraphGroup
? presetColor.groupcolor
: presetColor.bgcolor
}
return target instanceof LGraphGroup ? target.color ?? null : target.bgcolor ?? null
}
function getSharedAppliedColorForLegacyTargets(
targets: LegacyColorTarget[]
): string | null {
if (!targets.length) return null
const firstColor = getAppliedColorForLegacyTarget(targets[0])
return targets.every((target) => getAppliedColorForLegacyTarget(target) === firstColor)
? firstColor
: null
}
function createLegacyColorMenuContent(label: string, color?: string): string {
if (!color) {
return `<span style='display: block; padding-left: 4px;'>${label}</span>`
}
return (
`<span style='display: block; color: #fff; padding-left: 4px;` +
` border-left: 8px solid ${color}; background-color:${color}'>${label}</span>`
)
}
function applyLegacyCustomColor(
targets: LegacyColorTarget[],
color: string,
darkerHeader: boolean = true
): string {
const normalized = normalizeNodeColor(color)
for (const target of targets) {
if (target instanceof LGraphGroup) {
target.color = normalized
} else {
target.bgcolor = normalized
target.color = deriveCustomNodeHeaderColor(normalized, darkerHeader)
}
}
return normalized
}
interface HasShowSearchCallback {
/** See {@link LGraphCanvas.showSearchBox} */
showSearchBox: (
@@ -1649,62 +1727,91 @@ 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<LegacyColorMenuAction> | null)[] = [
{
value: { kind: 'preset', presetName: null },
content: createLegacyColorMenuContent(
st('color.noColor', 'No color')
)
}
values.push(value)
]
for (const [presetName, colorOption] of Object.entries(
LGraphCanvas.node_colors
)) {
values.push({
value: { kind: 'preset', presetName },
content: createLegacyColorMenuContent(
st(`color.${presetName}`, presetName),
colorOption.bgcolor
)
})
}
new LiteGraph.ContextMenu<string | null>(values, {
event: e,
callback: inner_clicked,
parentMenu: menu,
node
values.push(null)
values.push({
value: { kind: 'custom-picker' },
content: createLegacyColorMenuContent(st('g.custom', 'Custom') + '...')
})
function inner_clicked(v: IContextMenuValue<string>) {
if (!node) return
new LiteGraph.ContextMenu<LegacyColorMenuAction>(values, {
event: e,
callback: (value) => {
if (typeof value === 'string' || value == null) return
void innerClicked(value as IContextMenuValue<LegacyColorMenuAction>)
},
parentMenu: menu as unknown as ContextMenu<LegacyColorMenuAction>,
...(node instanceof LGraphNode ? { node } : {})
})
const fApplyColor = function (item: IColorable) {
const colorOption = v.value ? LGraphCanvas.node_colors[v.value] : null
item.setColorOption(colorOption)
}
async function innerClicked(v: IContextMenuValue<LegacyColorMenuAction>) {
if (!node || !isLegacyColorTarget(node) || !v?.value) return
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
switch (v.value.kind) {
case 'preset': {
node.graph?.beforeChange(graphInfo)
const colorOption = v.value.presetName
? LGraphCanvas.node_colors[v.value.presetName]
: null
for (const target of targets) {
target.setColorOption(colorOption)
}
node.graph?.afterChange(graphInfo)
canvas.setDirty(true, true)
return
}
case 'custom': {
node.graph?.beforeChange(graphInfo)
applyLegacyCustomColor(targets, v.value.color)
node.graph?.afterChange(graphInfo)
canvas.setDirty(true, true)
return
}
case 'custom-picker': {
const currentColor = getSharedAppliedColorForLegacyTargets(targets)
const pickedColor = await pickHexColor(
currentColor ?? getDefaultCustomNodeColor()
)
if (!pickedColor) return
node.graph?.beforeChange(graphInfo)
applyLegacyCustomColor(targets, pickedColor)
node.graph?.afterChange(graphInfo)
canvas.setDirty(true, true)
return
}
}
canvas.setDirty(true, true)
}
return false

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

@@ -293,6 +293,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,114 @@
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,
pickHexColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from '@/utils/nodeColorPersistence'

View File

@@ -0,0 +1,106 @@
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)
}
export async function pickHexColor(
initialColor?: string
): Promise<string | null> {
if (typeof document === 'undefined') return null
return await new Promise<string | null>((resolve) => {
const input = document.createElement('input')
input.type = 'color'
input.value = normalizeNodeColor(initialColor)
input.tabIndex = -1
input.style.position = 'fixed'
input.style.pointerEvents = 'none'
input.style.opacity = '0'
input.style.inset = '0'
let settled = false
const finish = (value: string | null) => {
if (settled) return
settled = true
input.remove()
resolve(value)
}
input.addEventListener(
'change',
() => {
finish(normalizeNodeColor(input.value))
},
{ once: true }
)
input.addEventListener(
'blur',
() => {
queueMicrotask(() => finish(null))
},
{ once: true }
)
document.body.append(input)
input.click()
})
}