mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-30 01:05:46 +00:00
Compare commits
3 Commits
test/stand
...
codex/feat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5480c3a4c | ||
|
|
aa97d176c2 | ||
|
|
36930a683a |
BIN
.github/pr-assets/node-color-legacy.png
vendored
Normal file
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
BIN
.github/pr-assets/node-color-nodes2.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]>
|
||||
) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
49
src/composables/graph/useCustomNodeColorSettings.ts
Normal file
49
src/composables/graph/useCustomNodeColorSettings.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => ({
|
||||
|
||||
143
src/lib/litegraph/src/LGraphCanvas.nodeColors.test.ts
Normal file
143
src/lib/litegraph/src/LGraphCanvas.nodeColors.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
88
src/utils/nodeColorCustomization.test.ts
Normal file
88
src/utils/nodeColorCustomization.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
114
src/utils/nodeColorCustomization.ts
Normal file
114
src/utils/nodeColorCustomization.ts
Normal 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'
|
||||
106
src/utils/nodeColorPersistence.ts
Normal file
106
src/utils/nodeColorPersistence.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user