mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 01:48:06 +00:00
Compare commits
15 Commits
pricing-ta
...
feat/node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652d45f03a | ||
|
|
e653a4326b | ||
|
|
e15af715ea | ||
|
|
9244b97fab | ||
|
|
abee586cae | ||
|
|
920159ecf2 | ||
|
|
cef6bed5e3 | ||
|
|
046827fab5 | ||
|
|
5e60b1a2a0 | ||
|
|
ed05e589bf | ||
|
|
e6be6fd921 | ||
|
|
327aeda027 | ||
|
|
e5480c3a4c | ||
|
|
aa97d176c2 | ||
|
|
36930a683a |
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[]>
|
||||
) => {
|
||||
|
||||
@@ -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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
159
src/composables/graph/useGroupMenuOptions.test.ts
Normal file
159
src/composables/graph/useGroupMenuOptions.test.ts
Normal 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]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 = (
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface SubMenuOption {
|
||||
action: () => void
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
pickerValue?: string
|
||||
onColorPick?: (color: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
93
src/composables/graph/useNodeMenuOptions.test.ts
Normal file
93
src/composables/graph/useNodeMenuOptions.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 => ({
|
||||
|
||||
400
src/lib/litegraph/src/LGraphCanvas.nodeColors.test.ts
Normal file
400
src/lib/litegraph/src/LGraphCanvas.nodeColors.test.ts
Normal 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('<img src=x onerror=1>'))
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
113
src/utils/nodeColorCustomization.ts
Normal file
113
src/utils/nodeColorCustomization.ts
Normal 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'
|
||||
61
src/utils/nodeColorPersistence.ts
Normal file
61
src/utils/nodeColorPersistence.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user