Selection toolbox color picker button (#2637)

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chenlei Hu
2025-02-19 15:25:46 -05:00
committed by GitHub
parent 08a6867c00
commit 6c6d86a30b
13 changed files with 299 additions and 8 deletions

View File

@@ -91,4 +91,97 @@ test.describe('Selection Toolbox', () => {
)
).not.toBeVisible()
})
test.describe('Color Picker', () => {
test('displays color picker button and allows color selection', async ({
comfyPage
}) => {
// Select a node
await comfyPage.selectNodes(['KSampler'])
// Color picker button should be visible
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).toBeVisible()
// Click color picker button
await colorPickerButton.click()
// Color picker dropdown should be visible
const colorPickerDropdown = comfyPage.page.locator(
'.color-picker-container'
)
await expect(colorPickerDropdown).toBeVisible()
// Select a color (e.g., blue)
const blueColorOption = colorPickerDropdown.locator(
'i[data-testid="blue"]'
)
await blueColorOption.click()
// Dropdown should close after selection
await expect(colorPickerDropdown).not.toBeVisible()
// Node should have the selected color class/style
// Note: Exact verification method depends on how color is applied to nodes
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
expect(selectedNode.getProperty('color')).not.toBeNull()
})
test('color picker shows current color of selected nodes', async ({
comfyPage
}) => {
// Select multiple nodes
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
// Initially should show default color
await expect(colorPickerButton).not.toHaveAttribute('color')
// Click color picker and select a color
await colorPickerButton.click()
const redColorOption = comfyPage.page.locator(
'.color-picker-container i[data-testid="red"]'
)
await redColorOption.click()
// Button should now show the selected color
await expect(colorPickerButton).toHaveCSS(
'color',
'rgb(85, 51, 51)' // Red color, adjust if different
)
})
test('color picker shows mixed state for differently colored selections', async ({
comfyPage
}) => {
// Select first node and color it
await comfyPage.selectNodes(['KSampler'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="blue"]')
.click()
await comfyPage.selectNodes(['KSampler'])
// Select second node and color it differently
await comfyPage.selectNodes(['CLIP Text Encode (Prompt)'])
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container i[data-testid="red"]')
.click()
// Select both nodes
await comfyPage.selectNodes(['KSampler', 'CLIP Text Encode (Prompt)'])
// Color picker should show null/mixed state
const colorPickerButton = comfyPage.page.locator(
'.selection-toolbox .pi-circle-fill'
)
await expect(colorPickerButton).not.toHaveAttribute('color')
})
})
})

8
package-lock.json generated
View File

@@ -11,7 +11,7 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.8.87",
"@comfyorg/litegraph": "^0.8.89",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -1944,9 +1944,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.87",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.87.tgz",
"integrity": "sha512-hEBe8Cc8C3PkWLfUxxhuO7zitYYCq3dO9mX8DfoK6On8EBE+1UijugVKfTWHuB/Yii4rN8yck/CI9yOYvCuD7Q==",
"version": "0.8.89",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.89.tgz",
"integrity": "sha512-/s5UUfZc3OOLmQQpAdRgPUkgK7vEqoClovzGIDmO3N++xkgbCmr1MGo8FQvC0+oqg56t5Ve1F+yYS54lwGZm0A==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -84,7 +84,7 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.8.87",
"@comfyorg/litegraph": "^0.8.89",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -34,15 +34,20 @@ import ColorPicker from 'primevue/colorpicker'
import SelectButton from 'primevue/selectbutton'
import { computed, onMounted, ref, watch } from 'vue'
const { modelValue, colorOptions } = defineProps<{
const {
modelValue,
colorOptions,
allowCustom = true
} = defineProps<{
modelValue: string | null
colorOptions: { name: Exclude<string, '_custom'>; value: string }[]
allowCustom?: boolean
}>()
const customColorOption = { name: '_custom', value: '' }
const colorOptionsWithCustom = computed(() => [
...colorOptions,
customColorOption
...(allowCustom ? [customColorOption] : [])
])
const emit = defineEmits<{

View File

@@ -6,6 +6,7 @@
content: 'p-0 flex flex-row'
}"
>
<ColorPickerButton />
<Button
v-if="nodeSelected"
severity="secondary"
@@ -46,6 +47,7 @@ import Button from 'primevue/button'
import Panel from 'primevue/panel'
import { computed } from 'vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import { useRefreshableSelection } from '@/composables/useRefreshableSelection'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'

View File

@@ -0,0 +1,140 @@
<template>
<div class="relative">
<Button
severity="secondary"
text
@click="() => (showColorPicker = !showColorPicker)"
>
<template #icon>
<div class="flex items-center gap-1">
<i class="pi pi-circle-fill" :style="{ color: currentColor }" />
<i class="pi pi-chevron-down" :style="{ fontSize: '0.5rem' }" />
</div>
</template>
</Button>
<div
v-if="showColorPicker"
class="color-picker-container absolute -top-10 left-1/2"
>
<SelectButton
:modelValue="selectedColorOption"
@update:modelValue="applyColor"
:options="colorOptions"
optionLabel="name"
dataKey="value"
>
<template #option="{ option }">
<i
class="pi pi-circle-fill"
:style="{
color: isLightTheme ? option.value.light : option.value.dark
}"
v-tooltip.top="option.localizedName"
:data-testid="option.name"
/>
</template>
</SelectButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { ColorOption as CanvasColorOption } from '@comfyorg/litegraph'
import { LGraphCanvas, LiteGraph, isColorable } from '@comfyorg/litegraph'
import Button from 'primevue/button'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCanvasStore } from '@/stores/graphStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { getItemsColorOption } from '@/utils/litegraphUtil'
const { t } = useI18n()
const canvasStore = useCanvasStore()
const colorPaletteStore = useColorPaletteStore()
const isLightTheme = computed(
() => colorPaletteStore.completedActivePalette.light_theme
)
const toLightThemeColor = (color: string) =>
adjustColor(color, { lightness: 0.5 })
const showColorPicker = ref(false)
type ColorOption = {
name: string
localizedName: string
value: {
dark: string
light: string
}
}
const NO_COLOR_OPTION: ColorOption = {
name: 'noColor',
localizedName: t('color.noColor'),
value: {
dark: LiteGraph.NODE_DEFAULT_BGCOLOR,
light: toLightThemeColor(LiteGraph.NODE_DEFAULT_BGCOLOR)
}
}
const colorOptions: ColorOption[] = [
NO_COLOR_OPTION,
...Object.entries(LGraphCanvas.node_colors).map(([name, color]) => ({
name,
localizedName: t(`color.${name}`),
value: {
dark: color.bgcolor,
light: toLightThemeColor(color.bgcolor)
}
}))
]
const selectedColorOption = ref<ColorOption | null>(null)
const applyColor = (colorOption: ColorOption | null) => {
const colorName = colorOption?.name ?? NO_COLOR_OPTION.name
const canvasColorOption =
colorName === NO_COLOR_OPTION.name
? null
: LGraphCanvas.node_colors[colorName]
for (const item of canvasStore.selectedItems) {
if (isColorable(item)) {
item.setColorOption(canvasColorOption)
}
}
canvasStore.canvas?.setDirty(true, true)
currentColorOption.value = canvasColorOption
showColorPicker.value = false
}
const currentColorOption = ref<CanvasColorOption | null>(null)
const currentColor = computed(() =>
currentColorOption.value
? isLightTheme.value
? toLightThemeColor(currentColorOption.value?.bgcolor)
: currentColorOption.value?.bgcolor
: null
)
watch(
() => canvasStore.selectedItems,
(newSelectedItems) => {
showColorPicker.value = false
selectedColorOption.value = null
currentColorOption.value = getItemsColorOption(newSelectedItems)
}
)
</script>
<style scoped>
.color-picker-container {
transform: translateX(-50%);
}
:deep(.p-togglebutton) {
@apply py-2 px-1;
}
</style>

View File

@@ -93,12 +93,18 @@
}
},
"color": {
"noColor": "No Color",
"default": "Default",
"blue": "Blue",
"green": "Green",
"red": "Red",
"pink": "Pink",
"yellow": "Yellow",
"brown": "Brown",
"pale_blue": "Pale Blue",
"cyan": "Cyan",
"purple": "Purple",
"black": "Black",
"custom": "Custom"
},
"contextMenu": {

View File

@@ -5,11 +5,17 @@
"successMessage": "Copié dans le presse-papiers"
},
"color": {
"black": "Noir",
"blue": "Bleu",
"brown": "Marron",
"custom": "Personnalisé",
"cyan": "Cyan",
"default": "Par défaut",
"green": "Vert",
"noColor": "Pas de couleur",
"pale_blue": "Bleu pâle",
"pink": "Rose",
"purple": "Violet",
"red": "Rouge",
"yellow": "Jaune"
},

View File

@@ -5,11 +5,17 @@
"successMessage": "クリップボードにコピーしました"
},
"color": {
"black": "黒",
"blue": "青",
"brown": "ブラウン",
"custom": "カスタム",
"cyan": "シアン",
"default": "デフォルト",
"green": "緑",
"noColor": "色なし",
"pale_blue": "淡い青",
"pink": "ピンク",
"purple": "パープル",
"red": "赤",
"yellow": "黄色"
},

View File

@@ -1,4 +1,5 @@
{
"0": "{",
"clipboard": {
"errorMessage": "클립보드에 복사하지 못했습니다",
"errorNotSupported": "브라우저가 클립보드 API를 지원하지 않습니다.",

View File

@@ -5,11 +5,17 @@
"successMessage": "Скопировано в буфер обмена"
},
"color": {
"black": "Черный",
"blue": "Синий",
"brown": "Коричневый",
"custom": "Пользовательский",
"cyan": "Голубой",
"default": "По умолчанию",
"green": "Зелёный",
"noColor": "Без цвета",
"pale_blue": "Бледно-синий",
"pink": "Розовый",
"purple": "Фиолетовый",
"red": "Красный",
"yellow": "Жёлтый"
},

View File

@@ -5,11 +5,17 @@
"successMessage": "已复制到剪贴板"
},
"color": {
"black": "黑色",
"blue": "蓝色",
"brown": "棕色",
"custom": "自定义",
"cyan": "青色",
"default": "默认",
"green": "绿色",
"noColor": "无色",
"pale_blue": "淡蓝色",
"pink": "粉色",
"purple": "紫色",
"red": "红色",
"yellow": "黄色"
},

View File

@@ -1,5 +1,7 @@
import type { IWidget, LGraphNode } from '@comfyorg/litegraph'
import type { ColorOption, IWidget } from '@comfyorg/litegraph'
import { LGraphNode, isColorable } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import _ from 'lodash'
export function isImageNode(node: LGraphNode) {
return (
@@ -22,3 +24,21 @@ export const isLGraphNode = (item: unknown): item is LGraphNode => {
const name = item?.constructor?.name
return name === 'ComfyNode' || name === 'LGraphNode'
}
/**
* Get the color option of all canvas items if they are all the same.
* @param items - The items to get the color option of.
* @returns The color option of the item.
*/
export const getItemsColorOption = (items: unknown[]): ColorOption | null => {
const validItems = _.filter(items, isColorable)
if (_.isEmpty(validItems)) return null
const colorOptions = _.map(validItems, (item) => item.getColorOption())
return _.every(colorOptions, (option) =>
_.isEqual(option, _.head(colorOptions))
)
? _.head(colorOptions)!
: null
}