mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 06:20:11 +00:00
Use PrimeVue color picker in node menus
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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: () => {}
|
||||
}
|
||||
]
|
||||
})()
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface SubMenuOption {
|
||||
action: () => void
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
pickerValue?: string
|
||||
onColorPick?: (color: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -108,7 +108,6 @@ export {
|
||||
NODE_COLOR_SWATCH_LIMIT,
|
||||
deriveCustomNodeHeaderColor,
|
||||
normalizeNodeColor,
|
||||
pickHexColor,
|
||||
toggleFavoriteNodeColor,
|
||||
upsertRecentNodeColor
|
||||
} from '@/utils/nodeColorPersistence'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user