Use PrimeVue color picker in node menus

This commit is contained in:
dante01yoon
2026-03-09 22:04:34 +09:00
parent abee586cae
commit 9244b97fab
10 changed files with 153 additions and 125 deletions

View File

@@ -0,0 +1,53 @@
import { mount } from '@vue/test-utils'
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 PrimeVue picker 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({ name: 'ColorPicker' })
expect(picker.exists()).toBe(true)
expect(picker.props('modelValue')).toBe('112233')
picker.vm.$emit('update:model-value', 'fedcba')
await wrapper.vm.$nextTick()
expect(onColorPick).toHaveBeenCalledWith('#fedcba')
})
})

View File

@@ -26,43 +26,63 @@
: '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"
v-if="isPickerOption(subOption)"
class="flex items-center gap-2 rounded-sm px-3 py-1.5 text-sm"
>
<span class="flex-1">{{ subOption.label }}</span>
<ColorPicker
:model-value="subOption.pickerValue"
format="hex"
:aria-label="subOption.label"
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="handleColorPickerUpdate(subOption, $event)"
/>
<div v-else class="w-4 shrink-0" />
<span>{{ subOption.label }}</span>
</template>
</div>
</div>
<div
v-else
: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 }"
/>
<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 +122,22 @@ const handleSubmenuClick = (subOption: SubMenuOption) => {
popoverRef.value?.hide()
}
const isPickerOption = (subOption: SubMenuOption): boolean =>
typeof subOption.pickerValue === 'string' &&
typeof subOption.onColorPick === 'function'
async function handleColorPickerUpdate(
subOption: SubMenuOption,
value: string
) {
if (!isPickerOption(subOption) || !value) return
await subOption.onColorPick?.(`#${value}`)
if (typeof popoverRef.value?.hide === 'function') {
popoverRef.value.hide()
}
}
const isShapeSelected = (subOption: SubMenuOption): boolean => {
if (subOption.color) return false

View File

@@ -5,7 +5,6 @@ import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type * as NodeColorCustomizationModule from '@/utils/nodeColorCustomization'
const mocks = vi.hoisted(() => ({
pickHexColor: vi.fn().mockResolvedValue('#fedcba'),
refreshCanvas: vi.fn(),
rememberRecentColor: vi.fn().mockResolvedValue(undefined),
selectedItems: [] as unknown[]
@@ -75,16 +74,11 @@ vi.mock('@/composables/graph/useNodeCustomization', () => ({
})
}))
vi.mock('@/utils/nodeColorCustomization', async () => {
const actual = await vi.importActual<typeof NodeColorCustomizationModule>(
vi.mock('@/utils/nodeColorCustomization', async () =>
vi.importActual<typeof NodeColorCustomizationModule>(
'@/utils/nodeColorCustomization'
)
return {
...actual,
pickHexColor: mocks.pickHexColor
}
})
)
function createNode() {
return Object.assign(Object.create(LGraphNode.prototype), {
@@ -135,7 +129,7 @@ describe('useGroupMenuOptions', () => {
)
})
it('seeds the custom picker from the clicked group color', async () => {
it('seeds the PrimeVue custom picker from the clicked group color', async () => {
const selectedNode = createNode()
selectedNode.bgcolor = '#445566'
const groupContext = createGroup('#112233')
@@ -150,10 +144,11 @@ describe('useGroupMenuOptions', () => {
)
expect(customEntry).toBeDefined()
expect(customEntry?.color).toBe('#112233')
expect(customEntry?.pickerValue).toBe('112233')
await customEntry?.action()
await customEntry?.onColorPick?.('#fedcba')
expect(mocks.pickHexColor).toHaveBeenCalledWith('#112233')
expect(groupContext.color).toBe('#fedcba')
expect(selectedNode.bgcolor).toBe('#445566')
expect(mocks.rememberRecentColor).toHaveBeenCalledWith('#fedcba')

View File

@@ -9,8 +9,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
applyCustomColorToItems,
getDefaultCustomNodeColor,
getSharedAppliedColor,
pickHexColor
getSharedAppliedColor
} from '@/utils/nodeColorCustomization'
import { useCanvasRefresh } from './useCanvasRefresh'
@@ -45,16 +44,6 @@ export function useGroupMenuOptions() {
await rememberRecentColor(color)
}
const openGroupCustomColorPicker = async (groupContext: LGraphGroup) => {
const currentColor = getSharedAppliedColor([groupContext])
const pickedColor = await pickHexColor(
currentColor ?? getDefaultCustomNodeColor()
)
if (!pickedColor) return
await applyCustomColorToGroup(groupContext, pickedColor)
}
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
label: 'Fit Group To Nodes',
icon: 'icon-[lucide--move-diagonal-2]',
@@ -100,6 +89,7 @@ export function useGroupMenuOptions() {
icon: 'icon-[lucide--palette]',
hasSubmenu: true,
submenu: (() => {
const currentAppliedColor = getSharedAppliedColor([groupContext])
const presetEntries = colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
@@ -147,11 +137,16 @@ export function useGroupMenuOptions() {
...customEntries,
{
label: t('g.custom'),
color: getSharedAppliedColor([groupContext]) ?? getDefaultCustomNodeColor(),
action: async () => {
await openGroupCustomColorPicker(groupContext)
color: currentAppliedColor ?? undefined,
pickerValue: (currentAppliedColor ?? getDefaultCustomNodeColor()).replace(
'#',
''
),
onColorPick: async (color: string) => {
await applyCustomColorToGroup(groupContext, color)
bump()
}
},
action: () => {}
}
]
})()

View File

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

View File

@@ -13,9 +13,7 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import {
applyCustomColorToItems,
getDefaultCustomNodeColor,
getSharedAppliedColor,
pickHexColor
getSharedAppliedColor
} from '@/utils/nodeColorCustomization'
import { useCustomNodeColorSettings } from './useCustomNodeColorSettings'
@@ -127,15 +125,6 @@ export function useNodeCustomization() {
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
@@ -202,7 +191,6 @@ export function useNodeCustomization() {
getCurrentColor,
getCurrentAppliedColor,
getCurrentShape,
openCustomColorPicker,
favoriteColors,
recentColors,
isLightTheme

View File

@@ -10,7 +10,6 @@ const mocks = vi.hoisted(() => ({
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn(),
openCustomColorPicker: vi.fn(),
getCurrentAppliedColor: vi.fn<() => string | null>(() => null)
}))
@@ -44,8 +43,7 @@ vi.mock('./useNodeCustomization', () => ({
favoriteColors: { value: [] },
recentColors: { value: [] },
getCurrentAppliedColor: mocks.getCurrentAppliedColor,
isLightTheme: { value: false },
openCustomColorPicker: mocks.openCustomColorPicker
isLightTheme: { value: false }
})
}))
@@ -75,6 +73,7 @@ describe('useNodeMenuOptions', () => {
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 () => {
@@ -89,5 +88,6 @@ describe('useNodeMenuOptions', () => {
expect(customEntry).toBeDefined()
expect(customEntry?.color).toBe('#abcdef')
expect(customEntry?.pickerValue).toBe('abcdef')
})
})

View File

@@ -1,6 +1,8 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { getDefaultCustomNodeColor } from '@/utils/nodeColorCustomization'
import type { MenuOption } from './useMoreOptionsMenu'
import { useNodeCustomization } from './useNodeCustomization'
import { useSelectedNodeActions } from './useSelectedNodeActions'
@@ -20,8 +22,7 @@ export function useNodeMenuOptions() {
favoriteColors,
recentColors,
getCurrentAppliedColor,
isLightTheme,
openCustomColorPicker
isLightTheme
} = useNodeCustomization()
const {
adjustNodeSize,
@@ -39,6 +40,7 @@ export function useNodeMenuOptions() {
)
const colorSubmenu = computed(() => {
const currentAppliedColor = getCurrentAppliedColor()
const presetEntries = colorOptions.map((colorOption) => ({
label: colorOption.localizedName,
color: isLightTheme.value
@@ -80,10 +82,13 @@ export function useNodeMenuOptions() {
...customEntries,
{
label: t('g.custom'),
color: getCurrentAppliedColor() ?? undefined,
action: () => {
void openCustomColorPicker()
}
color: currentAppliedColor ?? undefined,
pickerValue: (currentAppliedColor ?? getDefaultCustomNodeColor()).replace(
'#',
''
),
onColorPick: applyCustomColor,
action: () => {}
}
]
})

View File

@@ -108,7 +108,6 @@ export {
NODE_COLOR_SWATCH_LIMIT,
deriveCustomNodeHeaderColor,
normalizeNodeColor,
pickHexColor,
toggleFavoriteNodeColor,
upsertRecentNodeColor
} from '@/utils/nodeColorPersistence'

View File

@@ -59,48 +59,3 @@ export function toggleFavoriteNodeColor(
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()
})
}