Compare commits
33 Commits
v1.42.0
...
feat/node-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652d45f03a | ||
|
|
e653a4326b | ||
|
|
e15af715ea | ||
|
|
9244b97fab | ||
|
|
abee586cae | ||
|
|
76fd80aa98 | ||
|
|
920159ecf2 | ||
|
|
cef6bed5e3 | ||
|
|
046827fab5 | ||
|
|
5e60b1a2a0 | ||
|
|
ed05e589bf | ||
|
|
e6be6fd921 | ||
|
|
63c36d3f2f | ||
|
|
327aeda027 | ||
|
|
e5480c3a4c | ||
|
|
aa97d176c2 | ||
|
|
36930a683a | ||
|
|
892a9cf2c5 | ||
|
|
308c22efc6 | ||
|
|
5728d240da | ||
|
|
acf2f4280c | ||
|
|
7ad6994d01 | ||
|
|
2829f78579 | ||
|
|
c4156d7059 | ||
|
|
725a0a2b89 | ||
|
|
8a5bcde168 | ||
|
|
83ffaf30c8 | ||
|
|
2875f897dc | ||
|
|
ec129de63d | ||
|
|
1687ca93b3 | ||
|
|
5bb742ac3a | ||
|
|
ca2d61f393 | ||
|
|
750a2d23e0 |
@@ -23,7 +23,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Update electron types
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/pr-claude-review.yaml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
|
||||
2
.github/workflows/pr-perf-report.yaml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Download PR metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Read desktop-ui version
|
||||
id: get_version
|
||||
|
||||
2
.github/workflows/publish-desktop-ui.yaml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: 'frontend/.nvmrc'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
|
||||
2
.github/workflows/release-branch-create.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Check version bump type
|
||||
id: check_version
|
||||
|
||||
2
.github/workflows/release-draft-create.yaml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get current version
|
||||
|
||||
2
.github/workflows/release-npm-types.yaml
vendored
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
|
||||
2
.github/workflows/release-pypi-dev.yaml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get current version
|
||||
|
||||
2
.github/workflows/release-version-bump.yaml
vendored
@@ -149,7 +149,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
- name: Bump version
|
||||
id: bump-version
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24.x'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Bump desktop-ui version
|
||||
|
||||
2
.github/workflows/weekly-docs-check.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies for analysis tools
|
||||
|
||||
@@ -17,7 +17,7 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
### Prerequisites & Technology Stack
|
||||
|
||||
- **Required Software**:
|
||||
- Node.js (v24) and pnpm
|
||||
- Node.js (see `.nvmrc`, currently v24) and pnpm
|
||||
- Git for version control
|
||||
- A running ComfyUI backend instance (otherwise, you can use `pnpm dev:cloud`)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template v-if="filter.tasks.length === 0">
|
||||
<!-- Empty filter -->
|
||||
<Divider />
|
||||
<p class="text-neutral-400 w-full text-center">
|
||||
<p class="w-full text-center text-neutral-400">
|
||||
{{ $t('maintenance.allOk') }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
<!-- Display: Cards -->
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
|
||||
<div class="pad-y my-4 flex flex-wrap justify-evenly gap-8">
|
||||
<TaskCard
|
||||
v-for="task in filter.tasks"
|
||||
:key="task.id"
|
||||
@@ -45,7 +45,8 @@ import { useConfirm, useToast } from 'primevue'
|
||||
import ConfirmPopup from 'primevue/confirmpopup'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type {
|
||||
MaintenanceFilter,
|
||||
@@ -55,6 +56,7 @@ import type {
|
||||
import TaskCard from './TaskCard.vue'
|
||||
import TaskListItem from './TaskListItem.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const taskStore = useMaintenanceTaskStore()
|
||||
@@ -80,8 +82,7 @@ const executeTask = async (task: MaintenanceTask) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('maintenance.error.toastTitle'),
|
||||
detail: message ?? t('maintenance.error.defaultDescription'),
|
||||
life: 10_000
|
||||
detail: message ?? t('maintenance.error.defaultDescription')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -189,8 +189,7 @@ const completeValidation = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('maintenance.error.cannotContinue'),
|
||||
life: 5_000
|
||||
detail: t('maintenance.error.cannotContinue')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<BaseViewTemplate dark hide-language-selector>
|
||||
<div class="h-full p-8 2xl:p-16 flex flex-col items-center justify-center">
|
||||
<div class="flex h-full flex-col items-center justify-center p-8 2xl:p-16">
|
||||
<div
|
||||
class="bg-neutral-800 rounded-lg shadow-lg p-6 w-full max-w-[600px] flex flex-col gap-6"
|
||||
class="flex w-full max-w-[600px] flex-col gap-6 rounded-lg bg-neutral-800 p-6 shadow-lg"
|
||||
>
|
||||
<h2 class="text-3xl font-semibold text-neutral-100">
|
||||
{{ $t('install.helpImprove') }}
|
||||
@@ -15,7 +15,7 @@
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
class="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
{{ $t('install.privacyPolicy') }} </a
|
||||
>.
|
||||
@@ -33,7 +33,7 @@
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex pt-6 justify-end">
|
||||
<div class="flex justify-end pt-6">
|
||||
<Button
|
||||
:label="$t('g.ok')"
|
||||
icon="pi pi-check"
|
||||
@@ -72,8 +72,7 @@ const updateConsent = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('install.settings.errorUpdatingConsent'),
|
||||
detail: t('install.settings.errorUpdatingConsentDetail'),
|
||||
life: 3000
|
||||
detail: t('install.settings.errorUpdatingConsentDetail')
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
|
||||
@@ -27,7 +27,8 @@ cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
||||
Ensure you have the Node.js version from `.nvmrc` installed (currently v24).
|
||||
Then, set up the Chromium test driver:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright install chromium --with-deps
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.42.0",
|
||||
"version": "1.42.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -195,6 +195,9 @@
|
||||
"zip-dir": "^2.0.0",
|
||||
"zod-to-json-schema": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24.x"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "catalog:"
|
||||
|
||||
@@ -166,13 +166,22 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
|
||||
describe('authentication state', () => {
|
||||
function createLegacyTabBarWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
const settingStore = useSettingStore(pinia)
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.UI.TabBarLayout' ? 'Legacy' : undefined
|
||||
)
|
||||
return createWrapper({ pinia })
|
||||
}
|
||||
|
||||
describe('when user is logged in', () => {
|
||||
beforeEach(() => {
|
||||
mockData.isLoggedIn = true
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -186,7 +195,7 @@ describe('TopMenuSection', () => {
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
mockData.isDesktop = true
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
})
|
||||
@@ -194,7 +203,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createWrapper()
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -34,17 +34,7 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="actionbarContainerRef"
|
||||
:class="
|
||||
cn(
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg px-2 shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
|
||||
<ActionBarButtons />
|
||||
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
|
||||
<div
|
||||
@@ -55,6 +45,7 @@
|
||||
<ComfyActionbar
|
||||
:top-menu-container="actionbarContainerRef"
|
||||
:queue-overlay-expanded="isQueueOverlayExpanded"
|
||||
:has-any-error="hasAnyError"
|
||||
@update:progress-target="updateProgressTarget"
|
||||
/>
|
||||
<CurrentUserButton
|
||||
@@ -123,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useLocalStorage, useMutationObserver } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -145,6 +136,7 @@ import { buildTooltipConfig } from '@/composables/useTooltipConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
|
||||
import { useQueueUIStore } from '@/stores/queueStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
@@ -168,6 +160,7 @@ const { isLoggedIn } = useCurrentUser()
|
||||
const { t } = useI18n()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const actionBarButtonStore = useActionBarButtonStore()
|
||||
const queueUIStore = useQueueUIStore()
|
||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||
@@ -182,8 +175,45 @@ const isActionbarEnabled = computed(
|
||||
const isActionbarFloating = computed(
|
||||
() => isActionbarEnabled.value && !isActionbarDocked.value
|
||||
)
|
||||
/**
|
||||
* Whether the actionbar container has any visible docked buttons
|
||||
* (excluding ComfyActionbar, which uses position:fixed when floating
|
||||
* and does not contribute to the container's visual layout).
|
||||
*/
|
||||
const hasDockedButtons = computed(() => {
|
||||
if (actionBarButtonStore.buttons.length > 0) return true
|
||||
if (hasLegacyContent.value) return true
|
||||
if (isLoggedIn.value && !isIntegratedTabBar.value) return true
|
||||
if (isDesktop && !isIntegratedTabBar.value) return true
|
||||
if (isCloud && flags.workflowSharingEnabled) return true
|
||||
if (!isRightSidePanelOpen.value) return true
|
||||
return false
|
||||
})
|
||||
const isActionbarContainerEmpty = computed(
|
||||
() => isActionbarFloating.value && !hasDockedButtons.value
|
||||
)
|
||||
const actionbarContainerClass = computed(() => {
|
||||
const base =
|
||||
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
|
||||
|
||||
if (isActionbarContainerEmpty.value) {
|
||||
return cn(
|
||||
base,
|
||||
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
|
||||
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
|
||||
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
|
||||
)
|
||||
}
|
||||
|
||||
const borderClass =
|
||||
!isActionbarFloating.value && hasAnyError.value
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
|
||||
return cn(base, 'px-2', borderClass)
|
||||
})
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
|
||||
useQueueFeatureFlags()
|
||||
@@ -233,6 +263,25 @@ const rightSidePanelTooltipConfig = computed(() =>
|
||||
|
||||
// Maintain support for legacy topbar elements attached by custom scripts
|
||||
const legacyCommandsContainerRef = ref<HTMLElement>()
|
||||
const hasLegacyContent = ref(false)
|
||||
|
||||
function checkLegacyContent() {
|
||||
const el = legacyCommandsContainerRef.value
|
||||
if (!el) {
|
||||
hasLegacyContent.value = false
|
||||
return
|
||||
}
|
||||
// Mirror the CSS: [&:not(:has(*>*:not(:empty)))]:hidden
|
||||
hasLegacyContent.value =
|
||||
el.querySelector(':scope > * > *:not(:empty)') !== null
|
||||
}
|
||||
|
||||
useMutationObserver(legacyCommandsContainerRef, checkLegacyContent, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (legacyCommandsContainerRef.value) {
|
||||
app.menu.element.style.width = 'fit-content'
|
||||
|
||||
@@ -119,9 +119,14 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyRunButton from './ComfyRunButton'
|
||||
|
||||
const { topMenuContainer, queueOverlayExpanded = false } = defineProps<{
|
||||
const {
|
||||
topMenuContainer,
|
||||
queueOverlayExpanded = false,
|
||||
hasAnyError = false
|
||||
} = defineProps<{
|
||||
topMenuContainer?: HTMLElement | null
|
||||
queueOverlayExpanded?: boolean
|
||||
hasAnyError?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -435,7 +440,12 @@ const panelClass = computed(() =>
|
||||
isDragging.value && 'pointer-events-none select-none',
|
||||
isDocked.value
|
||||
? 'static border-none bg-transparent p-0'
|
||||
: 'fixed shadow-interface'
|
||||
: [
|
||||
'fixed shadow-interface',
|
||||
hasAnyError
|
||||
? 'border-destructive-background-hover'
|
||||
: 'border-interface-stroke'
|
||||
]
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -46,71 +46,74 @@ function showApps() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<div class="pointer-events-auto flex flex-row items-start gap-2">
|
||||
<div class="pointer-events-auto flex flex-col gap-2">
|
||||
<Button
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
:disabled="!hasNodes"
|
||||
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="enterBuilder"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
<i class="icon-[lucide--hammer] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && flags.workflowSharingEnabled"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
value: t('actionbar.shareTooltip'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="cn('size-10', isAppsActive && 'bg-secondary-background-hover')"
|
||||
@click="showApps"
|
||||
:aria-label="t('actionbar.shareTooltip')"
|
||||
class="size-10 rounded-lg"
|
||||
@click="() => openShareDialog().catch(toastErrorHandler)"
|
||||
@pointerenter="prefetchShareDialog"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--send] size-4" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.mediaAssets.title'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.mediaAssets.title')"
|
||||
:class="
|
||||
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="openAssets"
|
||||
>
|
||||
<i class="icon-[comfy--image-ai-edit] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.apps'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('linearMode.appModeToolbar.apps')"
|
||||
:class="
|
||||
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
|
||||
"
|
||||
@click="showApps"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,6 @@ import PropertiesAccordionItem from '@/components/rightSidePanel/layout/Properti
|
||||
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import {
|
||||
LGraphEventMode,
|
||||
@@ -25,9 +24,9 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { HideLayoutFieldKey } from '@/types/widgetTypes'
|
||||
@@ -73,15 +72,12 @@ const inputsWithState = computed(() =>
|
||||
}
|
||||
}
|
||||
|
||||
const input = node.inputs.find((i) => i.widget?.name === widget.name)
|
||||
const rename = input && (() => renameWidget(widget, input))
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
rename
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -92,20 +88,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
async function renameWidget(widget: IBaseWidget, input: INodeInputSlot) {
|
||||
const newLabel = await useDialogService().prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
if (newLabel === null) return
|
||||
widget.label = newLabel || undefined
|
||||
input.label = newLabel || undefined
|
||||
widget.callback?.(widget.value)
|
||||
useCanvasStore().canvas?.setDirty(true)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -162,7 +144,11 @@ function handleDown(e: MouseEvent) {
|
||||
}
|
||||
function handleClick(e: MouseEvent) {
|
||||
const [node, widget] = getHovered(e) ?? []
|
||||
if (node?.mode !== LGraphEventMode.ALWAYS)
|
||||
if (
|
||||
node?.mode !== LGraphEventMode.ALWAYS ||
|
||||
!nodeTypeValidForApp(node.type) ||
|
||||
node.has_errors
|
||||
)
|
||||
return canvasInteractions.forwardEventToCanvas(e)
|
||||
|
||||
if (!widget) {
|
||||
@@ -198,7 +184,9 @@ const renderedOutputs = computed(() => {
|
||||
return canvas
|
||||
.graph!.nodes.filter(
|
||||
(n) =>
|
||||
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
|
||||
n.constructor.nodeData?.output_node &&
|
||||
n.mode === LGraphEventMode.ALWAYS &&
|
||||
!n.has_errors
|
||||
)
|
||||
.map(nodeToDisplayTuple)
|
||||
})
|
||||
@@ -260,7 +248,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.inputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
@@ -315,7 +303,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
<template #label>
|
||||
<div class="flex gap-3">
|
||||
{{ t('nodeHelpPage.outputs') }}
|
||||
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
|
||||
<i class="icon-[lucide--info] bg-muted-foreground" />
|
||||
</div>
|
||||
</template>
|
||||
<template #empty>
|
||||
|
||||
@@ -72,7 +72,7 @@ const menuItems = computed(() => [
|
||||
},
|
||||
{
|
||||
label: t('builderMenu.exitAppBuilder'),
|
||||
icon: 'icon-[lucide--square-pen]',
|
||||
icon: 'icon-[lucide--x]',
|
||||
action: onExitBuilder
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
@@ -7,6 +7,13 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const titleTooltip = ref<string | null>(null)
|
||||
const subTitleTooltip = ref<string | null>(null)
|
||||
|
||||
function isTruncated(e: MouseEvent): boolean {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
return el.scrollWidth > el.clientWidth
|
||||
}
|
||||
const { rename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
@@ -32,15 +39,28 @@ const entries = computed(() => {
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="my-2 flex items-center-safe gap-2 rounded-lg p-2">
|
||||
<div
|
||||
class="drag-handle mr-auto inline max-w-max min-w-0 flex-[4_1_0%] truncate"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
class="drag-handle inline max-w-max min-w-0 flex-[2_1_0%] truncate text-end text-muted-foreground"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
<div
|
||||
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
|
||||
data-testid="builder-io-item"
|
||||
>
|
||||
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-sm"
|
||||
data-testid="builder-io-item-title"
|
||||
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-xs text-muted-foreground"
|
||||
data-testid="builder-io-item-subtitle"
|
||||
@mouseenter="
|
||||
subTitleTooltip = isTruncated($event) ? (subTitle ?? null) : null
|
||||
"
|
||||
v-text="subTitle"
|
||||
/>
|
||||
</div>
|
||||
<Popover :entries>
|
||||
<template #button>
|
||||
<Button variant="muted-textonly">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Avatar
|
||||
class="bg-interface-panel-selected-surface"
|
||||
class="aspect-square bg-interface-panel-selected-surface"
|
||||
:image="photoUrl ?? undefined"
|
||||
:icon="hasAvatar ? undefined : 'icon-[lucide--user]'"
|
||||
:pt:icon:class="{ 'size-4': !hasAvatar }"
|
||||
|
||||
@@ -138,8 +138,7 @@ onMounted(async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToFetchLogs'),
|
||||
life: 5000
|
||||
detail: t('toastMessages.failedToFetchLogs')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -275,8 +275,7 @@ async function handleBuy() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('credits.topUp.purchaseError'),
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage }),
|
||||
life: 5000
|
||||
detail: t('credits.topUp.purchaseErrorDetail', { error: errorMessage })
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -98,8 +98,7 @@ async function onConfirmCancel() {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('subscription.cancelDialog.failed'),
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: error instanceof Error ? error.message : t('g.unknownError')
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
|
||||
@@ -5,6 +5,7 @@ import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import type * as ColorUtilModule from '@/utils/colorUtil'
|
||||
|
||||
// Import after mocks
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
@@ -62,9 +63,14 @@ vi.mock('@/lib/litegraph/src/litegraph', async () => {
|
||||
})
|
||||
|
||||
// Mock the colorUtil module
|
||||
vi.mock('@/utils/colorUtil', () => ({
|
||||
adjustColor: vi.fn((color: string) => color + '_light')
|
||||
}))
|
||||
vi.mock('@/utils/colorUtil', async () => {
|
||||
const actual = await vi.importActual<typeof ColorUtilModule>('@/utils/colorUtil')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
adjustColor: vi.fn((color: string) => color + '_light')
|
||||
}
|
||||
})
|
||||
|
||||
// Mock the litegraphUtil module
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
@@ -83,11 +89,25 @@ describe('ColorPickerButton', () => {
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
color: 'Color',
|
||||
custom: 'Custom',
|
||||
favorites: 'Favorites',
|
||||
remove: 'Remove'
|
||||
},
|
||||
color: {
|
||||
noColor: 'No Color',
|
||||
red: 'Red',
|
||||
green: 'Green',
|
||||
blue: 'Blue'
|
||||
},
|
||||
shape: {
|
||||
default: 'Default',
|
||||
box: 'Box',
|
||||
CARD: 'Card'
|
||||
},
|
||||
modelLibrary: {
|
||||
sortRecent: 'Recent'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,4 +158,17 @@ describe('ColorPickerButton', () => {
|
||||
await button.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('disables favoriting when the selection has no shared applied color', async () => {
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = createWrapper()
|
||||
|
||||
await wrapper.find('[data-testid="color-picker-button"]').trigger('click')
|
||||
|
||||
expect(
|
||||
wrapper.find('[data-testid="toggle-favorite-color"]').attributes(
|
||||
'disabled'
|
||||
)
|
||||
).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</Button>
|
||||
<div
|
||||
v-if="showColorPicker"
|
||||
class="absolute -top-10 left-1/2 -translate-x-1/2"
|
||||
class="absolute -top-10 left-1/2 z-10 min-w-44 -translate-x-1/2 rounded-lg border border-border-default bg-interface-panel-surface p-2 shadow-lg"
|
||||
>
|
||||
<SelectButton
|
||||
:model-value="selectedColorOption"
|
||||
@@ -41,11 +41,70 @@
|
||||
/>
|
||||
</template>
|
||||
</SelectButton>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<ColorPicker
|
||||
data-testid="custom-color-trigger"
|
||||
:model-value="currentPickerValue"
|
||||
format="hex"
|
||||
:aria-label="t('g.custom')"
|
||||
class="h-8 w-8 overflow-hidden rounded-md border border-border-default bg-secondary-background"
|
||||
:pt="{
|
||||
preview: {
|
||||
class: '!h-full !w-full !rounded-md !border-none'
|
||||
}
|
||||
}"
|
||||
@update:model-value="onCustomColorUpdate"
|
||||
/>
|
||||
<button
|
||||
class="flex size-8 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:title="isCurrentColorFavorite ? t('g.remove') : t('g.favorites')"
|
||||
data-testid="toggle-favorite-color"
|
||||
:disabled="!currentAppliedColor"
|
||||
@click="toggleCurrentColorFavorite"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
isCurrentColorFavorite
|
||||
? 'icon-[lucide--star] text-yellow-500'
|
||||
: 'icon-[lucide--star-off]'
|
||||
"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="favoriteColors.length" class="mt-2 flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="color in favoriteColors"
|
||||
:key="`favorite-${color}`"
|
||||
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
|
||||
:title="`${t('g.favorites')}: ${color.toUpperCase()}`"
|
||||
@click="applySavedCustomColor(color)"
|
||||
>
|
||||
<div
|
||||
class="size-4 rounded-full border border-border-default"
|
||||
:style="{ backgroundColor: color }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="recentColors.length" class="mt-2 flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="color in recentColors"
|
||||
:key="`recent-${color}`"
|
||||
class="flex size-7 cursor-pointer items-center justify-center rounded-md border border-border-default bg-secondary-background hover:bg-secondary-background-hover"
|
||||
:title="`${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`"
|
||||
@click="applySavedCustomColor(color)"
|
||||
>
|
||||
<div
|
||||
class="size-4 rounded-full border border-border-default"
|
||||
:style="{ backgroundColor: color }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import type { Raw } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -61,16 +120,26 @@ import {
|
||||
LiteGraph,
|
||||
isColorable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
|
||||
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { adjustColor, toHexFromFormat } from '@/utils/colorUtil'
|
||||
import { getItemsColorOption } from '@/utils/litegraphUtil'
|
||||
import { getDefaultCustomNodeColor } from '@/utils/nodeColorCustomization'
|
||||
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { applyCustomColor, getCurrentAppliedColor } = useNodeCustomization()
|
||||
const {
|
||||
favoriteColors,
|
||||
recentColors,
|
||||
isFavoriteColor,
|
||||
toggleFavoriteColor
|
||||
} = useCustomNodeColorSettings()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
)
|
||||
@@ -129,16 +198,25 @@ const applyColor = (colorOption: ColorOption | null) => {
|
||||
}
|
||||
|
||||
const currentColorOption = ref<CanvasColorOption | null>(null)
|
||||
const currentAppliedColor = computed(() => getCurrentAppliedColor())
|
||||
const currentPickerValue = computed(() =>
|
||||
(currentAppliedColor.value ?? getDefaultCustomNodeColor()).replace('#', '')
|
||||
)
|
||||
const currentColor = computed(() =>
|
||||
currentColorOption.value
|
||||
? isLightTheme.value
|
||||
? toLightThemeColor(currentColorOption.value?.bgcolor)
|
||||
: currentColorOption.value?.bgcolor
|
||||
: null
|
||||
: currentAppliedColor.value
|
||||
)
|
||||
|
||||
const localizedCurrentColorName = computed(() => {
|
||||
if (!currentColorOption.value?.bgcolor) return null
|
||||
if (currentAppliedColor.value) {
|
||||
return currentAppliedColor.value.toUpperCase()
|
||||
}
|
||||
if (!currentColorOption.value?.bgcolor) {
|
||||
return null
|
||||
}
|
||||
const colorOption = colorOptions.find(
|
||||
(option) =>
|
||||
option.value.dark === currentColorOption.value?.bgcolor ||
|
||||
@@ -146,6 +224,26 @@ const localizedCurrentColorName = computed(() => {
|
||||
)
|
||||
return colorOption?.localizedName ?? NO_COLOR_OPTION.localizedName
|
||||
})
|
||||
|
||||
async function applySavedCustomColor(color: string) {
|
||||
currentColorOption.value = null
|
||||
await applyCustomColor(color)
|
||||
showColorPicker.value = false
|
||||
}
|
||||
|
||||
async function onCustomColorUpdate(value: string) {
|
||||
await applySavedCustomColor(toHexFromFormat(value, 'hex'))
|
||||
}
|
||||
|
||||
async function toggleCurrentColorFavorite() {
|
||||
if (!currentAppliedColor.value) return
|
||||
await toggleFavoriteColor(currentAppliedColor.value)
|
||||
}
|
||||
|
||||
const isCurrentColorFavorite = computed(() =>
|
||||
isFavoriteColor(currentAppliedColor.value)
|
||||
)
|
||||
|
||||
const updateColorSelectionFromNode = (
|
||||
newSelectedItems: Raw<Positionable[]>
|
||||
) => {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { MenuOption } from '@/composables/graph/useMoreOptionsMenu'
|
||||
|
||||
import ColorPickerMenu from './ColorPickerMenu.vue'
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
getCurrentShape: () => null
|
||||
})
|
||||
}))
|
||||
|
||||
describe('ColorPickerMenu', () => {
|
||||
it('renders a compact PrimeVue picker panel for custom color submenu entries', async () => {
|
||||
const onColorPick = vi.fn()
|
||||
const option: MenuOption = {
|
||||
label: 'Color',
|
||||
hasSubmenu: true,
|
||||
action: () => {},
|
||||
submenu: [
|
||||
{
|
||||
label: 'Custom',
|
||||
action: () => {},
|
||||
pickerValue: '112233',
|
||||
onColorPick
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mount(ColorPickerMenu, {
|
||||
props: { option },
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const picker = wrapper.findComponent(ColorPicker)
|
||||
expect(picker.exists()).toBe(true)
|
||||
expect(picker.props('modelValue')).toBe('112233')
|
||||
expect(picker.props('inline')).toBe(true)
|
||||
expect(wrapper.text()).toContain('#112233')
|
||||
|
||||
picker.vm.$emit('update:model-value', 'fedcba')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(onColorPick).toHaveBeenCalledWith('#fedcba')
|
||||
})
|
||||
|
||||
it('shows preset swatches in a compact grid when color presets are available', () => {
|
||||
const option: MenuOption = {
|
||||
label: 'Color',
|
||||
hasSubmenu: true,
|
||||
action: () => {},
|
||||
submenu: [
|
||||
{
|
||||
label: 'Custom',
|
||||
action: () => {},
|
||||
pickerValue: '112233',
|
||||
onColorPick: vi.fn()
|
||||
},
|
||||
{
|
||||
label: 'Red',
|
||||
action: () => {},
|
||||
color: '#ff0000'
|
||||
},
|
||||
{
|
||||
label: 'Green',
|
||||
action: () => {},
|
||||
color: '#00ff00'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const wrapper = mount(ColorPickerMenu, {
|
||||
props: { option },
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
stubs: {
|
||||
Popover: {
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('button[title]').map((node) => node.attributes('title'))).toEqual([
|
||||
'Red',
|
||||
'Green'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -20,49 +20,135 @@
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="isCompactColorPanel"
|
||||
class="w-[15.5rem] rounded-2xl border border-border-default bg-interface-panel-surface p-2.5 shadow-lg"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{{ option.label }}
|
||||
</p>
|
||||
<p class="mt-0.5 truncate text-sm font-medium text-base-foreground">
|
||||
{{ pickerOption?.label ?? 'Custom' }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-md border border-border-default bg-secondary-background px-2 py-1 font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{{ selectedPickerColor }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ColorPicker
|
||||
v-if="pickerOption"
|
||||
data-testid="color-picker-inline"
|
||||
:model-value="pickerOption.pickerValue"
|
||||
inline
|
||||
format="hex"
|
||||
:aria-label="pickerOption.label"
|
||||
class="w-full"
|
||||
:pt="{
|
||||
root: { class: '!w-full' },
|
||||
content: {
|
||||
class: '!border-none !bg-transparent !p-0 !shadow-none'
|
||||
},
|
||||
colorSelector: {
|
||||
class: '!h-32 !w-full overflow-hidden !rounded-xl'
|
||||
},
|
||||
colorBackground: {
|
||||
class: '!rounded-xl'
|
||||
},
|
||||
colorHandle: {
|
||||
class:
|
||||
'!h-3.5 !w-3.5 !rounded-full !border-2 !border-black/70 !shadow-sm'
|
||||
},
|
||||
hue: {
|
||||
class:
|
||||
'!mt-2 !h-3 !overflow-hidden !rounded-full !border !border-border-default'
|
||||
},
|
||||
hueHandle: {
|
||||
class:
|
||||
'!h-3.5 !w-3.5 !-translate-x-1/2 !rounded-full !border-2 !border-white !shadow-sm'
|
||||
}
|
||||
}"
|
||||
@update:model-value="handleColorPickerUpdate(pickerOption, $event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="swatchOptions.length"
|
||||
class="mt-2 rounded-xl border border-border-default bg-secondary-background p-2"
|
||||
>
|
||||
<div class="-mx-0.5 flex gap-1.5 overflow-x-auto px-0.5 pb-0.5">
|
||||
<button
|
||||
v-for="subOption in swatchOptions"
|
||||
:key="subOption.label"
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 items-center justify-center rounded-xl border border-transparent transition-transform hover:scale-[1.04] hover:border-border-default hover:bg-secondary-background-hover"
|
||||
:title="subOption.label"
|
||||
@click="handleSubmenuClick(subOption)"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'size-5 rounded-full border transition-shadow',
|
||||
isSelectedSwatch(subOption)
|
||||
? 'border-white shadow-[0_0_0_2px_rgba(255,255,255,0.18)]'
|
||||
: 'border-border-default'
|
||||
)
|
||||
"
|
||||
:style="{ backgroundColor: subOption.color }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="
|
||||
isColorSubmenu
|
||||
? 'flex flex-col gap-1 p-2'
|
||||
: 'flex min-w-40 flex-col p-2'
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-for="subOption in option.submenu"
|
||||
:key="subOption.label"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm hover:bg-secondary-background-hover',
|
||||
isColorSubmenu
|
||||
? 'flex size-7 items-center justify-center'
|
||||
: 'flex items-center gap-2 px-3 py-1.5 text-sm',
|
||||
subOption.disabled
|
||||
? 'pointer-events-none cursor-not-allowed text-node-icon-disabled'
|
||||
: 'hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
:title="subOption.label"
|
||||
@click="handleSubmenuClick(subOption)"
|
||||
>
|
||||
<template v-for="subOption in option.submenu" :key="subOption.label">
|
||||
<div
|
||||
v-if="subOption.color"
|
||||
class="size-5 rounded-full border border-border-default"
|
||||
:style="{ backgroundColor: subOption.color }"
|
||||
/>
|
||||
<template v-else-if="!subOption.color">
|
||||
<i
|
||||
v-if="isShapeSelected(subOption)"
|
||||
class="icon-[lucide--check] size-4 shrink-0"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer rounded-sm hover:bg-secondary-background-hover',
|
||||
isColorSubmenu
|
||||
? 'flex size-7 items-center justify-center'
|
||||
: 'flex items-center gap-2 px-3 py-1.5 text-sm',
|
||||
subOption.disabled
|
||||
? 'pointer-events-none cursor-not-allowed text-node-icon-disabled'
|
||||
: 'hover:bg-secondary-background-hover'
|
||||
)
|
||||
"
|
||||
:title="subOption.label"
|
||||
@click="handleSubmenuClick(subOption)"
|
||||
>
|
||||
<div
|
||||
v-if="subOption.color"
|
||||
class="size-5 rounded-full border border-border-default"
|
||||
:style="{ backgroundColor: subOption.color }"
|
||||
/>
|
||||
<div v-else class="w-4 shrink-0" />
|
||||
<span>{{ subOption.label }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else-if="!subOption.color">
|
||||
<i
|
||||
v-if="isShapeSelected(subOption)"
|
||||
class="icon-[lucide--check] size-4 shrink-0"
|
||||
/>
|
||||
<div v-else class="w-4 shrink-0" />
|
||||
<span>{{ subOption.label }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -102,6 +188,43 @@ const handleSubmenuClick = (subOption: SubMenuOption) => {
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
|
||||
const isPickerOption = (subOption: SubMenuOption): boolean =>
|
||||
typeof subOption.pickerValue === 'string' &&
|
||||
typeof subOption.onColorPick === 'function'
|
||||
|
||||
const pickerOption = computed(
|
||||
() => props.option.submenu?.find(isPickerOption) ?? null
|
||||
)
|
||||
|
||||
const swatchOptions = computed(() =>
|
||||
(props.option.submenu ?? []).filter(
|
||||
(subOption) => Boolean(subOption.color) && !isPickerOption(subOption)
|
||||
)
|
||||
)
|
||||
|
||||
const selectedPickerColor = computed(() =>
|
||||
pickerOption.value?.pickerValue
|
||||
? `#${pickerOption.value.pickerValue.toUpperCase()}`
|
||||
: '#000000'
|
||||
)
|
||||
|
||||
const isCompactColorPanel = computed(() => Boolean(pickerOption.value))
|
||||
|
||||
async function handleColorPickerUpdate(
|
||||
subOption: SubMenuOption,
|
||||
value: string
|
||||
) {
|
||||
if (!isPickerOption(subOption) || !value) return
|
||||
|
||||
await subOption.onColorPick?.(`#${value}`)
|
||||
}
|
||||
|
||||
function isSelectedSwatch(subOption: SubMenuOption): boolean {
|
||||
return (
|
||||
subOption.color?.toLowerCase() === selectedPickerColor.value.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
const isShapeSelected = (subOption: SubMenuOption): boolean => {
|
||||
if (subOption.color) return false
|
||||
|
||||
|
||||
@@ -579,8 +579,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed'),
|
||||
life: 5000
|
||||
detail: error.value || t('helpCenter.updateComfyUIFailed')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -597,8 +596,7 @@ const onUpdateComfyUI = async (): Promise<void> => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError'),
|
||||
life: 5000
|
||||
detail: err instanceof Error ? err.message : t('g.unknownError')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
v-if="isHelpCenterVisible"
|
||||
class="help-center-popup"
|
||||
:class="{
|
||||
'sidebar-left':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'left',
|
||||
'sidebar-right':
|
||||
triggerLocation === 'sidebar' && sidebarLocation === 'right',
|
||||
'topbar-right': triggerLocation === 'topbar',
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
>
|
||||
@@ -63,7 +60,6 @@ const { isSmall = false } = defineProps<{
|
||||
|
||||
const {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
sidebarLocation,
|
||||
closeHelpCenter,
|
||||
handleWhatsNewDismissed
|
||||
@@ -101,25 +97,6 @@ const {
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.help-center-popup.topbar-right {
|
||||
top: 2rem;
|
||||
right: 1rem;
|
||||
bottom: auto;
|
||||
animation: slideInDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -141,7 +141,7 @@ onMounted(async () => {
|
||||
if (isStandaloneMode && props.modelUrl) {
|
||||
await viewer.initializeStandaloneViewer(containerRef.value, props.modelUrl)
|
||||
} else if (props.node) {
|
||||
const source = useLoad3dService().getLoad3d(props.node)
|
||||
const source = await useLoad3dService().getLoad3dAsync(props.node)
|
||||
if (source) {
|
||||
await viewer.initializeViewer(containerRef.value, source)
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@
|
||||
/>
|
||||
</div>
|
||||
<SliderControl
|
||||
v-model="brushSize"
|
||||
v-model="brushSizeSliderValue"
|
||||
class="flex-1"
|
||||
label=""
|
||||
:min="1"
|
||||
:max="250"
|
||||
:step="1"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,26 @@ const brushSize = computed({
|
||||
set: (value: number) => store.setBrushSize(value)
|
||||
})
|
||||
|
||||
const rawSliderValue = ref<number | null>(null)
|
||||
|
||||
const brushSizeSliderValue = computed({
|
||||
get: () => {
|
||||
if (rawSliderValue.value !== null) {
|
||||
const cachedSize = Math.round(Math.pow(250, rawSliderValue.value))
|
||||
if (cachedSize === brushSize.value) {
|
||||
return rawSliderValue.value
|
||||
}
|
||||
}
|
||||
|
||||
return Math.log(brushSize.value) / Math.log(250)
|
||||
},
|
||||
set: (value: number) => {
|
||||
rawSliderValue.value = value
|
||||
const size = Math.round(Math.pow(250, value))
|
||||
store.setBrushSize(size)
|
||||
}
|
||||
})
|
||||
|
||||
const brushOpacity = computed({
|
||||
get: () => store.brushSettings.opacity,
|
||||
set: (value: number) => store.setBrushOpacity(value)
|
||||
|
||||
@@ -15,10 +15,9 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue } from '@/utils/widgetUtil'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
@@ -42,7 +41,6 @@ const label = defineModel<string>('label', { required: true })
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const dialogService = useDialogService()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
@@ -67,15 +65,8 @@ const isCurrentValueDefault = computed(() => {
|
||||
})
|
||||
|
||||
async function handleRename() {
|
||||
const newLabel = await dialogService.prompt({
|
||||
title: t('g.rename'),
|
||||
message: t('g.enterNewNamePrompt'),
|
||||
defaultValue: widget.label,
|
||||
placeholder: widget.name
|
||||
})
|
||||
|
||||
if (newLabel === null) return
|
||||
label.value = newLabel
|
||||
const newLabel = await promptWidgetLabel(widget, t)
|
||||
if (newLabel !== null) label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
}"
|
||||
@click="onLogoMenuClick($event)"
|
||||
>
|
||||
<div class="flex size-8 items-center justify-center rounded-lg bg-black">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<ComfyLogo
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo h-[18px] w-[18px] text-white"
|
||||
class="comfyui-logo h-[18px] w-[18px]"
|
||||
mode="fill"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
v-if="userStore.isMultiUserServer"
|
||||
:is-small="isSmall"
|
||||
/>
|
||||
<SidebarHelpCenterIcon v-if="!isIntegratedTabBar" :is-small="isSmall" />
|
||||
<SidebarHelpCenterIcon :is-small="isSmall" />
|
||||
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
|
||||
<SidebarShortcutsToggleButton :is-small="isSmall" />
|
||||
<SidebarSettingsButton :is-small="isSmall" />
|
||||
@@ -95,9 +95,6 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
settingStore.get('Comfy.Sidebar.Location')
|
||||
)
|
||||
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
)
|
||||
const isConnected = computed(
|
||||
() =>
|
||||
selectedTab.value ||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:icon-badge="shouldShowRedDot ? '•' : ''"
|
||||
badge-class="-top-1 -right-1 min-w-2 w-2 h-2 p-0 rounded-full text-[0px] bg-[#ff3b30]"
|
||||
:is-small="isSmall"
|
||||
@click="toggleHelpCenter"
|
||||
@click="toggleHelpCenter()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: item.asset.name,
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
@@ -44,7 +44,7 @@
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="item.asset.name"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
@@ -133,8 +133,12 @@ const listGridStyle = {
|
||||
gap: '0.5rem'
|
||||
}
|
||||
|
||||
function getAssetDisplayName(asset: AssetItem): string {
|
||||
return asset.display_name || asset.name
|
||||
}
|
||||
|
||||
function getAssetPrimaryText(asset: AssetItem): string {
|
||||
return truncateFilename(asset.name)
|
||||
return truncateFilename(getAssetDisplayName(asset))
|
||||
}
|
||||
|
||||
function getAssetMediaType(asset: AssetItem) {
|
||||
|
||||
@@ -569,7 +569,7 @@ const handleZoomClick = (asset: AssetItem) => {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
key: 'asset-3d-viewer',
|
||||
title: asset.name,
|
||||
title: asset.display_name || asset.name,
|
||||
component: Load3dViewerContent,
|
||||
props: {
|
||||
modelUrl: asset.preview_url || ''
|
||||
@@ -615,8 +615,7 @@ const enterFolderView = async (asset: AssetItem) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('sideToolbar.folderView.errorSummary'),
|
||||
detail: t('sideToolbar.folderView.errorDetail'),
|
||||
life: 5000
|
||||
detail: t('sideToolbar.folderView.errorDetail')
|
||||
})
|
||||
exitFolderView()
|
||||
}
|
||||
@@ -662,8 +661,7 @@ const copyJobId = async () => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('mediaAsset.jobIdToast.error'),
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed'),
|
||||
life: 3000
|
||||
detail: t('mediaAsset.jobIdToast.jobIdCopyFailed')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<UserAvatar
|
||||
v-else
|
||||
:photo-url="photoURL"
|
||||
:class="compact && 'size-full'"
|
||||
:class="compact && 'h-full w-auto'"
|
||||
/>
|
||||
|
||||
<i v-if="showArrow" class="icon-[lucide--chevron-down] size-4 px-1" />
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
class="comfy-help-center-btn relative text-base-foreground"
|
||||
variant="textonly"
|
||||
@click="toggleHelpCenter"
|
||||
>
|
||||
<div class="not-md:hidden">{{ $t('menu.helpAndFeedback') }}</div>
|
||||
<i class="ml-0.5 icon-[lucide--circle-help]" />
|
||||
<span
|
||||
v-if="shouldShowRedDot"
|
||||
class="absolute top-[7px] right-[7px] size-1.5 rounded-full bg-[#ff3b30]"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useHelpCenter } from '@/composables/useHelpCenter'
|
||||
|
||||
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter('topbar')
|
||||
</script>
|
||||
@@ -10,8 +10,9 @@
|
||||
@mouseup="handleMouseUp"
|
||||
@click="handleClick"
|
||||
>
|
||||
<i v-if="isBuilderState" class="bg-text-subtle icon-[lucide--hammer]" />
|
||||
<i
|
||||
v-if="workflowOption.workflow.initialMode === 'app'"
|
||||
v-else-if="workflowOption.workflow.initialMode === 'app'"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
@@ -149,6 +150,11 @@ const shouldShowStatusIndicator = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isBuilderState = computed(() => {
|
||||
const currentMode = props.workflowOption.workflow.activeMode
|
||||
return typeof currentMode === 'string' && currentMode.startsWith('builder:')
|
||||
})
|
||||
|
||||
const isActiveTab = computed(() => {
|
||||
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
|
||||
})
|
||||
|
||||
@@ -83,13 +83,18 @@
|
||||
v-if="isIntegratedTabBar"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<TopMenuHelpButton />
|
||||
<CurrentUserButton
|
||||
v-if="isLoggedIn"
|
||||
:show-arrow="false"
|
||||
compact
|
||||
class="grid w-10 shrink-0 p-1"
|
||||
/>
|
||||
<Button
|
||||
v-if="isCloud || isNightly"
|
||||
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
|
||||
variant="muted-textonly"
|
||||
size="icon"
|
||||
class="shrink-0 text-base-foreground"
|
||||
:aria-label="$t('actionbar.feedback')"
|
||||
@click="openFeedback"
|
||||
>
|
||||
<i class="icon-[lucide--message-square-text]" />
|
||||
</Button>
|
||||
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
|
||||
<LoginButton v-else-if="isDesktop" class="p-1" />
|
||||
</div>
|
||||
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
|
||||
@@ -102,21 +107,20 @@ import ScrollPanel from 'primevue/scrollpanel'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
|
||||
import type { WatchStopHandle } from 'vue'
|
||||
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
|
||||
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
|
||||
@@ -138,8 +142,14 @@ const commandStore = useCommandStore()
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
const isIntegratedTabBar = computed(
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
|
||||
)
|
||||
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
|
||||
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
function openFeedback() {
|
||||
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const showOverflowArrows = ref(false)
|
||||
|
||||
49
src/composables/graph/useCustomNodeColorSettings.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import {
|
||||
NODE_COLOR_DARKER_HEADER_SETTING_ID,
|
||||
NODE_COLOR_FAVORITES_SETTING_ID,
|
||||
NODE_COLOR_RECENTS_SETTING_ID,
|
||||
normalizeNodeColor,
|
||||
toggleFavoriteNodeColor,
|
||||
upsertRecentNodeColor
|
||||
} from '@/utils/nodeColorCustomization'
|
||||
|
||||
export function useCustomNodeColorSettings() {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const favoriteColors = computed(() =>
|
||||
settingStore.get(NODE_COLOR_FAVORITES_SETTING_ID) ?? []
|
||||
)
|
||||
const recentColors = computed(() =>
|
||||
settingStore.get(NODE_COLOR_RECENTS_SETTING_ID) ?? []
|
||||
)
|
||||
const darkerHeader = computed(() =>
|
||||
settingStore.get(NODE_COLOR_DARKER_HEADER_SETTING_ID) ?? true
|
||||
)
|
||||
|
||||
async function rememberRecentColor(color: string) {
|
||||
const nextColors = upsertRecentNodeColor(recentColors.value, color)
|
||||
await settingStore.set(NODE_COLOR_RECENTS_SETTING_ID, nextColors)
|
||||
}
|
||||
|
||||
async function toggleFavoriteColor(color: string) {
|
||||
const nextColors = toggleFavoriteNodeColor(favoriteColors.value, color)
|
||||
await settingStore.set(NODE_COLOR_FAVORITES_SETTING_ID, nextColors)
|
||||
}
|
||||
|
||||
function isFavoriteColor(color: string | null | undefined) {
|
||||
if (!color) return false
|
||||
return favoriteColors.value.includes(normalizeNodeColor(color))
|
||||
}
|
||||
|
||||
return {
|
||||
favoriteColors,
|
||||
recentColors,
|
||||
darkerHeader,
|
||||
rememberRecentColor,
|
||||
toggleFavoriteColor,
|
||||
isFavoriteColor
|
||||
}
|
||||
}
|
||||
159
src/composables/graph/useGroupMenuOptions.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as VueI18nModule from 'vue-i18n'
|
||||
|
||||
import { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type * as NodeColorCustomizationModule from '@/utils/nodeColorCustomization'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
refreshCanvas: vi.fn(),
|
||||
rememberRecentColor: vi.fn().mockResolvedValue(undefined),
|
||||
selectedItems: [] as unknown[]
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueI18nModule>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
selectedItems: mocks.selectedItems,
|
||||
canvas: {
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: null
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
|
||||
useCanvasRefresh: () => ({
|
||||
refreshCanvas: mocks.refreshCanvas
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useCustomNodeColorSettings', () => ({
|
||||
useCustomNodeColorSettings: () => ({
|
||||
darkerHeader: { value: true },
|
||||
favoriteColors: { value: ['#abcdef'] },
|
||||
recentColors: { value: [] },
|
||||
rememberRecentColor: mocks.rememberRecentColor
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
colorOptions: [
|
||||
{
|
||||
name: 'noColor',
|
||||
localizedName: 'color.noColor',
|
||||
value: {
|
||||
dark: '#353535',
|
||||
light: '#6f6f6f'
|
||||
}
|
||||
}
|
||||
],
|
||||
isLightTheme: { value: false },
|
||||
shapeOptions: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/nodeColorCustomization', async () =>
|
||||
vi.importActual<typeof NodeColorCustomizationModule>(
|
||||
'@/utils/nodeColorCustomization'
|
||||
)
|
||||
)
|
||||
|
||||
function createNode() {
|
||||
return Object.assign(Object.create(LGraphNode.prototype), {
|
||||
color: undefined,
|
||||
bgcolor: undefined,
|
||||
getColorOption: () => null
|
||||
}) as LGraphNode
|
||||
}
|
||||
|
||||
function createGroup(color?: string) {
|
||||
return Object.assign(Object.create(LGraphGroup.prototype), {
|
||||
color,
|
||||
getColorOption: () => null
|
||||
}) as LGraphGroup
|
||||
}
|
||||
|
||||
describe('useGroupMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.selectedItems = []
|
||||
})
|
||||
|
||||
it('applies saved custom colors to the group context only', async () => {
|
||||
const selectedNode = createNode()
|
||||
const groupContext = createGroup()
|
||||
mocks.selectedItems = [selectedNode, groupContext]
|
||||
|
||||
const { useGroupMenuOptions } = await import('./useGroupMenuOptions')
|
||||
const { getGroupColorOptions } = useGroupMenuOptions()
|
||||
const bump = vi.fn()
|
||||
|
||||
const colorMenu = getGroupColorOptions(groupContext, bump)
|
||||
const favoriteEntry = colorMenu.submenu?.find((entry) =>
|
||||
entry.label.includes('#ABCDEF')
|
||||
)
|
||||
|
||||
expect(favoriteEntry).toBeDefined()
|
||||
|
||||
await favoriteEntry?.action()
|
||||
|
||||
expect(groupContext.color).toBe('#abcdef')
|
||||
expect(selectedNode.bgcolor).toBeUndefined()
|
||||
expect(mocks.refreshCanvas).toHaveBeenCalledOnce()
|
||||
expect(mocks.rememberRecentColor).toHaveBeenCalledWith('#abcdef')
|
||||
expect(bump).toHaveBeenCalledOnce()
|
||||
expect(mocks.rememberRecentColor.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
bump.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds the PrimeVue custom picker from the clicked group color', async () => {
|
||||
const selectedNode = createNode()
|
||||
selectedNode.bgcolor = '#445566'
|
||||
const groupContext = createGroup('#112233')
|
||||
mocks.selectedItems = [selectedNode, groupContext]
|
||||
|
||||
const { useGroupMenuOptions } = await import('./useGroupMenuOptions')
|
||||
const { getGroupColorOptions } = useGroupMenuOptions()
|
||||
const bump = vi.fn()
|
||||
const colorMenu = getGroupColorOptions(groupContext, bump)
|
||||
const customEntry = colorMenu.submenu?.find(
|
||||
(entry) => entry.label === 'g.custom'
|
||||
)
|
||||
|
||||
expect(customEntry).toBeDefined()
|
||||
expect(customEntry?.color).toBe('#112233')
|
||||
expect(customEntry?.pickerValue).toBe('112233')
|
||||
|
||||
await customEntry?.onColorPick?.('#fedcba')
|
||||
|
||||
expect(groupContext.color).toBe('#fedcba')
|
||||
expect(selectedNode.bgcolor).toBe('#445566')
|
||||
expect(mocks.rememberRecentColor).toHaveBeenCalledWith('#fedcba')
|
||||
expect(mocks.rememberRecentColor.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
bump.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useCustomNodeColorSettings } from '@/composables/graph/useCustomNodeColorSettings'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import {
|
||||
applyCustomColorToItems,
|
||||
getDefaultCustomNodeColor,
|
||||
getSharedAppliedColor
|
||||
} from '@/utils/nodeColorCustomization'
|
||||
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
@@ -19,7 +25,24 @@ export function useGroupMenuOptions() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const settingStore = useSettingStore()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const { shapeOptions, colorOptions, isLightTheme } = useNodeCustomization()
|
||||
const { darkerHeader, favoriteColors, recentColors, rememberRecentColor } =
|
||||
useCustomNodeColorSettings()
|
||||
const {
|
||||
colorOptions,
|
||||
isLightTheme,
|
||||
shapeOptions
|
||||
} = useNodeCustomization()
|
||||
|
||||
const applyCustomColorToGroup = async (
|
||||
groupContext: LGraphGroup,
|
||||
color: string
|
||||
) => {
|
||||
applyCustomColorToItems([groupContext], color, {
|
||||
darkerHeader: darkerHeader.value
|
||||
})
|
||||
canvasRefresh.refreshCanvas()
|
||||
await rememberRecentColor(color)
|
||||
}
|
||||
|
||||
const getFitGroupToNodesOption = (groupContext: LGraphGroup): MenuOption => ({
|
||||
label: 'Fit Group To Nodes',
|
||||
@@ -65,19 +88,68 @@ export function useGroupMenuOptions() {
|
||||
label: t('contextMenu.Color'),
|
||||
icon: 'icon-[lucide--palette]',
|
||||
hasSubmenu: true,
|
||||
submenu: colorOptions.map((colorOption) => ({
|
||||
label: colorOption.localizedName,
|
||||
color: isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark,
|
||||
action: () => {
|
||||
groupContext.color = isLightTheme.value
|
||||
submenu: (() => {
|
||||
const currentAppliedColor = getSharedAppliedColor([groupContext])
|
||||
const presetEntries = colorOptions.map((colorOption) => ({
|
||||
label: colorOption.localizedName,
|
||||
color: isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark
|
||||
canvasRefresh.refreshCanvas()
|
||||
bump()
|
||||
}
|
||||
}))
|
||||
: colorOption.value.dark,
|
||||
action: () => {
|
||||
groupContext.color = isLightTheme.value
|
||||
? colorOption.value.light
|
||||
: colorOption.value.dark
|
||||
canvasRefresh.refreshCanvas()
|
||||
bump()
|
||||
}
|
||||
}))
|
||||
|
||||
const presetColors = new Set(
|
||||
colorOptions.map((colorOption) => colorOption.value.dark.toLowerCase())
|
||||
)
|
||||
const customEntries = [
|
||||
...favoriteColors.value.map((color) => ({
|
||||
label: `${t('g.favorites')}: ${color.toUpperCase()}`,
|
||||
color
|
||||
})),
|
||||
...recentColors.value.map((color) => ({
|
||||
label: `${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`,
|
||||
color
|
||||
}))
|
||||
]
|
||||
.filter((entry, index, entries) => {
|
||||
return (
|
||||
entries.findIndex((candidate) => candidate.color === entry.color) ===
|
||||
index
|
||||
)
|
||||
})
|
||||
.filter((entry) => !presetColors.has(entry.color.toLowerCase()))
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
action: async () => {
|
||||
await applyCustomColorToGroup(groupContext, entry.color)
|
||||
bump()
|
||||
}
|
||||
}))
|
||||
|
||||
return [
|
||||
...presetEntries,
|
||||
...customEntries,
|
||||
{
|
||||
label: t('g.custom'),
|
||||
color: currentAppliedColor ?? undefined,
|
||||
pickerValue: (currentAppliedColor ?? getDefaultCustomNodeColor()).replace(
|
||||
'#',
|
||||
''
|
||||
),
|
||||
onColorPick: async (color: string) => {
|
||||
await applyCustomColorToGroup(groupContext, color)
|
||||
bump()
|
||||
},
|
||||
action: () => {}
|
||||
}
|
||||
]
|
||||
})()
|
||||
})
|
||||
|
||||
const getGroupModeOptions = (
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface SubMenuOption {
|
||||
action: () => void
|
||||
color?: string
|
||||
disabled?: boolean
|
||||
pickerValue?: string
|
||||
onColorPick?: (color: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export enum BadgeVariant {
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import {
|
||||
applyCustomColorToItems,
|
||||
getSharedAppliedColor
|
||||
} from '@/utils/nodeColorCustomization'
|
||||
|
||||
import { useCustomNodeColorSettings } from './useCustomNodeColorSettings'
|
||||
import { useCanvasRefresh } from './useCanvasRefresh'
|
||||
|
||||
interface ColorOption {
|
||||
@@ -36,6 +41,12 @@ export function useNodeCustomization() {
|
||||
const { t } = useI18n()
|
||||
const canvasStore = useCanvasStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const {
|
||||
favoriteColors,
|
||||
recentColors,
|
||||
darkerHeader,
|
||||
rememberRecentColor
|
||||
} = useCustomNodeColorSettings()
|
||||
const canvasRefresh = useCanvasRefresh()
|
||||
const isLightTheme = computed(
|
||||
() => colorPaletteStore.completedActivePalette.light_theme
|
||||
@@ -101,6 +112,19 @@ export function useNodeCustomization() {
|
||||
canvasRefresh.refreshCanvas()
|
||||
}
|
||||
|
||||
const applyCustomColor = async (color: string) => {
|
||||
const normalized = applyCustomColorToItems(
|
||||
canvasStore.selectedItems,
|
||||
color,
|
||||
{
|
||||
darkerHeader: darkerHeader.value
|
||||
}
|
||||
)
|
||||
|
||||
canvasRefresh.refreshCanvas()
|
||||
await rememberRecentColor(normalized)
|
||||
}
|
||||
|
||||
const applyShape = (shapeOption: ShapeOption) => {
|
||||
const selectedNodes = Array.from(canvasStore.selectedItems).filter(
|
||||
(item): item is LGraphNode => item instanceof LGraphNode
|
||||
@@ -155,13 +179,20 @@ export function useNodeCustomization() {
|
||||
)
|
||||
}
|
||||
|
||||
const getCurrentAppliedColor = (): string | null =>
|
||||
getSharedAppliedColor(Array.from(canvasStore.selectedItems))
|
||||
|
||||
return {
|
||||
colorOptions,
|
||||
shapeOptions,
|
||||
applyColor,
|
||||
applyCustomColor,
|
||||
applyShape,
|
||||
getCurrentColor,
|
||||
getCurrentAppliedColor,
|
||||
getCurrentShape,
|
||||
favoriteColors,
|
||||
recentColors,
|
||||
isLightTheme
|
||||
}
|
||||
}
|
||||
|
||||
93
src/composables/graph/useNodeMenuOptions.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as VueI18nModule from 'vue-i18n'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
applyCustomColor: vi.fn(),
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn(),
|
||||
getCurrentAppliedColor: vi.fn<() => string | null>(() => null)
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof VueI18nModule>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [],
|
||||
applyShape: mocks.applyShape,
|
||||
applyColor: mocks.applyColor,
|
||||
applyCustomColor: mocks.applyCustomColor,
|
||||
colorOptions: [
|
||||
{
|
||||
name: 'noColor',
|
||||
localizedName: 'color.noColor',
|
||||
value: {
|
||||
dark: '#353535',
|
||||
light: '#6f6f6f'
|
||||
}
|
||||
}
|
||||
],
|
||||
favoriteColors: { value: [] },
|
||||
recentColors: { value: [] },
|
||||
getCurrentAppliedColor: mocks.getCurrentAppliedColor,
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: mocks.adjustNodeSize,
|
||||
toggleNodeCollapse: mocks.toggleNodeCollapse,
|
||||
toggleNodePin: mocks.toggleNodePin,
|
||||
toggleNodeBypass: mocks.toggleNodeBypass,
|
||||
runBranch: mocks.runBranch
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useNodeMenuOptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getCurrentAppliedColor.mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('keeps the custom node color entry unset when there is no shared applied color', async () => {
|
||||
const { useNodeMenuOptions } = await import('./useNodeMenuOptions')
|
||||
const { colorSubmenu } = useNodeMenuOptions()
|
||||
|
||||
const customEntry = colorSubmenu.value.find(
|
||||
(entry) => entry.label === 'g.custom'
|
||||
)
|
||||
|
||||
expect(customEntry).toBeDefined()
|
||||
expect(customEntry?.color).toBeUndefined()
|
||||
expect(customEntry?.pickerValue).toBe('353535')
|
||||
})
|
||||
|
||||
it('preserves the shared applied color for the custom node color entry', async () => {
|
||||
mocks.getCurrentAppliedColor.mockReturnValue('#abcdef')
|
||||
|
||||
const { useNodeMenuOptions } = await import('./useNodeMenuOptions')
|
||||
const { colorSubmenu } = useNodeMenuOptions()
|
||||
|
||||
const customEntry = colorSubmenu.value.find(
|
||||
(entry) => entry.label === 'g.custom'
|
||||
)
|
||||
|
||||
expect(customEntry).toBeDefined()
|
||||
expect(customEntry?.color).toBe('#abcdef')
|
||||
expect(customEntry?.pickerValue).toBe('abcdef')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getDefaultCustomNodeColor } from '@/utils/nodeColorCustomization'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
import { useSelectedNodeActions } from './useSelectedNodeActions'
|
||||
@@ -11,8 +13,17 @@ import type { NodeSelectionState } from './useSelectionState'
|
||||
*/
|
||||
export function useNodeMenuOptions() {
|
||||
const { t } = useI18n()
|
||||
const { shapeOptions, applyShape, applyColor, colorOptions, isLightTheme } =
|
||||
useNodeCustomization()
|
||||
const {
|
||||
shapeOptions,
|
||||
applyShape,
|
||||
applyColor,
|
||||
applyCustomColor,
|
||||
colorOptions,
|
||||
favoriteColors,
|
||||
recentColors,
|
||||
getCurrentAppliedColor,
|
||||
isLightTheme
|
||||
} = useNodeCustomization()
|
||||
const {
|
||||
adjustNodeSize,
|
||||
toggleNodeCollapse,
|
||||
@@ -29,7 +40,8 @@ export function useNodeMenuOptions() {
|
||||
)
|
||||
|
||||
const colorSubmenu = computed(() => {
|
||||
return colorOptions.map((colorOption) => ({
|
||||
const currentAppliedColor = getCurrentAppliedColor()
|
||||
const presetEntries = colorOptions.map((colorOption) => ({
|
||||
label: colorOption.localizedName,
|
||||
color: isLightTheme.value
|
||||
? colorOption.value.light
|
||||
@@ -37,6 +49,48 @@ export function useNodeMenuOptions() {
|
||||
action: () =>
|
||||
applyColor(colorOption.name === 'noColor' ? null : colorOption)
|
||||
}))
|
||||
|
||||
const presetColors = new Set(
|
||||
colorOptions.map((colorOption) => colorOption.value.dark.toLowerCase())
|
||||
)
|
||||
const customEntries = [
|
||||
...favoriteColors.value.map((color) => ({
|
||||
label: `${t('g.favorites')}: ${color.toUpperCase()}`,
|
||||
color
|
||||
})),
|
||||
...recentColors.value.map((color) => ({
|
||||
label: `${t('modelLibrary.sortRecent')}: ${color.toUpperCase()}`,
|
||||
color
|
||||
}))
|
||||
]
|
||||
.filter((entry, index, entries) => {
|
||||
return (
|
||||
entries.findIndex((candidate) => candidate.color === entry.color) ===
|
||||
index
|
||||
)
|
||||
})
|
||||
.filter((entry) => !presetColors.has(entry.color.toLowerCase()))
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
action: () => {
|
||||
void applyCustomColor(entry.color)
|
||||
}
|
||||
}))
|
||||
|
||||
return [
|
||||
...presetEntries,
|
||||
...customEntries,
|
||||
{
|
||||
label: t('g.custom'),
|
||||
color: currentAppliedColor ?? undefined,
|
||||
pickerValue: (currentAppliedColor ?? getDefaultCustomNodeColor()).replace(
|
||||
'#',
|
||||
''
|
||||
),
|
||||
onColorPick: applyCustomColor,
|
||||
action: () => {}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const getAdjustSizeOption = (): MenuOption => ({
|
||||
|
||||
@@ -397,8 +397,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
if (app.canvas.empty) {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.emptyCanvas'),
|
||||
life: 3000
|
||||
summary: t('toastMessages.emptyCanvas')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -557,8 +556,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToQueue'),
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.pleaseSelectOutputNodes')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -571,8 +569,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.failedToQueue'),
|
||||
detail: t('toastMessages.failedExecutionPathResolution'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedExecutionPathResolution')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -602,8 +599,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.nothingToGroup'),
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.pleaseSelectNodesToGroup')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -962,8 +958,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('manager.notAvailable'),
|
||||
life: 3000
|
||||
detail: t('manager.notAvailable')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1048,8 +1043,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('toastMessages.cannotCreateSubgraph'),
|
||||
detail: t('toastMessages.failedToConvertToSubgraph'),
|
||||
life: 3000
|
||||
detail: t('toastMessages.failedToConvertToSubgraph')
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1258,8 +1252,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModels'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1278,8 +1271,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
summary: t('g.error'),
|
||||
detail: t('g.commandProhibited', {
|
||||
command: 'Comfy.Memory.UnloadModelsAndExecutionCache'
|
||||
}),
|
||||
life: 3000
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,19 +5,15 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||
import { useHelpCenterStore } from '@/stores/helpCenterStore'
|
||||
import type { HelpCenterTriggerLocation } from '@/stores/helpCenterStore'
|
||||
import { useConflictAcknowledgment } from '@/workbench/extensions/manager/composables/useConflictAcknowledgment'
|
||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||
import { useNodeConflictDialog } from '@/workbench/extensions/manager/composables/useNodeConflictDialog'
|
||||
|
||||
export function useHelpCenter(
|
||||
triggerFrom: HelpCenterTriggerLocation = 'sidebar'
|
||||
) {
|
||||
export function useHelpCenter() {
|
||||
const settingStore = useSettingStore()
|
||||
const releaseStore = useReleaseStore()
|
||||
const helpCenterStore = useHelpCenterStore()
|
||||
const { isVisible: isHelpCenterVisible, triggerLocation } =
|
||||
storeToRefs(helpCenterStore)
|
||||
const { isVisible: isHelpCenterVisible } = storeToRefs(helpCenterStore)
|
||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||
|
||||
const conflictDetection = useConflictDetection()
|
||||
@@ -42,9 +38,9 @@ export function useHelpCenter(
|
||||
*/
|
||||
const toggleHelpCenter = () => {
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: `${triggerFrom}_help_center_toggled`
|
||||
button_id: 'sidebar_help_center_toggled'
|
||||
})
|
||||
helpCenterStore.toggle(triggerFrom)
|
||||
helpCenterStore.toggle()
|
||||
}
|
||||
|
||||
const closeHelpCenter = () => {
|
||||
@@ -90,7 +86,6 @@ export function useHelpCenter(
|
||||
|
||||
return {
|
||||
isHelpCenterVisible,
|
||||
triggerLocation,
|
||||
shouldShowRedDot,
|
||||
sidebarLocation,
|
||||
toggleHelpCenter,
|
||||
|
||||
@@ -81,8 +81,7 @@ function getParentNodes(): SubgraphNode[] {
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph'),
|
||||
life: 2000
|
||||
detail: t('subgraphStore.promoteOutsideSubgraph')
|
||||
})
|
||||
return []
|
||||
}
|
||||
|
||||
161
src/core/graph/subgraph/resolveSubgraphInputTarget.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({})
|
||||
}))
|
||||
vi.mock('@/stores/domWidgetStore', () => ({
|
||||
useDomWidgetStore: () => ({ widgetStates: new Map() })
|
||||
}))
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
function createOuterSubgraphSetup(inputNames: string[]): {
|
||||
outerSubgraph: Subgraph
|
||||
outerSubgraphNode: SubgraphNode
|
||||
} {
|
||||
const outerSubgraph = createTestSubgraph({
|
||||
inputs: inputNames.map((name) => ({ name, type: '*' }))
|
||||
})
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 1 })
|
||||
return { outerSubgraph, outerSubgraphNode }
|
||||
}
|
||||
|
||||
function addLinkedNestedSubgraphNode(
|
||||
outerSubgraph: Subgraph,
|
||||
inputName: string,
|
||||
linkedInputName: string,
|
||||
options: { widget?: string } = {}
|
||||
): { innerSubgraphNode: SubgraphNode } {
|
||||
const innerSubgraph = createTestSubgraph({
|
||||
inputs: [{ name: linkedInputName, type: '*' }]
|
||||
})
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, { id: 819 })
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === inputName
|
||||
)
|
||||
if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`)
|
||||
|
||||
const input = innerSubgraphNode.addInput(linkedInputName, '*')
|
||||
if (options.widget) {
|
||||
innerSubgraphNode.addWidget('number', options.widget, 0, () => undefined)
|
||||
input.widget = { name: options.widget }
|
||||
}
|
||||
inputSlot.connect(input, innerSubgraphNode)
|
||||
|
||||
if (input.link == null) {
|
||||
throw new Error(`Expected link to be created for input ${linkedInputName}`)
|
||||
}
|
||||
|
||||
return { innerSubgraphNode }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('resolveSubgraphInputTarget', () => {
|
||||
test('returns target for widget-backed input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'width',
|
||||
'audio'
|
||||
])
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
|
||||
widget: 'width'
|
||||
})
|
||||
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
|
||||
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
|
||||
).toMatchObject({
|
||||
nodeId: '819',
|
||||
widgetName: 'width'
|
||||
})
|
||||
expect(
|
||||
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns target for widget-backed input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'seed'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'seed'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-seed')
|
||||
node.id = 42
|
||||
const input = node.addInput('seed_input', '*')
|
||||
node.addWidget('number', 'seed', 0, () => undefined)
|
||||
input.widget = { name: 'seed' }
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
|
||||
|
||||
expect(result).toMatchObject({
|
||||
nodeId: '42',
|
||||
widgetName: 'seed'
|
||||
})
|
||||
})
|
||||
|
||||
test('returns undefined for non-widget input on plain interior node', () => {
|
||||
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
|
||||
'image'
|
||||
])
|
||||
|
||||
const inputSlot = outerSubgraph.inputNode.slots.find(
|
||||
(slot) => slot.name === 'image'
|
||||
)!
|
||||
const node = new LGraphNode('Interior-image')
|
||||
const input = node.addInput('image_input', '*')
|
||||
outerSubgraph.add(node)
|
||||
inputSlot.connect(input, node)
|
||||
|
||||
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'image')
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,9 @@ export function resolveSubgraphInputTarget(
|
||||
inputName,
|
||||
({ inputNode, targetInput, getTargetWidget }) => {
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
const targetWidget = getTargetWidget()
|
||||
if (!targetWidget) return undefined
|
||||
|
||||
return {
|
||||
nodeId: String(inputNode.id),
|
||||
widgetName: targetInput.name
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
|
||||
import { t } from '@/i18n'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { TopbarBadge } from '@/types/comfy'
|
||||
|
||||
@@ -17,16 +18,20 @@ const badges = computed<TopbarBadge[]>(() => {
|
||||
tooltip: alert.tooltip
|
||||
})
|
||||
}
|
||||
|
||||
// Always add cloud badge last (furthest right)
|
||||
result.push({
|
||||
icon: 'icon-[lucide--cloud]',
|
||||
text: 'Comfy Cloud'
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
(canvas) => {
|
||||
if (canvas) {
|
||||
canvas.info_text = t('g.comfyCloud')
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.Badges',
|
||||
get topbarBadges() {
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { t } from '@/i18n'
|
||||
import { getDistribution, ZENDESK_FIELDS } from '@/platform/support/config'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { buildFeedbackUrl } from '@/platform/support/config'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import type { ActionBarButton } from '@/types/comfy'
|
||||
|
||||
const ZENDESK_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
|
||||
const ZENDESK_FEEDBACK_FORM_ID = '43066738713236'
|
||||
|
||||
const distribution = getDistribution()
|
||||
const params = new URLSearchParams({
|
||||
ticket_form_id: ZENDESK_FEEDBACK_FORM_ID,
|
||||
[ZENDESK_FIELDS.DISTRIBUTION]: distribution
|
||||
})
|
||||
const feedbackUrl = `${ZENDESK_BASE_URL}?${params.toString()}`
|
||||
const feedbackUrl = buildFeedbackUrl()
|
||||
|
||||
const buttons: ActionBarButton[] = [
|
||||
{
|
||||
icon: 'icon-[lucide--message-circle-question-mark]',
|
||||
icon: 'icon-[lucide--message-square-text]',
|
||||
label: t('actionbar.feedback'),
|
||||
tooltip: t('actionbar.feedbackTooltip'),
|
||||
onClick: () => {
|
||||
@@ -25,6 +18,10 @@ const buttons: ActionBarButton[] = [
|
||||
]
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.FeedbackButton',
|
||||
actionBarButtons: buttons
|
||||
name: 'Comfy.FeedbackButton',
|
||||
get actionBarButtons() {
|
||||
return useSettingStore().get('Comfy.UI.TabBarLayout') === 'Legacy'
|
||||
? buttons
|
||||
: []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -204,8 +204,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorInstallingUpdate'),
|
||||
life: 10_000
|
||||
detail: t('desktopUpdate.errorInstallingUpdate')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -214,8 +213,7 @@ import { electronAPI as getElectronAPI } from '@/utils/envUtil'
|
||||
toastStore.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('desktopUpdate.errorCheckingUpdate'),
|
||||
life: 10_000
|
||||
detail: t('desktopUpdate.errorCheckingUpdate')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
400
src/lib/litegraph/src/LGraphCanvas.nodeColors.test.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
import { LGraphGroup } from './LGraphGroup'
|
||||
import { LGraphNode } from './LGraphNode'
|
||||
import { LiteGraph } from './litegraph'
|
||||
|
||||
describe('LGraphCanvas.onMenuNodeColors', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does not add a custom color entry to the legacy submenu', () => {
|
||||
const node = Object.assign(Object.create(LGraphNode.prototype), {
|
||||
color: undefined,
|
||||
bgcolor: undefined
|
||||
}) as LGraphNode
|
||||
|
||||
const canvas = {
|
||||
selectedItems: new Set([node]),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
|
||||
|
||||
let capturedValues:
|
||||
| ReadonlyArray<{ content?: string } | string | null>
|
||||
| undefined
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
class MockContextMenu {
|
||||
constructor(values: ReadonlyArray<{ content?: string } | string | null>) {
|
||||
capturedValues = values
|
||||
}
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
try {
|
||||
LGraphCanvas.onMenuNodeColors(
|
||||
{ content: 'Colors', value: null },
|
||||
{} as never,
|
||||
new MouseEvent('contextmenu'),
|
||||
{} as ContextMenu<string | null>,
|
||||
node
|
||||
)
|
||||
|
||||
const contents = capturedValues
|
||||
?.filter(
|
||||
(value): value is { content?: string } =>
|
||||
typeof value === 'object' && value !== null
|
||||
)
|
||||
.map((value) => value.content ?? '')
|
||||
|
||||
expect(contents).not.toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Custom...')])
|
||||
)
|
||||
} finally {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
}
|
||||
})
|
||||
|
||||
it('uses group preset colors for legacy group menu swatches', () => {
|
||||
const group = Object.assign(Object.create(LGraphGroup.prototype), {
|
||||
color: undefined
|
||||
}) as LGraphGroup
|
||||
|
||||
const canvas = {
|
||||
selectedItems: new Set([group]),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
|
||||
|
||||
let capturedValues:
|
||||
| ReadonlyArray<{ content?: string } | string | null>
|
||||
| undefined
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
class MockContextMenu {
|
||||
constructor(values: ReadonlyArray<{ content?: string } | string | null>) {
|
||||
capturedValues = values
|
||||
}
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
try {
|
||||
LGraphCanvas.onMenuNodeColors(
|
||||
{ content: 'Colors', value: null },
|
||||
{} as never,
|
||||
new MouseEvent('contextmenu'),
|
||||
{} as ContextMenu<string | null>,
|
||||
group
|
||||
)
|
||||
|
||||
const contents = capturedValues
|
||||
?.filter(
|
||||
(value): value is { content?: string } =>
|
||||
typeof value === 'object' && value !== null
|
||||
)
|
||||
.map((value) => value.content ?? '')
|
||||
|
||||
expect(contents).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining(LGraphCanvas.node_colors.red.groupcolor)
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
}
|
||||
})
|
||||
|
||||
it('sanitizes legacy menu markup for extension-provided labels and colors', () => {
|
||||
const node = Object.assign(Object.create(LGraphNode.prototype), {
|
||||
color: undefined,
|
||||
bgcolor: undefined
|
||||
}) as LGraphNode
|
||||
|
||||
const canvas = {
|
||||
selectedItems: new Set([node]),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
|
||||
|
||||
let capturedValues:
|
||||
| ReadonlyArray<{ content?: string } | string | null>
|
||||
| undefined
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
const originalNodeColors = LGraphCanvas.node_colors
|
||||
class MockContextMenu {
|
||||
constructor(values: ReadonlyArray<{ content?: string } | string | null>) {
|
||||
capturedValues = values
|
||||
}
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
LGraphCanvas.node_colors = {
|
||||
...originalNodeColors,
|
||||
'<img src=x onerror=1>': {
|
||||
color: '#000',
|
||||
bgcolor: 'not-a-color',
|
||||
groupcolor: '#fff'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
LGraphCanvas.onMenuNodeColors(
|
||||
{ content: 'Colors', value: null },
|
||||
{} as never,
|
||||
new MouseEvent('contextmenu'),
|
||||
{} as ContextMenu<string | null>,
|
||||
node
|
||||
)
|
||||
|
||||
const escapedEntry = capturedValues
|
||||
?.filter(
|
||||
(value): value is { content?: string } =>
|
||||
typeof value === 'object' && value !== null
|
||||
)
|
||||
.map((value) => value.content ?? '')
|
||||
.find((content) => content.includes('<img src=x onerror=1>'))
|
||||
|
||||
expect(escapedEntry).toBeDefined()
|
||||
expect(escapedEntry).not.toContain('<img src=x onerror=1>')
|
||||
expect(escapedEntry).not.toContain('background-color:not-a-color')
|
||||
} finally {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
LGraphCanvas.node_colors = originalNodeColors
|
||||
}
|
||||
})
|
||||
|
||||
it('applies preset colors to selected nodes and groups in legacy mode', () => {
|
||||
const graph = {
|
||||
beforeChange: vi.fn(),
|
||||
afterChange: vi.fn()
|
||||
}
|
||||
|
||||
const node = Object.assign(Object.create(LGraphNode.prototype), {
|
||||
graph,
|
||||
color: undefined,
|
||||
bgcolor: undefined
|
||||
}) as LGraphNode
|
||||
const group = Object.assign(Object.create(LGraphGroup.prototype), {
|
||||
graph,
|
||||
color: undefined
|
||||
}) as LGraphGroup
|
||||
|
||||
const canvas = {
|
||||
selectedItems: new Set([node, group]),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
|
||||
|
||||
let callback: ((value: { value?: unknown }) => void) | undefined
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
class MockContextMenu {
|
||||
constructor(
|
||||
_values: ReadonlyArray<{ content?: string } | string | null>,
|
||||
options: { callback?: (value: { value?: unknown }) => void }
|
||||
) {
|
||||
callback = options.callback
|
||||
}
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
try {
|
||||
LGraphCanvas.onMenuNodeColors(
|
||||
{ content: 'Colors', value: null },
|
||||
{} as never,
|
||||
new MouseEvent('contextmenu'),
|
||||
{} as ContextMenu<string | null>,
|
||||
node
|
||||
)
|
||||
|
||||
callback?.({
|
||||
value: 'red'
|
||||
})
|
||||
|
||||
expect(node.bgcolor).toBe(LGraphCanvas.node_colors.red.bgcolor)
|
||||
expect(group.color).toBe(LGraphCanvas.node_colors.red.groupcolor)
|
||||
expect(graph.beforeChange).toHaveBeenCalled()
|
||||
expect(graph.afterChange).toHaveBeenCalled()
|
||||
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
|
||||
} finally {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fan out legacy preset actions to an unrelated single selection', () => {
|
||||
const graph = {
|
||||
beforeChange: vi.fn(),
|
||||
afterChange: vi.fn()
|
||||
}
|
||||
|
||||
const selectedNode = Object.assign(Object.create(LGraphNode.prototype), {
|
||||
graph,
|
||||
color: undefined,
|
||||
bgcolor: undefined
|
||||
}) as LGraphNode
|
||||
const targetNode = Object.assign(Object.create(LGraphNode.prototype), {
|
||||
graph,
|
||||
color: undefined,
|
||||
bgcolor: undefined
|
||||
}) as LGraphNode
|
||||
|
||||
const canvas = {
|
||||
selectedItems: new Set([selectedNode]),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
|
||||
|
||||
let callback: ((value: { value?: unknown }) => void) | undefined
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
class MockContextMenu {
|
||||
constructor(
|
||||
_values: ReadonlyArray<{ content?: string } | string | null>,
|
||||
options: { callback?: (value: { value?: unknown }) => void }
|
||||
) {
|
||||
callback = options.callback
|
||||
}
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
try {
|
||||
LGraphCanvas.onMenuNodeColors(
|
||||
{ content: 'Colors', value: null },
|
||||
{} as never,
|
||||
new MouseEvent('contextmenu'),
|
||||
{} as ContextMenu<string | null>,
|
||||
targetNode
|
||||
)
|
||||
|
||||
callback?.({
|
||||
value: 'red'
|
||||
})
|
||||
|
||||
expect(targetNode.bgcolor).toBe(LGraphCanvas.node_colors.red.bgcolor)
|
||||
expect(selectedNode.bgcolor).toBeUndefined()
|
||||
} finally {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps legacy group color actions scoped to the clicked group', () => {
|
||||
const graph = {
|
||||
beforeChange: vi.fn(),
|
||||
afterChange: vi.fn()
|
||||
}
|
||||
|
||||
const selectedNode = Object.assign(Object.create(LGraphNode.prototype), {
|
||||
graph,
|
||||
color: undefined,
|
||||
bgcolor: undefined
|
||||
}) as LGraphNode
|
||||
const targetGroup = Object.assign(Object.create(LGraphGroup.prototype), {
|
||||
graph,
|
||||
color: undefined
|
||||
}) as LGraphGroup
|
||||
|
||||
const canvas = {
|
||||
selectedItems: new Set([selectedNode, targetGroup]),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
|
||||
|
||||
let callback: ((value: { value?: unknown }) => void) | undefined
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
class MockContextMenu {
|
||||
constructor(
|
||||
_values: ReadonlyArray<{ content?: string } | string | null>,
|
||||
options: { callback?: (value: { value?: unknown }) => void }
|
||||
) {
|
||||
callback = options.callback
|
||||
}
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
try {
|
||||
LGraphCanvas.onMenuNodeColors(
|
||||
{ content: 'Colors', value: null },
|
||||
{} as never,
|
||||
new MouseEvent('contextmenu'),
|
||||
{} as ContextMenu<string | null>,
|
||||
targetGroup
|
||||
)
|
||||
|
||||
callback?.({
|
||||
value: 'red'
|
||||
})
|
||||
|
||||
expect(targetGroup.color).toBe(LGraphCanvas.node_colors.red.groupcolor)
|
||||
expect(selectedNode.bgcolor).toBeUndefined()
|
||||
} finally {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
}
|
||||
})
|
||||
|
||||
it('balances graph change lifecycle if applying a legacy preset throws', () => {
|
||||
const graph = {
|
||||
beforeChange: vi.fn(),
|
||||
afterChange: vi.fn()
|
||||
}
|
||||
|
||||
const node = Object.assign(Object.create(LGraphNode.prototype), {
|
||||
graph,
|
||||
setColorOption: vi.fn(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
}) as LGraphNode
|
||||
|
||||
const canvas = {
|
||||
selectedItems: new Set([node]),
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
LGraphCanvas.active_canvas = canvas as unknown as LGraphCanvas
|
||||
|
||||
let callback:
|
||||
| ((value: string | { value?: unknown } | null) => void)
|
||||
| undefined
|
||||
const originalContextMenu = LiteGraph.ContextMenu
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined)
|
||||
class MockContextMenu {
|
||||
constructor(
|
||||
_values: ReadonlyArray<{ content?: string } | string | null>,
|
||||
options: {
|
||||
callback?: (value: string | { value?: unknown } | null) => void
|
||||
}
|
||||
) {
|
||||
callback = options.callback
|
||||
}
|
||||
}
|
||||
LiteGraph.ContextMenu =
|
||||
MockContextMenu as unknown as typeof LiteGraph.ContextMenu
|
||||
|
||||
try {
|
||||
LGraphCanvas.onMenuNodeColors(
|
||||
{ content: 'Colors', value: null },
|
||||
{} as never,
|
||||
new MouseEvent('contextmenu'),
|
||||
{} as ContextMenu<string | null>,
|
||||
node
|
||||
)
|
||||
|
||||
expect(() => callback?.('red')).not.toThrow()
|
||||
expect(graph.beforeChange).toHaveBeenCalledOnce()
|
||||
expect(graph.afterChange).toHaveBeenCalledOnce()
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
} finally {
|
||||
LiteGraph.ContextMenu = originalContextMenu
|
||||
consoleErrorSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { toString } from 'es-toolkit/compat'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import { st } from '@/i18n'
|
||||
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
@@ -156,6 +157,52 @@ interface ICreateDefaultNodeOptions extends ICreateNodeOptions {
|
||||
posSizeFix?: Point
|
||||
}
|
||||
|
||||
type LegacyColorTarget = (LGraphNode | LGraphGroup) & IColorable & Positionable
|
||||
|
||||
function isLegacyColorTarget(item: unknown): item is LegacyColorTarget {
|
||||
return item instanceof LGraphNode || item instanceof LGraphGroup
|
||||
}
|
||||
|
||||
function getLegacyColorTargets(target: LegacyColorTarget): LegacyColorTarget[] {
|
||||
if (target instanceof LGraphGroup) {
|
||||
return [target]
|
||||
}
|
||||
|
||||
const selected = Array.from(LGraphCanvas.active_canvas.selectedItems).filter(
|
||||
isLegacyColorTarget
|
||||
)
|
||||
|
||||
return selected.length > 1 && selected.includes(target) ? selected : [target]
|
||||
}
|
||||
|
||||
function createLegacyColorMenuContent(label: string, color?: string): string {
|
||||
const escapedLabel = label
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
const safeColor = getSafeLegacyMenuColor(color)
|
||||
|
||||
if (!safeColor) {
|
||||
return `<span style='display: block; padding-left: 4px;'>${escapedLabel}</span>`
|
||||
}
|
||||
|
||||
return (
|
||||
`<span style='display: block; color: #fff; padding-left: 4px;` +
|
||||
` border-left: 8px solid ${safeColor}; background-color:${safeColor}'>${escapedLabel}</span>`
|
||||
)
|
||||
}
|
||||
|
||||
function getSafeLegacyMenuColor(color?: string): string | undefined {
|
||||
if (!color) return undefined
|
||||
|
||||
const trimmed = color.trim()
|
||||
return /^#(?:[\da-fA-F]{3,4}|[\da-fA-F]{6}|[\da-fA-F]{8})$/.test(trimmed)
|
||||
? trimmed
|
||||
: undefined
|
||||
}
|
||||
|
||||
interface HasShowSearchCallback {
|
||||
/** See {@link LGraphCanvas.showSearchBox} */
|
||||
showSearchBox: (
|
||||
@@ -559,6 +606,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
clear_background_color: string
|
||||
render_only_selected: boolean
|
||||
show_info: boolean
|
||||
/** Additional text appended to the canvas info overlay (rendered by {@link renderInfo}). */
|
||||
info_text: string | undefined
|
||||
allow_dragcanvas: boolean
|
||||
allow_dragnodes: boolean
|
||||
allow_interaction: boolean
|
||||
@@ -1647,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)
|
||||
}
|
||||
|
||||
@@ -5180,8 +5238,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
* draws some useful stats in the corner of the canvas
|
||||
*/
|
||||
renderInfo(ctx: CanvasRenderingContext2D, x: number, y: number): void {
|
||||
const lineHeight = 13
|
||||
const lineCount = (this.graph ? 5 : 1) + (this.info_text ? 1 : 0)
|
||||
x = x || 10
|
||||
y = y || this.canvas.offsetHeight - 80
|
||||
y = y || this.canvas.offsetHeight - (lineCount + 1) * lineHeight
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(x, y)
|
||||
@@ -5189,18 +5249,26 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.font = `10px ${LiteGraph.DEFAULT_FONT}`
|
||||
ctx.fillStyle = '#888'
|
||||
ctx.textAlign = 'left'
|
||||
let line = 1
|
||||
if (this.graph) {
|
||||
ctx.fillText(`T: ${this.graph.globaltime.toFixed(2)}s`, 5, 13 * 1)
|
||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, 13 * 2)
|
||||
ctx.fillText(
|
||||
`T: ${this.graph.globaltime.toFixed(2)}s`,
|
||||
5,
|
||||
lineHeight * line++
|
||||
)
|
||||
ctx.fillText(`I: ${this.graph.iteration}`, 5, lineHeight * line++)
|
||||
ctx.fillText(
|
||||
`N: ${this.graph._nodes.length} [${this.visible_nodes.length}]`,
|
||||
5,
|
||||
13 * 3
|
||||
lineHeight * line++
|
||||
)
|
||||
ctx.fillText(`V: ${this.graph._version}`, 5, 13 * 4)
|
||||
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, 13 * 5)
|
||||
ctx.fillText(`V: ${this.graph._version}`, 5, lineHeight * line++)
|
||||
ctx.fillText(`FPS:${this.fps.toFixed(2)}`, 5, lineHeight * line++)
|
||||
} else {
|
||||
ctx.fillText('No graph selected', 5, 13 * 1)
|
||||
ctx.fillText('No graph selected', 5, lineHeight * line++)
|
||||
}
|
||||
if (this.info_text) {
|
||||
ctx.fillText(this.info_text, 5, lineHeight * line++)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
@@ -952,6 +952,7 @@
|
||||
"collapseAll": "طي الكل",
|
||||
"color": "اللون",
|
||||
"comfy": "Comfy",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"comfyOrgLogoAlt": "شعار ComfyOrg",
|
||||
"comingSoon": "قريباً",
|
||||
"command": "أمر",
|
||||
@@ -1459,12 +1460,14 @@
|
||||
"unknownWidget": "عنصر الواجهة غير مرئي"
|
||||
},
|
||||
"cancelThisRun": "إلغاء هذا التشغيل",
|
||||
"deleteAllAssets": "حذف جميع الأصول من هذه الجلسة",
|
||||
"downloadAll": "تنزيل الكل",
|
||||
"dragAndDropImage": "اسحب وأسقط صورة",
|
||||
"emptyWorkflowExplanation": "سير العمل الخاص بك فارغ. تحتاج إلى بعض العقد أولاً لبدء بناء التطبيق.",
|
||||
"enterNodeGraph": "دخول مخطط العقد",
|
||||
"giveFeedback": "إعطاء ملاحظات",
|
||||
"graphMode": "وضع الرسم البياني",
|
||||
"hasCreditCost": "يتطلب أرصدة إضافية",
|
||||
"linearMode": "وضع التطبيق",
|
||||
"loadTemplate": "تحميل قالب",
|
||||
"mobileControls": "تعديل وتشغيل",
|
||||
@@ -1855,7 +1858,6 @@
|
||||
"execute": "تنفيذ",
|
||||
"fullscreen": "ملء الشاشة",
|
||||
"help": "مساعدة",
|
||||
"helpAndFeedback": "المساعدة والتعليقات",
|
||||
"hideMenu": "إخفاء القائمة",
|
||||
"instant": "فوري",
|
||||
"instantTooltip": "سيتم وضع سير العمل في قائمة الانتظار فور انتهاء التوليد",
|
||||
@@ -3374,10 +3376,7 @@
|
||||
"addedToWorkspace": "تمت إضافتك إلى {workspaceName}",
|
||||
"inviteAccepted": "تم قبول الدعوة",
|
||||
"inviteFailed": "فشل في قبول الدعوة",
|
||||
"unsavedChanges": {
|
||||
"message": "لديك تغييرات غير محفوظة. هل تريد تجاهلها والانتقال إلى مساحة عمل أخرى؟",
|
||||
"title": "تغييرات غير محفوظة"
|
||||
},
|
||||
"switchFailed": "فشل في تبديل مساحة العمل. يرجى المحاولة مرة أخرى.",
|
||||
"viewWorkspace": "عرض مساحة العمل"
|
||||
},
|
||||
"workspaceAuth": {
|
||||
|
||||
@@ -400,7 +400,7 @@
|
||||
"name": "تخطيط شريط التبويبات",
|
||||
"options": {
|
||||
"Default": "افتراضي",
|
||||
"Integrated": "مُدمج"
|
||||
"Legacy": "تقليدي"
|
||||
},
|
||||
"tooltip": "يتحكم في تخطيط شريط التبويبات. \"مُدمج\" ينقل عناصر المساعدة والتحكمات الخاصة بالمستخدم إلى منطقة شريط التبويبات."
|
||||
},
|
||||
|
||||
@@ -301,6 +301,7 @@
|
||||
"1x": "1x",
|
||||
"2x": "2x",
|
||||
"beta": "BETA",
|
||||
"comfyCloud": "Comfy Cloud",
|
||||
"nightly": "NIGHTLY",
|
||||
"profile": "Profile",
|
||||
"noItems": "No items",
|
||||
@@ -968,7 +969,6 @@
|
||||
"customNodesManager": "Custom Nodes Manager",
|
||||
"settings": "Settings",
|
||||
"help": "Help",
|
||||
"helpAndFeedback": "Help & Feedback",
|
||||
"queue": "Queue Panel",
|
||||
"fullscreen": "Fullscreen"
|
||||
},
|
||||
@@ -3178,6 +3178,8 @@
|
||||
"backToWorkflow": "Back to workflow",
|
||||
"loadTemplate": "Load a template",
|
||||
"cancelThisRun": "Cancel this run",
|
||||
"deleteAllAssets": "Delete all assets from this run",
|
||||
"hasCreditCost": "Requires additional credits",
|
||||
"welcome": {
|
||||
"title": "App Mode",
|
||||
"message": "A simplified view that hides the node graph so you can focus on creating.",
|
||||
@@ -3404,14 +3406,11 @@
|
||||
"retryDownload": "Retry download"
|
||||
},
|
||||
"workspace": {
|
||||
"unsavedChanges": {
|
||||
"title": "Unsaved Changes",
|
||||
"message": "You have unsaved changes. Do you want to discard them and switch workspaces?"
|
||||
},
|
||||
"inviteAccepted": "Invite Accepted",
|
||||
"addedToWorkspace": "You have been added to:",
|
||||
"inviteFailed": "Failed to Accept Invite",
|
||||
"viewWorkspace": "View workspace"
|
||||
"viewWorkspace": "View workspace",
|
||||
"switchFailed": "Failed to switch workspace. Please try again."
|
||||
},
|
||||
"workspaceAuth": {
|
||||
"errors": {
|
||||
|
||||
@@ -398,10 +398,10 @@
|
||||
},
|
||||
"Comfy_UI_TabBarLayout": {
|
||||
"name": "Tab Bar Layout",
|
||||
"tooltip": "Controls the layout of the tab bar. \"Integrated\" moves Help and User controls into the tab bar area.",
|
||||
"tooltip": "Controls the elements contained in the integrated tab bar.",
|
||||
"options": {
|
||||
"Default": "Default",
|
||||
"Integrated": "Integrated"
|
||||
"Legacy": "Legacy"
|
||||
}
|
||||
},
|
||||
"Comfy_UseNewMenu": {
|
||||
|
||||