Compare commits

..

15 Commits

Author SHA1 Message Date
dante01yoon
652d45f03a Tighten node menu color picker layout 2026-03-09 22:41:58 +09:00
Dante
e653a4326b Merge branch 'main' into feat/node-color-persistence-v1 2026-03-09 22:31:32 +09:00
dante01yoon
e15af715ea Remove PR screenshots 2026-03-09 22:05:50 +09:00
dante01yoon
9244b97fab Use PrimeVue color picker in node menus 2026-03-09 22:04:34 +09:00
dante01yoon
abee586cae Harden legacy color menu handling 2026-03-09 21:38:29 +09:00
dante01yoon
920159ecf2 Fix menu update type errors 2026-03-09 16:19:56 +09:00
dante01yoon
cef6bed5e3 Fix node menu custom color state 2026-03-09 16:07:00 +09:00
dante01yoon
046827fab5 Limit custom node colors to Node 2 menus 2026-03-09 15:46:00 +09:00
dante01yoon
5e60b1a2a0 Fix test import type annotations 2026-03-09 15:28:51 +09:00
dante01yoon
ed05e589bf Fix legacy group color menu scoping 2026-03-09 15:23:41 +09:00
dante01yoon
e6be6fd921 Fix legacy node color menu behavior 2026-03-09 14:35:09 +09:00
dante01yoon
327aeda027 Fix node color menu edge cases 2026-03-09 13:26:26 +09:00
dante01yoon
e5480c3a4c Use PrimeVue color picker for node colors 2026-03-09 12:36:28 +09:00
dante01yoon
aa97d176c2 Add PR screenshots for node color persistence 2026-03-09 12:26:00 +09:00
dante01yoon
36930a683a Add native custom node color persistence 2026-03-09 10:47:09 +09:00
77 changed files with 2589 additions and 1212 deletions

View File

@@ -14,7 +14,7 @@ on:
- 'cloud/*'
- 'main'
pull_request:
types: [labeled, synchronize]
types: [labeled]
workflow_dispatch:
permissions: {}
@@ -26,18 +26,11 @@ concurrency:
jobs:
dispatch:
# Fork guard: prevent forks from dispatching to the cloud repo.
# For pull_request events, only dispatch for preview labels.
# - labeled: fires when a label is added; check the added label name.
# - synchronize: fires on push; check existing labels on the PR.
# For pull_request events, only dispatch when the 'preview' label is added.
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
github.event.label.name == 'preview')
runs-on: ubuntu-latest
steps:
- name: Build client payload

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -1,62 +0,0 @@
# Release Process
## Bump Types
All releases use `release-version-bump.yaml`. Effects differ by bump type:
| Bump | Target | Creates branches? | GitHub release |
| ---------- | ---------- | ------------------------------------- | ---------------------------- |
| Minor | `main` | `core/` + `cloud/` for previous minor | Published, "latest" |
| Patch | `main` | No | Published, "latest" |
| Patch | `core/X.Y` | No | **Draft** (uncheck "latest") |
| Prerelease | any | No | Draft + prerelease |
**Minor bump** (e.g. 1.41→1.42): freezes the previous minor into `core/1.41`
and `cloud/1.41`, branched from the commit _before_ the bump. Nightly patch
bumps on `main` are convenience snapshots — no branches created.
**Patch on `core/X.Y`**: publishes a hotfix draft release. Must not be marked
"latest" so `main` stays current.
### Dual-homed commits
When a minor bump happens, unreleased commits appear in both places:
```
v1.40.1 ── A ── B ── C ── [bump to 1.41.0]
└── core/1.40
```
A, B, C become v1.41.0 on `main` AND sit on `core/1.40` (where they could
later ship as v1.40.2). Same commits, no divergence — the branch just prevents
1.41+ features from mixing in so ComfyUI can stay on 1.40.x.
## Backporting
1. Add `needs-backport` + version label to the merged PR
2. `pr-backport.yaml` cherry-picks and creates a backport PR
3. Conflicts produce a comment with details and an agent prompt
## Publishing
Merged PRs with the `Release` label trigger `release-draft-create.yaml`,
publishing to GitHub Releases (`dist.zip`), PyPI (`comfyui-frontend-package`),
and npm (`@comfyorg/comfyui-frontend-types`).
## Bi-weekly ComfyUI Integration
`release-biweekly-comfyui.yaml` runs every other Monday — if the next `core/`
branch has unreleased commits, it triggers a patch bump and drafts a PR to
`Comfy-Org/ComfyUI` updating `requirements.txt`.
## Workflows
| Workflow | Purpose |
| ------------------------------- | ------------------------------------------------ |
| `release-version-bump.yaml` | Bump version, create Release PR |
| `release-draft-create.yaml` | Build + publish to GitHub/PyPI/npm |
| `release-branch-create.yaml` | Create `core/` + `cloud/` branches (minor/major) |
| `release-biweekly-comfyui.yaml` | Auto-patch + ComfyUI requirements PR |
| `pr-backport.yaml` | Cherry-pick fixes to stable branches |
| `cloud-backport-tag.yaml` | Tag cloud branch merges |

View File

@@ -61,7 +61,7 @@
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<i class="icon-[lucide--share-2] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>

View File

@@ -8,7 +8,6 @@ import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -28,7 +27,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { promptRenameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -53,15 +52,18 @@ workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
const node = resolveNode(nodeId)
if (!node) return null
const widget = node.widgets?.find((w) => w.name === widgetName)
return { nodeId, widgetName, node, widget }
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = resolveNode(nodeId)
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (!node || !widget) {
return {
nodeId,
@@ -106,7 +108,7 @@ function getHovered(
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
const node = app.rootGraph.getNodeById(nodeId)
if (!node) return
const titleOffset =
@@ -119,6 +121,7 @@ function getBounding(nodeId: NodeId, widgetName?: string) {
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
@@ -157,16 +160,12 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
if (!isSelectInputsMode.value) return
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
if (index === -1) appModeStore.selectedInputs.push([node.id, widget.name])
else appModeStore.selectedInputs.splice(index, 1)
}

View File

@@ -1,51 +0,0 @@
<script setup lang="ts">
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
DialogTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
defineProps<{ title?: string; to?: string | HTMLElement }>()
const { t } = useI18n()
</script>
<template>
<DialogRoot v-slot="{ close }">
<DialogTrigger as-child>
<slot name="button" />
</DialogTrigger>
<DialogPortal :to>
<DialogOverlay
class="data-[state=open]:animate-overlayShow fixed inset-0 z-30 bg-black/70"
/>
<DialogContent
v-bind="$attrs"
class="data-[state=open]:animate-contentShow fixed top-[50%] left-[50%] z-1700 max-h-[85vh] w-[90vw] max-w-[450px] translate-x-[-50%] translate-y-[-50%] rounded-2xl border border-border-subtle bg-base-background p-2 shadow-sm"
>
<div
v-if="title"
class="flex w-full items-center justify-between border-b border-border-subtle px-4"
>
<DialogTitle class="text-sm">{{ title }}</DialogTitle>
<DialogClose as-child>
<Button
:aria-label="t('g.close')"
size="icon"
variant="muted-textonly"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
</div>
<slot :close />
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>

View File

@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: Omit<ComponentExposed<C>, 'focus'>
}
const meta: GenericMeta<typeof SearchBox> = {
title: 'Components/Input/SearchBox',
component: SearchBox,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text'
},
placeholder: {
control: 'text'
},
showBorder: {
control: 'boolean',
description: 'Toggle border prop'
},
size: {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
},
'onUpdate:modelValue': { action: 'update:modelValue' },
onSearch: { action: 'search' }
},
args: {
modelValue: '',
placeholder: 'Search...',
showBorder: false,
size: 'md'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { SearchBox },
setup() {
const searchText = ref('')
return { searchText, args }
},
template: `
<div style="max-width: 320px;">
<SearchBox v-bind="args" v-model="searchText" />
</div>
`
})
}
export const WithBorder: Story = {
...Default,
args: {
showBorder: true
}
}
export const NoBorder: Story = {
...Default,
args: {
showBorder: false
}
}
export const MediumSize: Story = {
...Default,
args: {
size: 'md',
showBorder: false
}
}
export const LargeSize: Story = {
...Default,
args: {
size: 'lg',
showBorder: false
}
}
export const LargeSizeWithBorder: Story = {
...Default,
args: {
size: 'lg',
showBorder: true
}
}

View File

@@ -0,0 +1,193 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWidgets: {
sort: {
searchPlaceholder: 'Search...'
}
}
}
}
})
describe('SearchBox', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
const createWrapper = (props = {}) => {
return mount(SearchBox, {
props: {
modelValue: '',
...props
},
global: {
plugins: [i18n]
}
})
}
describe('debounced search functionality', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type search query
await input.setValue('test')
// Model should not update immediately
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 299ms (just before debounce delay)
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 1ms more (reaching 300ms)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
// Model should now be updated
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['test'])
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Type first character
await input.setValue('t')
vi.advanceTimersByTime(200)
await nextTick()
// Type second character (should reset timer)
await input.setValue('te')
vi.advanceTimersByTime(200)
await nextTick()
// Type third character (should reset timer again)
await input.setValue('tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
// Should not have emitted yet (only 200ms passed since last keystroke)
expect(wrapper.emitted('search')).toBeFalsy()
// Advance final 100ms to reach 300ms
await vi.advanceTimersByTimeAsync(100)
await nextTick()
// Should now emit with final value
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
})
it('should only emit final value after rapid typing', async () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
// Simulate rapid typing
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
}
await nextTick()
// Should not have emitted yet
expect(wrapper.emitted('search')).toBeFalsy()
// Complete the debounce delay
await vi.advanceTimersByTimeAsync(350)
await nextTick()
// Should emit only once with final value
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
})
describe('bidirectional model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = createWrapper({ modelValue: 'initial' })
const input = wrapper.find('input')
expect(input.element.value).toBe('initial')
// Update model externally
await wrapper.setProps({ modelValue: 'external update' })
await nextTick()
// Internal state should sync
expect(input.element.value).toBe('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = createWrapper({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
expect(input.attributes('aria-label')).toBe('Custom search...')
})
it('should use default placeholder when not provided', () => {
const wrapper = createWrapper()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
expect(input.attributes('aria-label')).toBe('Search...')
})
})
describe('autofocus', () => {
it('should focus input when autofocus is true', async () => {
const wrapper = createWrapper({ autofocus: true })
await nextTick()
const input = wrapper.find('input')
const inputElement = input.element as HTMLInputElement
// Note: In JSDOM, focus() doesn't actually set document.activeElement
// We can only verify that the focus method exists and doesn't throw
expect(inputElement.focus).toBeDefined()
})
it('should not autofocus when autofocus is false', () => {
const wrapper = createWrapper({ autofocus: false })
const input = wrapper.find('input')
expect(document.activeElement).not.toBe(input.element)
})
})
describe('click to focus', () => {
it('should focus input when wrapper is clicked', async () => {
const wrapper = createWrapper()
const wrapperDiv = wrapper.find('[class*="flex"]')
await wrapperDiv.trigger('click')
await nextTick()
// Input should receive focus
const input = wrapper.find('input').element as HTMLInputElement
expect(input.focus).toBeDefined()
})
})
})
})

View File

@@ -0,0 +1,139 @@
<template>
<div
:class="
cn(
'relative flex w-full cursor-text items-center gap-2 bg-comfy-input text-comfy-input-foreground',
customClass,
wrapperStyle
)
"
>
<InputText
ref="inputRef"
v-model="modelValue"
:placeholder
:autofocus
unstyled
:class="
cn(
'absolute inset-0 size-full border-none bg-transparent text-sm outline-none',
isLarge ? 'pl-11' : 'pl-8'
)
"
:aria-label="placeholder"
/>
<Button
v-if="filterIcon"
size="icon"
variant="textonly"
class="filter-button absolute inset-y-0 right-0 m-0 p-0"
@click="$emit('showFilter', $event)"
>
<i :class="filterIcon" />
</Button>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
:class="cn('clear-button absolute', isLarge ? 'left-2' : 'left-0')"
variant="textonly"
size="icon"
@click="modelValue = ''"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { cn } from '@comfyorg/tailwind-utils'
import { watchDebounced } from '@vueuse/core'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const {
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = [],
autofocus = false,
showBorder = false,
size = 'md',
class: customClass
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const isLarge = computed(() => size === 'lg')
const emit = defineEmits<{
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const modelValue = defineModel<string>({ required: true })
const inputRef = ref()
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
watchDebounced(
modelValue,
(value: string) => {
emit('search', value, filters)
},
{ debounce: debounceTime }
)
const wrapperStyle = computed(() => {
if (showBorder) {
return cn(
'box-border rounded-sm border border-solid border-border-default p-2',
isLarge.value ? 'h-10' : 'h-8'
)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn('rounded-lg', sizeClasses)
})
</script>
<style scoped>
:deep(.p-inputtext) {
--p-form-field-padding-x: 0.625rem;
}
</style>

View File

@@ -0,0 +1,90 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SearchBoxV2 from './SearchBoxV2.vue'
vi.mock('@vueuse/core', () => ({
watchDebounced: vi.fn(() => vi.fn())
}))
describe('SearchBoxV2', () => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
clear: 'Clear',
searchPlaceholder: 'Search...'
}
}
}
})
function mountComponent(props = {}) {
return mount(SearchBoxV2, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: {
template: '<div><slot /></div>'
},
ComboboxAnchor: {
template: '<div><slot /></div>'
},
ComboboxInput: {
template:
'<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['placeholder', 'modelValue', 'autoFocus']
}
}
},
props: {
modelValue: '',
...props
}
})
}
it('uses i18n placeholder when no placeholder prop provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
})
it('uses custom placeholder when provided', () => {
const wrapper = mountComponent({
placeholder: 'Custom placeholder'
})
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom placeholder')
})
it('shows search icon when search term is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
})
it('shows clear button when search term is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button').exists()).toBe(true)
})
it('clears search term when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
it('applies large size classes when size is lg', () => {
const wrapper = mountComponent({ size: 'lg' })
expect(wrapper.html()).toContain('size-5')
})
it('applies medium size classes when size is md', () => {
const wrapper = mountComponent({ size: 'md' })
expect(wrapper.html()).toContain('size-4')
})
})

View File

@@ -0,0 +1,117 @@
<template>
<div class="flex flex-auto flex-col gap-2">
<ComboboxRoot :ignore-filter="true" :open="false">
<ComboboxAnchor
:class="
cn(
'relative flex w-full cursor-text items-center',
'rounded-lg bg-comfy-input text-comfy-input-foreground',
showBorder &&
'box-border border border-solid border-border-default',
sizeClasses,
className
)
"
>
<i
v-if="!searchTerm"
:class="cn('pointer-events-none absolute left-4', icon, iconClass)"
/>
<Button
v-else
class="absolute left-2"
variant="textonly"
size="icon"
:aria-label="$t('g.clear')"
@click="clearSearch"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<ComboboxInput
ref="inputRef"
v-model="searchTerm"
:class="
cn(
'size-full border-none bg-transparent text-sm outline-none',
inputPadding
)
"
:placeholder="placeholderText"
:auto-focus="autofocus"
/>
</ComboboxAnchor>
</ComboboxRoot>
</div>
</template>
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
import { watchDebounced } from '@vueuse/core'
import { ComboboxAnchor, ComboboxInput, ComboboxRoot } from 'reka-ui'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
const { t } = useI18n()
const {
placeholder,
icon = 'icon-[lucide--search]',
debounceTime = 300,
autofocus = false,
showBorder = false,
size = 'md',
class: className
} = defineProps<{
placeholder?: string
icon?: string
debounceTime?: number
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const emit = defineEmits<{
search: [value: string]
}>()
const searchTerm = defineModel<string>({ required: true })
const inputRef = ref<InstanceType<typeof ComboboxInput> | null>(null)
defineExpose({
focus: () => {
inputRef.value?.$el?.focus()
}
})
const isLarge = computed(() => size === 'lg')
const placeholderText = computed(
() => placeholder ?? t('g.searchPlaceholder', { subject: '' })
)
const sizeClasses = computed(() => {
if (showBorder) {
return isLarge.value ? 'h-10 p-2' : 'h-8 p-2'
}
return isLarge.value ? 'h-12 px-4 py-2' : 'h-10 px-4 py-2'
})
const iconClass = computed(() => (isLarge.value ? 'size-5' : 'size-4'))
const inputPadding = computed(() => (isLarge.value ? 'pl-8' : 'pl-6'))
function clearSearch() {
searchTerm.value = ''
}
watchDebounced(
searchTerm,
(value: string) => {
emit('search', value)
},
{ debounce: debounceTime }
)
</script>

View File

@@ -155,93 +155,6 @@ describe('VirtualGrid', () => {
wrapper.unmount()
})
it('emits approach-end for single-column list when scrolled near bottom', async () => {
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)'
},
defaultItemHeight: 48,
defaultItemWidth: 200,
maxColumns: 1,
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
expect(wrapper.emitted('approach-end')).toBeUndefined()
// Scroll near the end: 50 items * 48px = 2400px total
// viewRows = ceil(600/48) = 13, buffer = 1
// Need toCol >= items.length - cols*bufferRows = 50 - 1 = 49
// toCol = (offsetRows + bufferRows + viewRows) * cols
// offsetRows = floor(scrollY / 48)
// Need (offsetRows + 1 + 13) * 1 >= 49 → offsetRows >= 35
// scrollY = 35 * 48 = 1680
mockedScrollY.value = 1680
await nextTick()
expect(wrapper.emitted('approach-end')).toBeDefined()
wrapper.unmount()
})
it('does not emit approach-end without maxColumns in single-column layout', async () => {
// Demonstrates the bug: without maxColumns=1, cols is calculated
// from width/itemWidth (400/200 = 2), causing incorrect row math
const items = createItems(50)
mockedWidth.value = 400
mockedHeight.value = 600
mockedScrollY.value = 0
const wrapper = mount(VirtualGrid<TestItem>, {
props: {
items,
gridStyle: {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)'
},
defaultItemHeight: 48,
defaultItemWidth: 200,
// No maxColumns — cols will be floor(400/200) = 2
bufferRows: 1
},
slots: {
item: `<template #item="{ item }">
<div class="test-item">{{ item.name }}</div>
</template>`
},
attachTo: document.body
})
await nextTick()
// Same scroll position as the passing test
mockedScrollY.value = 1680
await nextTick()
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
// The approach-end never fires at the correct scroll position
expect(wrapper.emitted('approach-end')).toBeUndefined()
wrapper.unmount()
})
it('forces cols to maxColumns when maxColumns is finite', async () => {
mockedWidth.value = 100
mockedHeight.value = 200

View File

@@ -14,10 +14,10 @@
</template>
<template #header>
<SearchInput
<SearchBox
v-model="searchQuery"
size="lg"
class="max-w-96 flex-1"
class="max-w-[384px]"
autofocus
/>
</template>
@@ -389,7 +389,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'

View File

@@ -1,6 +1,6 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchInput
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
@@ -155,7 +155,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { KeyComboImpl } from '@/platform/keybindings/keyCombo'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'

View File

@@ -50,9 +50,7 @@
{{ t('g.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="seeErrors">
{{
appMode ? t('linearMode.error.goto') : t('errorOverlay.seeErrors')
}}
{{ t('errorOverlay.seeErrors') }}
</Button>
</div>
</div>
@@ -71,8 +69,6 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
@@ -98,7 +94,6 @@ function dismiss() {
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()

View File

@@ -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()
})
})

View File

@@ -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[]>
) => {

View File

@@ -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'
])
})
})

View File

@@ -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

View File

@@ -93,12 +93,13 @@
#header
>
<div class="flex flex-col px-2 pt-2 pb-0">
<SearchInput
<SearchBox
v-if="showSearchBox"
v-model="searchQuery"
:class="showSelectedCount || showClearButton ? 'mb-2' : ''"
:placeholder="searchPlaceholder"
size="sm"
:show-order="true"
:show-border="true"
:place-holder="searchPlaceholder"
/>
<div
v-if="showSelectedCount || showClearButton"
@@ -181,7 +182,7 @@ import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex min-w-0 items-center gap-2">
<SearchInput
<SearchBox
v-if="showSearch"
:model-value="searchQuery"
class="min-w-0 flex-1"
@@ -116,7 +116,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'

View File

@@ -1,5 +1,5 @@
import { computed, reactive, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import type { Ref } from 'vue'
import Fuse from 'fuse.js'
import type { IFuseOptions } from 'fuse.js'
@@ -227,7 +227,7 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
}
export function useErrorGroups(
searchQuery: MaybeRefOrGetter<string>,
searchQuery: Ref<string>,
t: (key: string) => string
) {
const executionErrorStore = useExecutionErrorStore()
@@ -584,7 +584,7 @@ export function useErrorGroups(
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = toValue(searchQuery).trim()
const query = searchQuery.value.trim()
return searchErrorGroups(tabErrorGroups.value, query)
})

View File

@@ -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>

View File

@@ -18,8 +18,6 @@
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
:max-columns="1"
:default-item-height="48"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">

View File

@@ -21,7 +21,7 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchInput
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
@@ -146,7 +146,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'

View File

@@ -22,7 +22,7 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<SearchInput
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
:placeholder="
@@ -56,7 +56,7 @@
import { Divider } from 'primevue'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'

View File

@@ -86,40 +86,18 @@
</template>
<template #header>
<div class="px-2 2xl:px-4">
<div class="flex items-center gap-1">
<SearchInput
ref="searchBoxRef"
v-model="searchQuery"
data-testid="node-library-search"
class="node-lib-search-box"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.nodes') })
"
@search="handleSearch"
/>
<Button
variant="textonly"
size="icon"
class="filter-button shrink-0"
:aria-label="$t('g.filter')"
@click="(e: Event) => searchFilter?.toggle(e)"
>
<i class="pi pi-filter" />
</Button>
</div>
<div
v-if="filters?.length"
class="search-filters flex flex-wrap gap-2 pt-2"
>
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="onRemoveFilter(filter)"
/>
</div>
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
data-testid="node-library-search"
class="node-lib-search-box"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.nodes') })"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
/>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
@@ -177,9 +155,8 @@ import {
} from 'vue'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchFilterChip from '@/components/common/SearchFilterChip.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'

View File

@@ -69,7 +69,7 @@ vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
}
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
vi.mock('@/components/common/SearchBoxV2.vue', () => ({
default: {
name: 'SearchBox',
template: '<input data-testid="search-box" />',

View File

@@ -3,7 +3,7 @@
<template #header>
<TabsRoot v-model="selectedTab" class="flex flex-col">
<div class="flex items-center justify-between gap-2 px-2 pb-2 2xl:px-4">
<SearchInput
<SearchBox
ref="searchBoxRef"
v-model="searchQuery"
:placeholder="$t('g.search') + '...'"
@@ -180,7 +180,7 @@ import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
@@ -253,7 +253,7 @@ const filterOptions = ref<Record<NodeCategoryId, boolean>>({
const { t } = useI18n()
const searchBoxRef = ref<InstanceType<typeof SearchInput> | null>(null)
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
const searchQuery = ref('')
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],

View File

@@ -24,7 +24,7 @@ function handleWheel(e: WheelEvent) {
let dragging = false
function handleDown(e: PointerEvent) {
if (e.button !== 0 && e.button !== 1) return
if (e.button !== 0) return
const zoomPaneEl = zoomPane.value
if (!zoomPaneEl) return

View File

@@ -1,202 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { createI18n } from 'vue-i18n'
import SearchInput from './SearchInput.vue'
vi.mock('@vueuse/core', () => ({
watchDebounced: vi.fn((source, cb, opts) => {
let timer: ReturnType<typeof setTimeout> | null = null
return watch(source, (val: string) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => cb(val), opts?.debounce ?? 300)
})
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
clear: 'Clear',
searchPlaceholder: 'Search...'
}
}
}
})
describe('SearchInput', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
function mountComponent(props = {}) {
return mount(SearchInput, {
global: {
plugins: [i18n],
stubs: {
ComboboxRoot: {
template: '<div><slot /></div>'
},
ComboboxAnchor: {
template: '<div @click="$emit(\'click\')"><slot /></div>',
emits: ['click']
},
ComboboxInput: {
template:
'<input :placeholder="placeholder" :value="modelValue" :autofocus="autoFocus || undefined" @input="$emit(\'update:modelValue\', $event.target.value)" />',
props: ['placeholder', 'modelValue', 'autoFocus']
}
}
},
props: {
modelValue: '',
...props
}
})
}
describe('debounced search', () => {
it('should debounce search input by 300ms', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
await input.setValue('test')
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(1)
await nextTick()
expect(wrapper.emitted('search')).toEqual([['test']])
})
it('should reset debounce timer on each keystroke', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
await input.setValue('t')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('te')
vi.advanceTimersByTime(200)
await nextTick()
await input.setValue('tes')
await vi.advanceTimersByTimeAsync(200)
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(100)
await nextTick()
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes'])
})
it('should only emit final value after rapid typing', async () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
await vi.advanceTimersByTimeAsync(50)
}
await nextTick()
expect(wrapper.emitted('search')).toBeFalsy()
await vi.advanceTimersByTimeAsync(350)
await nextTick()
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search'])
})
})
describe('model sync', () => {
it('should sync external model changes to internal state', async () => {
const wrapper = mountComponent({ modelValue: 'initial' })
const input = wrapper.find('input')
expect(input.element.value).toBe('initial')
await wrapper.setProps({ modelValue: 'external update' })
await nextTick()
expect(input.element.value).toBe('external update')
})
})
describe('placeholder', () => {
it('should use custom placeholder when provided', () => {
const wrapper = mountComponent({ placeholder: 'Custom search...' })
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Custom search...')
})
it('should use i18n placeholder when not provided', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('placeholder')).toBe('Search...')
})
})
describe('autofocus', () => {
it('should pass autofocus prop to ComboboxInput', () => {
const wrapper = mountComponent({ autofocus: true })
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeDefined()
})
it('should not autofocus by default', () => {
const wrapper = mountComponent()
const input = wrapper.find('input')
expect(input.attributes('autofocus')).toBeUndefined()
})
})
describe('focus method', () => {
it('should expose focus method via ref', () => {
const wrapper = mountComponent()
expect(wrapper.vm.focus).toBeDefined()
})
})
describe('clear button', () => {
it('shows search icon when value is empty', () => {
const wrapper = mountComponent({ modelValue: '' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(false)
})
it('shows clear button when value is not empty', () => {
const wrapper = mountComponent({ modelValue: 'test' })
expect(wrapper.find('button[aria-label="Clear"]').exists()).toBe(true)
})
it('clears value when clear button is clicked', async () => {
const wrapper = mountComponent({ modelValue: 'test' })
const clearButton = wrapper.find('button')
await clearButton.trigger('click')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
})
})
})

View File

@@ -1,15 +1,11 @@
<template>
<ComboboxRoot
:ignore-filter="true"
:open="false"
:disabled="disabled"
:class="className"
>
<ComboboxRoot :ignore-filter="true" :open="false" :disabled="disabled">
<ComboboxAnchor
:class="
cn(
searchInputVariants({ size }),
disabled && 'pointer-events-none opacity-50'
disabled && 'pointer-events-none opacity-50',
className
)
"
@click="focus"

View File

@@ -1,77 +0,0 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { computed, ref, toRefs } from 'vue'
import Slider from './Slider.vue'
interface StoryArgs extends ComponentPropsAndSlots<typeof Slider> {
disabled: boolean
}
const meta: Meta<StoryArgs> = {
title: 'Components/Slider',
component: Slider,
tags: ['autodocs'],
parameters: { layout: 'centered' },
argTypes: {
min: { control: 'number' },
max: { control: 'number' },
step: { control: 'number' },
disabled: { control: 'boolean' }
},
args: {
min: 0,
max: 100,
step: 1,
disabled: false
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-72"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { Slider },
setup() {
const { min, max, step, disabled } = toRefs(args)
const value = ref([36])
const display = computed(() => value.value[0])
return { value, display, min, max, step, disabled }
},
template: `
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
</div>
`
})
}
export const Disabled: Story = {
args: { disabled: true },
render: (args) => ({
components: { Slider },
setup() {
const { min, max, step, disabled } = toRefs(args)
const value = ref([36])
const display = computed(() => value.value[0])
return { value, display, min, max, step, disabled }
},
template: `
<div class="flex items-center gap-4 rounded-lg bg-component-node-widget-background px-3 py-2">
<Slider v-model="value" :min :max :step :disabled class="flex-1" />
<span class="w-14 shrink-0 text-right text-xs text-component-node-foreground">{{ display }}</span>
</div>
`
})
}

View File

@@ -12,7 +12,7 @@
</template>
<template #header>
<SearchInput v-model="searchQuery" size="lg" class="max-w-96 flex-1" />
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
</template>
<template #header-right-area>
@@ -130,7 +130,7 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -8,7 +8,7 @@ import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -68,7 +68,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
components: {
BaseModalLayout,
LeftSidePanel,
SearchInput,
SearchBox,
MultiSelect,
SingleSelect,
Button,
@@ -186,7 +186,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchInput
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"
@@ -309,7 +309,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
<!-- Header -->
<template v-if="args.hasHeader" #header>
<SearchInput
<SearchBox
class="max-w-[384px]"
size="lg"
:modelValue="searchQuery"

View 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
}
}

View 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]
)
})
})

View File

@@ -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 = (

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

@@ -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
}
}

View 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')
})
})

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'
@@ -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 => ({

View File

@@ -1,5 +1,6 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
@@ -28,6 +29,7 @@ useExtensionService().registerExtension({
const toUrl = (record: Record<string, string>) => {
const params = new URLSearchParams(record)
appendCloudResParam(params, record.filename)
return api.apiURL(`/view?${params}${rand}`)
}

View 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('&lt;img src=x onerror=1&gt;'))
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()
}
})
})

View File

@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
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)
}

View File

@@ -3180,7 +3180,6 @@
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
@@ -3225,19 +3224,6 @@
"outputPlaceholder": "Output nodes will show up here",
"outputRequiredPlaceholder": "At least one node is required"
},
"error": {
"header": "This app encountered an error",
"log": "Error Logs",
"mobileFixable": "Check {0} for errors",
"requiresGraph": "Something went wrong during generation. This could be due to invalid hidden inputs, missing resources, or workflow configuration issues.",
"promptVisitGraph": "View the node graph to see the full error.",
"getHelp": "For help, view our {0}, {1}, or {2} with the copied error.",
"goto": "Show errors in graph",
"github": "submit a GitHub issue",
"guide": "troubleshooting guide",
"support": "contact our support",
"promptShow": "Show error report"
},
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"

View File

@@ -26,12 +26,12 @@
class="flex w-full items-center justify-between gap-2"
@click.self="focusedAsset = null"
>
<SearchInput
<SearchBox
v-model="searchQuery"
:autofocus="true"
size="lg"
:placeholder="$t('g.searchPlaceholder', { subject: '' })"
class="max-w-lg flex-1"
class="max-w-96"
/>
<Button
v-if="isUploadButtonEnabled"
@@ -88,7 +88,7 @@ import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'

View File

@@ -236,7 +236,7 @@ const adaptedAsset = computed(() => {
name: asset.name,
display_name: asset.display_name,
kind: fileKind.value,
src: asset.thumbnail_url || asset.preview_url || '',
src: asset.preview_url || '',
size: asset.size,
tags: asset.tags || [],
created_at: asset.created_at,

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex items-center gap-3">
<SearchInput
<SearchBox
:model-value="searchQuery"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('sideToolbar.labels.assets') })
@@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { isCloud } from '@/platform/distribution/types'
import MediaAssetFilterButton from './MediaAssetFilterButton.vue'

View File

@@ -45,8 +45,7 @@ export function mapTaskOutputToAssetItem(
? new Date(taskItem.executionStartTimestamp).toISOString()
: new Date().toISOString(),
tags: ['output'],
thumbnail_url: output.previewUrl,
preview_url: output.url,
preview_url: output.previewUrl,
user_metadata: metadata
}
}
@@ -64,7 +63,6 @@ export function mapInputFileToAssetItem(
directory: 'input' | 'output' = 'input'
): AssetItem {
const params = new URLSearchParams({ filename, type: directory })
const preview_url = api.apiURL(`/view?${params}`)
appendCloudResParam(params, filename)
return {
@@ -73,7 +71,6 @@ export function mapInputFileToAssetItem(
size: 0,
created_at: new Date().toISOString(),
tags: [directory],
thumbnail_url: api.apiURL(`/view?${params}`),
preview_url
preview_url: api.apiURL(`/view?${params}`)
}
}

View File

@@ -11,7 +11,6 @@ const zAsset = z.object({
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
is_immutable: z.boolean().optional(),

View File

@@ -73,8 +73,7 @@ function mapOutputsToAssetItems({
size: 0,
created_at: createdAtValue,
tags: ['output'],
thumbnail_url: output.previewUrl,
preview_url: output.url,
preview_url: output.previewUrl,
user_metadata: {
jobId,
nodeId: output.nodeId,

View File

@@ -31,19 +31,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
path: 'signup',
name: 'cloud-signup',
component: () =>
import('@/platform/cloud/onboarding/CloudSignupView.vue'),
beforeEnter: async (to, _from, next) => {
if (!to.query.switchAccount) {
const { useCurrentUser } =
await import('@/composables/auth/useCurrentUser')
const { isLoggedIn } = useCurrentUser()
if (isLoggedIn.value) {
return next({ name: 'cloud-user-check' })
}
}
next()
}
import('@/platform/cloud/onboarding/CloudSignupView.vue')
},
{
path: 'forgot-password',

View File

@@ -1,6 +1,6 @@
<template>
<div class="extension-panel flex flex-col gap-2">
<SearchInput
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.extensions') })"
/>
@@ -92,7 +92,7 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useExtensionStore } from '@/stores/extensionStore'

View File

@@ -7,7 +7,7 @@
<template #leftPanel>
<div class="px-3">
<SearchInput
<SearchBox
v-model:model-value="searchQuery"
size="md"
:placeholder="$t('g.searchSettings') + '...'"
@@ -71,7 +71,7 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import CurrentUserMessage from '@/components/dialog/content/setting/CurrentUserMessage.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import NavItem from '@/components/widget/nav/NavItem.vue'

View File

@@ -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'],

View File

@@ -46,10 +46,7 @@
onThumbnailError($event.name, $event.previewUrl)
"
/>
<span
v-tooltip="buildTooltipConfig(item.name)"
class="truncate text-xs text-base-foreground"
>
<span class="truncate text-xs text-base-foreground">
{{ item.name }}
</span>
<span
@@ -77,7 +74,6 @@ import ShareAssetThumbnail from '@/platform/workflow/sharing/components/ShareAss
import { useAssetSections } from '@/platform/workflow/sharing/composables/useAssetSections'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const { items } = defineProps<{
items: AssetInfo[]

View File

@@ -32,7 +32,7 @@
v-if="uiConfig.showSearch && !isSingleSeatPlan"
class="flex items-start gap-2"
>
<SearchInput
<SearchBox
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
@@ -367,7 +367,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

@@ -10,7 +10,6 @@ import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useBillingContext } from '@/composables/billing/useBillingContext'
@@ -30,7 +29,7 @@ import { useQueueSettingsStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { resolveNode } from '@/utils/litegraphUtil'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionErrorStore = useExecutionErrorStore()
@@ -64,41 +63,21 @@ useEventListener(
)
const mappedSelections = computed(() => {
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return widget ? ([[node, widget]] as const) : []
}
)
let unprocessedInputs = [...appModeStore.selectedInputs]
//FIXME strict typing here
const processedInputs: ReturnType<typeof nodeToNodeData>[] = []
while (unprocessedInputs.length) {
const [node] = unprocessedInputs[0]
const inputGroup = takeWhile(unprocessedInputs, ([n]) => n === node).map(
([, widget]) => widget
)
const nodeId = unprocessedInputs[0][0]
const inputGroup = takeWhile(
unprocessedInputs,
([id]) => id === nodeId
).map(([, widgetName]) => widgetName)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
//FIXME: hide widget if owning node bypassed
const node = resolveNode(nodeId)
if (node?.mode !== LGraphEventMode.ALWAYS) continue
const nodeData = nodeToNodeData(node)
remove(nodeData.widgets ?? [], (vueWidget) => {
if (vueWidget.slotMetadata?.linked) return true
if (!node.isSubgraphNode())
return !inputGroup.some((w) => w.name === vueWidget.name)
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return !inputGroup.some(
(subWidget) =>
isPromotedWidgetView(subWidget) &&
subWidget.sourceNodeId == storeNodeId &&
subWidget.sourceWidgetName === vueWidget.storeName
)
})
for (const widget of nodeData.widgets ?? []) {
widget.slotMetadata = undefined
widget.nodeId = String(node.id)
}
remove(nodeData.widgets ?? [], (w) => !inputGroup.includes(w.name))
processedInputs.push(nodeData)
}
return processedInputs
@@ -128,6 +107,8 @@ function getDropIndicator(node: LGraphNode) {
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
remove(nodeData.widgets ?? [], (w) => w.slotMetadata?.linked ?? false)
for (const widget of nodeData.widgets ?? []) widget.slotMetadata = undefined
return {
...nodeData,

View File

@@ -15,9 +15,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import MobileError from '@/renderer/extensions/linearMode/MobileError.vue'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueStore } from '@/stores/queueStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
@@ -33,7 +31,6 @@ const canvasStore = useCanvasStore()
const colorPaletteService = useColorPaletteService()
const colorPaletteStore = useColorPaletteStore()
const { isLoggedIn } = useCurrentUser()
const executionErrorStore = useExecutionErrorStore()
const { t } = useI18n()
const { commandIdToMenuItem } = useMenuItemStore()
const queueStore = useQueueStore()
@@ -43,7 +40,7 @@ const { toggle: toggleFullscreen } = useFullscreen(undefined, {
autoExit: true
})
const activeIndex = ref(1)
const activeIndex = ref(2)
const sliderPaneRef = useTemplateRef('sliderPaneRef')
const sliderWidth = computed(() => sliderPaneRef.value?.offsetWidth)
@@ -195,11 +192,7 @@ const menuEntries = computed<MenuItem[]>(() => [
<div
class="absolute top-0 left-[100vw] flex h-full w-screen flex-col bg-base-background"
>
<MobileError
v-if="executionErrorStore.isErrorOverlayOpen"
@navigate-controls="activeIndex = 0"
/>
<LinearPreview v-else mobile @navigate-controls="activeIndex = 0" />
<LinearPreview mobile />
</div>
<AssetsSidebarTab
class="absolute top-0 left-[200vw] h-full w-screen bg-base-background"
@@ -220,11 +213,7 @@ const menuEntries = computed<MenuItem[]>(() => [
<div class="relative size-4">
<i :class="cn('size-4', icon)" />
<div
v-if="index === 1 && executionErrorStore.isErrorOverlayOpen"
class="absolute -top-1 -right-1 size-2 rounded-full bg-error"
/>
<div
v-else-if="
v-if="
index === 1 &&
(queueStore.runningTasks.length > 0 ||
queueStore.pendingTasks.length > 0)

View File

@@ -1,174 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialogue from '@/components/common/Dialogue.vue'
import { useErrorGroups } from '@/components/rightSidePanel/errors/useErrorGroups'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useExternalLink } from '@/composables/useExternalLink'
import { buildSupportUrl } from '@/platform/support/config'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
defineEmits<{ navigateControls: [] }>()
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const executionErrorStore = useExecutionErrorStore()
const { buildDocsUrl, staticUrls } = useExternalLink()
const { allErrorGroups } = useErrorGroups('', t)
const { copyToClipboard } = useCopyToClipboard()
const guideUrl = buildDocsUrl('troubleshooting/overview', {
includeLocale: true
})
const supportUrl = buildSupportUrl()
const inputNodeIds = computed(() => {
const ids = new Set()
for (const [id] of appModeStore.selectedInputs) ids.add(String(id))
return ids
})
const accessibleNodeErrors = computed(() =>
Object.keys(executionErrorStore.lastNodeErrors ?? {}).filter((k) =>
inputNodeIds.value.has(k)
)
)
const accessibleErrors = computed(() =>
accessibleNodeErrors.value.flatMap((k) =>
executionErrorStore.lastNodeErrors![k].errors.flatMap((error) => {
const { extra_info } = error
if (!extra_info) return []
const selectedInput = appModeStore.selectedInputs.find(
([id, name]) => id == k && extra_info.input_name === name
)
if (!selectedInput) return []
return [`${selectedInput[1]}: ${error.message}`]
})
)
)
const allErrors = computed(() =>
allErrorGroups.value.flatMap((group) => {
if (group.type !== 'execution') return [group.title]
return group.cards.flatMap((c) =>
c.errors.map((e) =>
e.details
? `${c.title} (${e.details}): ${e.message}`
: `${c.title}: ${e.message}`
)
)
})
)
function copy(obj: unknown) {
copyToClipboard(JSON.stringify(obj))
}
</script>
<template>
<section class="flex h-full flex-col items-center justify-center gap-2 px-4">
<i class="icon-[lucide--circle-alert] size-6 bg-error" />
{{ t('linearMode.error.header') }}
<div class="p-1 text-muted-foreground">
<i18n-t
v-if="accessibleErrors.length"
keypath="linearMode.error.mobileFixable"
>
<Button @click="$emit('navigateControls')">
{{ t('linearMode.mobileControls') }}
</Button>
</i18n-t>
<div v-else class="text-center">
<p v-text="t('linearMode.error.requiresGraph')" />
<p v-text="t('linearMode.error.promptVisitGraph')" />
<p class="*:text-muted-foreground">
<i18n-t keypath="linearMode.error.getHelp">
<a
:href="guideUrl"
target="_blank"
v-text="t('linearMode.error.guide')"
/>
<a
:href="staticUrls.githubIssues"
target="_blank"
v-text="t('linearMode.error.github')"
/>
<a
:href="supportUrl"
target="_blank"
v-text="t('linearMode.error.support')"
/>
</i18n-t>
</p>
<Dialogue :title="t('linearMode.error.log')">
<template #button>
<Button variant="textonly">
{{ t('linearMode.error.promptShow') }}
<i class="icon-[lucide--chevron-right] size-5" />
</Button>
</template>
<template #default="{ close }">
<article class="flex flex-col gap-2 p-4">
<section class="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
<div
v-for="error in allErrors"
:key="error"
class="w-full rounded-lg bg-secondary-background p-2 text-muted-foreground"
v-text="error"
/>
</section>
<div class="flex items-center justify-end gap-4">
<Button variant="muted-textonly" size="lg" @click="close">
{{ t('g.close') }}
</Button>
<Button size="lg" @click="copy(allErrors)">
{{ t('importFailed.copyError') }}
<i class="icon-[lucide--copy]" />
</Button>
</div>
</article>
</template>
</Dialogue>
</div>
</div>
<div
v-if="accessibleErrors.length"
class="my-8 w-full rounded-lg bg-secondary-background text-muted-foreground"
>
<ul>
<li
v-for="error in accessibleErrors"
:key="error"
class="before:content"
v-text="error"
/>
</ul>
</div>
<div class="flex gap-2">
<Button
variant="textonly"
size="lg"
@click="executionErrorStore.dismissErrorOverlay()"
>
{{ t('g.dismiss') }}
</Button>
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.viewGraph') }}
</Button>
<Button
v-if="accessibleErrors.length"
size="lg"
@click="copy(accessibleErrors)"
>
{{ t('importFailed.copyError') }}
<i class="icon-[lucide--copy]" />
</Button>
</div>
</section>
</template>

View File

@@ -1,13 +1,6 @@
<template>
<WidgetLayoutField :widget="widget">
<div
:class="
cn(
WidgetInputBaseClass,
'flex items-center gap-2 pr-2 pl-3 not-disabled:hover:bg-component-node-widget-background-hovered'
)
"
>
<div :class="cn(WidgetInputBaseClass, 'flex items-center gap-2 pr-2 pl-3')">
<Slider
:model-value="[modelValue]"
v-bind="filteredProps"

View File

@@ -1,115 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import FormDropdown from './FormDropdown.vue'
import type { FormDropdownItem } from './types'
function createItem(id: string, name: string): FormDropdownItem {
return { id, preview_url: '', name, label: name }
}
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
addAlert: vi.fn()
})
}))
const MockFormDropdownMenu = defineComponent({
name: 'FormDropdownMenu',
props: {
items: { type: Array as () => FormDropdownItem[], default: () => [] },
isSelected: { type: Function, default: undefined },
filterOptions: { type: Array, default: () => [] },
sortOptions: { type: Array, default: () => [] },
maxSelectable: { type: Number, default: 1 },
disabled: { type: Boolean, default: false },
showOwnershipFilter: { type: Boolean, default: false },
ownershipOptions: { type: Array, default: () => [] },
showBaseModelFilter: { type: Boolean, default: false },
baseModelOptions: { type: Array, default: () => [] }
},
setup() {
return () => h('div', { class: 'mock-menu' })
}
})
function mountDropdown(items: FormDropdownItem[]) {
return mount(FormDropdown, {
props: { items },
global: {
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: true,
Popover: { template: '<div><slot /></div>' },
FormDropdownMenu: MockFormDropdownMenu
}
}
})
}
function getMenuItems(
wrapper: ReturnType<typeof mountDropdown>
): FormDropdownItem[] {
return wrapper
.findComponent(MockFormDropdownMenu)
.props('items') as FormDropdownItem[]
}
describe('FormDropdown', () => {
describe('filteredItems updates when items prop changes', () => {
it('updates displayed items when items prop changes', async () => {
const wrapper = mountDropdown([
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(2)
await wrapper.setProps({
items: [
createItem('output-0', 'rendered1.mp4'),
createItem('output-1', 'rendered2.mp4')
]
})
await flushPromises()
const menuItems = getMenuItems(wrapper)
expect(menuItems).toHaveLength(2)
expect(menuItems[0].name).toBe('rendered1.mp4')
})
it('updates when items change but IDs stay the same', async () => {
const wrapper = mountDropdown([createItem('1', 'alpha')])
await flushPromises()
await wrapper.setProps({ items: [createItem('1', 'beta')] })
await flushPromises()
expect(getMenuItems(wrapper)[0].name).toBe('beta')
})
it('updates when switching between empty and non-empty items', async () => {
const wrapper = mountDropdown([])
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(0)
await wrapper.setProps({ items: [createItem('1', 'video.mp4')] })
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(1)
expect(getMenuItems(wrapper)[0].name).toBe('video.mp4')
await wrapper.setProps({ items: [] })
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(0)
})
})
})

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -102,16 +101,9 @@ const maxSelectable = computed(() => {
return 1
})
const debouncedSearchQuery = refDebounced(searchQuery, 250, { maxWait: 1000 })
const itemsKey = computed(() => items.map((item) => item.id).join('|'))
const filteredItems = computedAsync(async (onCancel) => {
let cleanupFn: (() => void) | undefined
onCancel(() => cleanupFn?.())
const result = await searcher(debouncedSearchQuery.value, items, (cb) => {
cleanupFn = cb
})
return result
}, [])
const filteredItems = ref<FormDropdownItem[]>([])
const defaultSorter = computed<SortOption['sorter']>(() => {
const sorter = sortOptions.find((option) => option.id === 'default')?.sorter
@@ -179,6 +171,21 @@ function handleSelection(item: FormDropdownItem, index: number) {
closeDropdown()
}
}
async function customSearcher(
query: string,
onCleanup: (cleanupFn: () => void) => void
) {
let isCleanup = false
let cleanupFn: undefined | (() => void)
onCleanup(() => {
isCleanup = true
cleanupFn?.()
})
await searcher(query, items, (cb) => (cleanupFn = cb)).then((results) => {
if (!isCleanup) filteredItems.value = results
})
}
</script>
<template>
@@ -226,9 +233,11 @@ function handleSelection(item: FormDropdownItem, index: number) {
:show-base-model-filter
:base-model-options
:disabled
:searcher="customSearcher"
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
:update-key="itemsKey"
@close="closeDropdown"
@item-click="handleSelection"
/>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import type { CSSProperties, MaybeRefOrGetter } from 'vue'
import { computed } from 'vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
@@ -20,6 +20,11 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -31,6 +36,8 @@ const {
isSelected,
filterOptions,
sortOptions,
searcher,
updateKey,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -111,6 +118,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:searcher
:update-key
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { MaybeRefOrGetter } from 'vue'
import Popover from 'primevue/popover'
import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -17,7 +19,12 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
defineProps<{
searcher?: (
query: string,
onCleanup: (cleanupFn: () => void) => void
) => Promise<void>
sortOptions: SortOption[]
updateKey?: MaybeRefOrGetter<unknown>
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -101,6 +108,8 @@ function toggleBaseModelSelection(item: FilterOption) {
<div class="text-secondary flex gap-2 px-4">
<FormSearchInput
v-model="searchQuery"
:searcher
:update-key
:class="
cn(
actionButtonStyle,

View File

@@ -109,15 +109,8 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
// Spy on the addWidget method
vi.spyOn(node, 'addWidget').mockImplementation(
(type, name, value, callback, options = {}) => {
const normalizedOptions =
typeof options === 'string' ? { property: options } : options
const widget = createMockWidget({
type,
name,
value,
options: normalizedOptions
})
(type, name, value, callback) => {
const widget = createMockWidget({ type, name, value })
// Store the callback function on the widget for testing
if (typeof callback === 'function') {
widget.callback = callback
@@ -327,7 +320,7 @@ describe('useComboWidget', () => {
HASH_FILENAME,
expect.any(Function),
expect.objectContaining({
values: [], // Empty initially, populated via dynamic getter
values: [], // Empty initially, populated dynamically by Proxy
getOptionLabel: expect.any(Function)
})
)
@@ -335,23 +328,6 @@ describe('useComboWidget', () => {
}
)
it('should keep the original options object for cloud input mappings', () => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockNode = createMockNode('LoadImage')
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
})
const widget = constructor(mockNode, inputSpec)
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
const options = addWidgetCall[4]
expect(widget.options).toBe(options)
})
it("should format option labels using store's getInputName function", () => {
mockDistributionState.isCloud = true
mockGetInputName.mockReturnValue('Beautiful Sunset.png')

View File

@@ -44,29 +44,6 @@ const NODE_PLACEHOLDER_MAP: Record<string, string> = {
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
}
const bindDynamicValuesOption = (
widget: IBaseWidget,
getValues: () => unknown
) => {
const options = widget.options
let fallbackValues = Array.isArray(options.values)
? options.values
: ([] as unknown[])
Object.defineProperty(options, 'values', {
configurable: true,
enumerable: true,
get: () => {
const values = getValues()
if (values === undefined || values === null) return fallbackValues
return values
},
set: (values: unknown[]) => {
fallbackValues = Array.isArray(values) ? values : fallbackValues
}
})
}
const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
@@ -156,16 +133,22 @@ const createInputMappingWidget = (
})
}
bindDynamicValuesOption(widget, () =>
assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
)
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
if (prop !== 'values') {
return target[prop as keyof typeof target]
}
return assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
}
})
if (inputSpec.control_after_generate) {
if (!isComboWidget(widget)) {
@@ -227,7 +210,15 @@ const addComboWidget = (
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
bindDynamicValuesOption(widget, () => remoteWidget.getValue())
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
// Assertion: Proxy handler passthrough
return prop !== 'values'
? target[prop as keyof typeof target]
: remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {

View File

@@ -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(),

View File

@@ -208,11 +208,6 @@ export class ComfyApp {
return this.rootGraphInternal!
}
/** Whether the root graph has been initialized. Safe to check without triggering error logs. */
get isGraphReady(): boolean {
return !!this.rootGraphInternal
}
canvas!: LGraphCanvas
dragOverNode: LGraphNode | null = null
readonly canvasElRef = shallowRef<HTMLCanvasElement>()
@@ -1830,6 +1825,7 @@ export class ComfyApp {
)
if (missingNodeTypes.length) {
this.showMissingNodesError(missingNodeTypes.map((t) => t.class_type))
return
}
const ids = Object.keys(apiData)

View File

@@ -238,7 +238,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** Graph node IDs (as strings) that have errors in the current graph scope. */
const activeGraphErrorNodeIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.isGraphReady) return ids
if (!app.rootGraph) return ids
// Fall back to rootGraph when currentGraph hasn't been initialized yet
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
@@ -287,7 +287,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
const activeMissingNodeGraphIds = computed<Set<string>>(() => {
const ids = new Set<string>()
if (!app.isGraphReady) return ids
if (!app.rootGraph) return ids
const activeGraph = canvasStore.currentGraph ?? app.rootGraph
@@ -357,7 +357,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** True if the node has errors inside it at any nesting depth. */
function isContainerWithInternalError(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
if (!app.rootGraph) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return errorAncestorExecutionIds.value.has(execId)
@@ -365,15 +365,15 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
/** True if the node has a missing node inside it at any nesting depth. */
function isContainerWithMissingNode(node: LGraphNode): boolean {
if (!app.isGraphReady) return false
if (!app.rootGraph) return false
const execId = getExecutionIdByNode(app.rootGraph, node)
if (!execId) return false
return missingAncestorExecutionIds.value.has(execId)
}
watch(lastNodeErrors, () => {
if (!app.isGraphReady) return
const rootGraph = app.rootGraph
if (!rootGraph) return
clearAllNodeErrorFlags(rootGraph)

View File

@@ -10,6 +10,7 @@ import type {
ResultItem,
ResultItemType
} from '@/schemas/apiSchema'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { clone } from '@/scripts/utils'
@@ -119,9 +120,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
const isImage = isImageOutputs(node, outputs)
return outputs.images.map((image) => {
const params = new URLSearchParams(image)
if (isImage) appendCloudResParam(params, image.filename)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}

View File

@@ -1,6 +1,5 @@
import _ from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
@@ -320,31 +319,6 @@ export function resolveNode(
}
return undefined
}
export function resolveNodeWidget(
nodeId: NodeId,
widgetName?: string,
graph: LGraph = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
const node = graph.getNodeById(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {
const widget = node.widgets?.find((w) => w.name === widgetName)
return widget ? [node, widget] : []
}
for (const node of graph.nodes) {
if (!node.isSubgraphNode()) continue
const widget = node.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
if (widget) return [node, widget]
}
return []
}
export function isLoad3dNode(node: LGraphNode) {
return (

View 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'])
})
})

View 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'

View 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)
}

View File

@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -157,7 +156,6 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"