Compare commits

...

36 Commits

Author SHA1 Message Date
filtered
429b8799c0 Increment version to 1.21.7 (#4067) 2025-06-03 22:03:36 -07:00
Comfy Org PR Bot
edff418741 [chore] Update litegraph to 0.15.15 (#4062)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-03 21:53:12 -07:00
filtered
ead7e190f8 Increment version to 1.21.6 (#4051) 2025-06-02 10:03:00 +10:00
filtered
df2feea227 Revert "Export vue new (#3966)" (#4050) 2025-06-01 16:58:37 -07:00
filtered
5d87cf4f8a Increment version to 1.21.5 (#4049) 2025-06-02 06:33:32 +10:00
filtered
ec26daa020 Revert "[Dev] Add Playwright MCP for Local Development (#4028)" (#4048) 2025-06-01 13:24:57 -07:00
filtered
ca72839af9 Increment version to 1.21.4 (#4046) 2025-06-02 03:30:55 +10:00
filtered
bb400f181a Revert "[refactor] Refactor file handling (#3955)"
This reverts commit 30ee669f5c.
2025-06-01 09:56:45 -07:00
filtered
27dcc19a4b Revert "[fix] Remove dynamic import timing issue causing Playwright test flakiness (#4031)"
This reverts commit afac449f41.
2025-06-01 09:56:31 -07:00
Comfy Org PR Bot
6289ac9182 1.21.3 (#4035)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-01 00:31:35 +00:00
Christian Byrne
86a7dd05a3 [Dev] Add Playwright MCP for Local Development (#4028)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 13:51:37 -07:00
Christian Byrne
dee00edc5f [feat] Add node library sorting and grouping controls (#4024)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 17:39:39 +10:00
Christian Byrne
afac449f41 [fix] Remove dynamic import timing issue causing Playwright test flakiness (#4031) 2025-05-31 14:01:13 +10:00
filtered
aca1a2a194 Revert "Allow extensions to define pinia stores" (#4027) 2025-05-31 04:12:59 +10:00
filtered
4dfe75d68b Add GH types to issue templates (#3991) 2025-05-30 02:57:10 -07:00
Christian Byrne
2c37dba143 [docs] Add Claude command for adding missing i18n strings (#4023) 2025-05-30 02:22:40 -07:00
Christian Byrne
3936454ffd [feat] Add logout button to user popover (#4022) 2025-05-30 02:17:00 -07:00
Christian Byrne
30ee669f5c [refactor] Refactor file handling (#3955) 2025-05-30 02:05:41 -07:00
Terry Jia
811ddd6165 Allow extensions to define pinia stores (#4018) 2025-05-30 12:05:03 +10:00
filtered
0cdaa512c8 Allow extensions to raise their own Vue dialogs (#4008) 2025-05-29 21:05:52 +10:00
filtered
3a514ca63b Fix dragging preview image does nothing (#4009) 2025-05-29 04:50:04 +10:00
Terry Jia
405b5fc5b7 Add copy url button (#4000)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-28 17:55:57 +10:00
Comfy Org PR Bot
0eaf7d11b6 1.21.2 (#4003)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-28 17:09:41 +10:00
Robin Huang
fa58c04b3a [fix] Disable serialization for text preview widget (#4004) 2025-05-28 04:20:26 +00:00
Comfy Org PR Bot
9c84c9e250 [chore] Update litegraph to 0.15.14 (#3998)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-28 00:40:16 +00:00
Terry Jia
6f9f048b4a [3d] fix wrong hasRecording status (#3995) 2025-05-27 13:07:50 +00:00
filtered
768faeee7e [Test] Disable flaky test (#3994) 2025-05-27 21:03:49 +10:00
filtered
eba81efb4b [Test] Fix husky rejects all test file commits (#3993) 2025-05-27 20:50:15 +10:00
filtered
f9d92b8198 Fix native reroute chaining (#3989) 2025-05-27 16:57:36 +10:00
filtered
c4bbe7fee1 Update Claude rules: no @ts-expect-error (#3985) 2025-05-27 13:23:49 +10:00
Comfy Org PR Bot
8f4f5f8e5f [chore] Update litegraph to 0.15.13 (#3983)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 22:34:53 +00:00
Comfy Org PR Bot
9e137d9924 1.21.1 (#3982)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 08:31:56 +00:00
Comfy Org PR Bot
a084b55db7 [chore] Update litegraph to 0.15.12 (#3981)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-05-26 07:39:07 +00:00
filtered
835f318999 Report if Forgot Password? cannot be processed (#3979) 2025-05-26 11:10:05 +10:00
filtered
c35d44c491 [TS] Fix workflow store type assertions (#3978) 2025-05-26 05:39:30 +10:00
filtered
38d3e15103 Never restore view when setting is disabled (#3975) 2025-05-24 22:47:08 +10:00
42 changed files with 1494 additions and 227 deletions

View File

@@ -0,0 +1,43 @@
# Add Missing i18n Translations
## Task: Add English translations for all new localized strings
### Step 1: Identify new translation keys
Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions.
### Step 2: Add translations to English locale file
For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`.
### Key-to-JSON mapping rules:
- Translation keys use dot notation to represent nested JSON structure
- Convert dot notation to nested JSON objects when adding to the locale file
- Example: The key `g.user.name` maps to:
```json
{
"g": {
"user": {
"name": "User Name"
}
}
}
```
### Important notes:
1. **Only modify the English locale file** (`src/locales/en/main.json`)
2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow
3. **Exception for manual translations**: Only add translations to non-English locale files if:
- You have specific domain knowledge that would produce a more accurate translation than the automated system
- The automated translation would likely be incorrect due to technical terminology or context-specific meaning
### Example workflow:
1. If you added `t('settings.advanced.enable')` in a Vue component
2. Add to `src/locales/en/main.json`:
```json
{
"settings": {
"advanced": {
"enable": "Enable advanced settings"
}
}
}
```

View File

@@ -1,7 +1,9 @@
name: Bug Report
description: "Something is not behaving as expected."
title: "[Bug]: "
description: 'Something is not behaving as expected.'
title: '[Bug]: '
labels: ['Potential Bug']
type: Bug
body:
- type: markdown
attributes:

View File

@@ -1,7 +1,8 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature Request]: "
labels: ["enhancement"]
title: '[Feature Request]: '
labels: ['enhancement']
type: Feature
body:
- type: checkboxes

View File

@@ -1,4 +1,3 @@
- use npm run to see what commands are available
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
@@ -36,3 +35,4 @@
- Follow Vue 3 style guide and naming conventions
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -32,7 +32,9 @@ test.describe('Templates', () => {
}
})
test('should have all required thumbnail media for each template', async ({
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()

View File

@@ -0,0 +1,59 @@
import { Plugin } from 'vite'
/**
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
*
* This plugin addresses compatibility issues where some components or libraries
* might be using the older createElementVNode function name instead of createBaseVNode.
* It modifies the Vue vendor chunk during build to add the alias export.
*
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
*/
export function addElementVnodeExportPlugin(): Plugin {
return {
name: 'add-element-vnode-export-plugin',
renderChunk(code, chunk, _options) {
if (chunk.name.startsWith('vendor-vue')) {
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
const match = code.match(exportRegex)
if (match) {
const existingExports = match[2].trim()
const exportsArray = existingExports
.split(',')
.map((e) => e.trim())
.filter(Boolean)
const hasCreateBaseVNode = exportsArray.some((e) =>
e.startsWith('createBaseVNode')
)
const hasCreateElementVNode = exportsArray.some((e) =>
e.includes('createElementVNode')
)
if (hasCreateBaseVNode && !hasCreateElementVNode) {
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
const newCode = code.replace(exportRegex, newExportStatement)
console.log(
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
)
return { code: newCode, map: null }
} else if (!hasCreateBaseVNode) {
console.warn(
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
)
}
} else {
console.warn(
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
)
}
}
return null
}
}
}

View File

@@ -1,24 +1,9 @@
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
interface ImportMapSource {
interface VendorLibrary {
name: string
pattern: string | RegExp
entry: string
recursiveDependence?: boolean
override?: Record<string, Partial<ImportMapSource>>
}
const parseDeps = (root: string, pkg: string) => {
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
if (fs.existsSync(pkgPath)) {
const content = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(content)
return Object.keys(pkg.dependencies || {})
}
return []
pattern: RegExp
}
/**
@@ -38,89 +23,53 @@ const parseDeps = (root: string, pkg: string) => {
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
importMapSources: ImportMapSource[]
vendorLibraries: VendorLibrary[]
): Plugin {
const importMapEntries: Record<string, string> = {}
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
const assetDir = 'assets/lib'
let root: string
return {
name: 'generate-import-map-plugin',
// Configure manual chunks during the build process
configResolved(config) {
root = config.root
if (config.build) {
// Ensure rollupOptions exists
if (!config.build.rollupOptions) {
config.build.rollupOptions = {}
}
for (const source of importMapSources) {
resolvedImportMapSources.set(source.name, source)
if (source.recursiveDependence) {
const deps = parseDeps(root, source.name)
while (deps.length) {
const dep = deps.shift()!
const depSource = Object.assign({}, source, {
name: dep,
pattern: dep,
...source.override?.[dep]
})
resolvedImportMapSources.set(depSource.name, depSource)
const _deps = parseDeps(root, depSource.name)
deps.unshift(..._deps)
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
}
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
const external: (string | RegExp)[] = []
for (const [, source] of resolvedImportMapSources) {
external.push(source.pattern)
}
config.build.rollupOptions.external = external
config.build.rollupOptions.output = outputOptions
}
},
generateBundle(_options) {
for (const [, source] of resolvedImportMapSources) {
if (source.entry) {
const moduleFile = join(source.name, source.entry)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
generateBundle(_options, bundle) {
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && !chunk.isEntry) {
// Find matching vendor library by chunk name
const vendorLib = vendorLibraries.find(
(lib) => chunk.name === `vendor-${lib.name}`
)
importMapEntries[source.name] =
'./' + normalizePath(join(assetDir, moduleFile))
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
}
if (source.recursiveDependence) {
const files = glob.sync(['**/*.{js,mjs}'], {
cwd: join(root, 'node_modules', source.name)
})
for (const file of files) {
const moduleFile = join(source.name, file)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
'./' + normalizePath(join(assetDir, moduleFile))
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
)
}
}
}

View File

@@ -1,2 +1,3 @@
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

View File

@@ -24,7 +24,7 @@ export default [
},
parser: tseslint.parser,
parserOptions: {
project: './tsconfig.json',
project: ['./tsconfig.json', './tsconfig.eslint.json'],
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue']

12
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.21.0",
"version": "1.21.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.21.0",
"version": "1.21.7",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.11",
"@comfyorg/litegraph": "^0.15.15",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -788,9 +788,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.15.11",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.11.tgz",
"integrity": "sha512-gU8KK9cid7dXSK1yh3ReUolG0HGT3piKgKLd8YDr21PWl64pQvzy8BIh7W1vKH8ZWictKmNBaG9IRKlsJ667Zw==",
"version": "0.15.15",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.15.tgz",
"integrity": "sha512-otOKgTxNPV6gEa6PW1fHGMMF8twjnZkP0vWQhGsRISK4vN8tPfX8O9sC9Hnq3nV8axaMv4/Ff49+7mMVcFEKeA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.21.0",
"version": "1.21.7",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -74,7 +74,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.11",
"@comfyorg/litegraph": "^0.15.15",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -30,6 +30,15 @@
@click="download.triggerBrowserDownload"
/>
</div>
<div>
<Button
:label="$t('g.copyURL')"
size="small"
outlined
:disabled="!!props.error"
@click="copyURL"
/>
</div>
</div>
</template>
@@ -38,6 +47,7 @@ import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
@@ -49,9 +59,15 @@ const props = defineProps<{
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -0,0 +1,293 @@
import { Form } from '@primevue/forms'
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
sendPasswordResetEmail: vi.fn()
}))
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}
}))
}))
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoading = false
})
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
},
props,
...options
})
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
})
it('shows button when not loading', () => {
mockLoading = false
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
it('renders password input with correct attributes', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
})

View File

@@ -7,15 +7,12 @@
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
{{ t('auth.login.emailLabel') }}
</label>
<InputText
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
:id="emailInputId"
autocomplete="email"
class="h-10"
name="email"
type="text"
@@ -37,8 +34,11 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="text-muted text-base font-medium cursor-pointer"
@click="handleForgotPassword($form.email?.value)"
class="text-muted text-base font-medium cursor-pointer select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
>
{{ t('auth.login.forgotPassword') }}
</span>
@@ -77,6 +77,7 @@ import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -87,6 +88,7 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
const { t } = useI18n()
@@ -94,14 +96,34 @@ const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
const handleForgotPassword = async (email: string) => {
if (!email) return
const handleForgotPassword = async (
email: string,
isValid: boolean | undefined
) => {
if (!email || !isValid) {
toast.add({
severity: 'warn',
summary: t('auth.login.emailPlaceholder'),
life: 5_000
})
// Focus the email input
document.getElementById(emailInputId)?.focus?.()
return
}
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -150,8 +150,8 @@ const handleStopRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -294,8 +294,8 @@ const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value && load3DSceneRef.value?.load3d) {
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}

View File

@@ -168,8 +168,8 @@ const handleStopRecording = () => {
if (sceneRef?.load3d) {
sceneRef.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -197,8 +197,8 @@ const listenRecordingStatusChange = (value: boolean) => {
if (!value) {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
}

View File

@@ -99,8 +99,6 @@ const emit = defineEmits<{
}>()
const resizeNodeMatchOutput = () => {
console.log('resizeNodeMatchOutput')
const outputWidth = node.widgets?.find((w) => w.name === 'width')
const outputHeight = node.widgets?.find((w) => w.name === 'height')

View File

@@ -166,10 +166,11 @@ const showContextMenu = (e: CanvasPointerEvent) => {
showSearchBox(e)
}
}
const afterRerouteId = firstLink.fromReroute?.id
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot }
: { nodeTo: node, slotTo: fromSlot }
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
const canvas = canvasStore.getCanvas()
const menu = canvas.showConnectionMenu({

View File

@@ -13,13 +13,58 @@
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortOrder')"
class="sort-button"
:icon="alphabeticalSort ? 'pi pi-sort-alpha-down' : 'pi pi-sort-alt'"
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
:icon="selectedGroupingIcon"
text
severity="secondary"
@click="alphabeticalSort = !alphabeticalSort"
@click="groupingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
:icon="selectedSortingIcon"
text
severity="secondary"
@click="sortingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-refresh"
text
severity="secondary"
@click="resetOrganization"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in groupingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedGroupingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectGrouping(option.id)"
/>
</div>
</Popover>
<Popover ref="sortingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in sortingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedSortingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectSorting(option.id)"
/>
</div>
</Popover>
</template>
<template #header>
<SearchBox
@@ -62,6 +107,7 @@
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
@@ -76,16 +122,20 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
useNodeDefStore
} from '@/stores/nodeDefStore'
DEFAULT_GROUPING_ID,
DEFAULT_SORTING_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import type {
GroupingStrategyId,
SortingStrategyId
} from '@/types/nodeOrganizationTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { FuseFilterWithValue } from '@/utils/fuseUtil'
import { sortedTree } from '@/utils/treeUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
@@ -98,13 +148,67 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
> | null>(null)
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
const alphabeticalSort = ref(false)
const groupingPopover = ref<InstanceType<typeof Popover> | null>(null)
const sortingPopover = ref<InstanceType<typeof Popover> | null>(null)
const selectedGroupingId = useLocalStorage<GroupingStrategyId>(
'Comfy.NodeLibrary.GroupBy',
DEFAULT_GROUPING_ID
)
const selectedSortingId = useLocalStorage<SortingStrategyId>(
'Comfy.NodeLibrary.SortBy',
DEFAULT_SORTING_ID
)
const searchQuery = ref<string>('')
const groupingOptions = computed(() =>
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const selectedGroupingIcon = computed(() =>
nodeOrganizationService.getGroupingIcon(selectedGroupingId.value)
)
const selectedSortingIcon = computed(() =>
nodeOrganizationService.getSortingIcon(selectedSortingId.value)
)
const selectGrouping = (groupingId: string) => {
selectedGroupingId.value = groupingId as GroupingStrategyId
groupingPopover.value?.hide()
}
const selectSorting = (sortingId: string) => {
selectedSortingId.value = sortingId as SortingStrategyId
sortingPopover.value?.hide()
}
const resetOrganization = () => {
selectedGroupingId.value = DEFAULT_GROUPING_ID
selectedSortingId.value = DEFAULT_SORTING_ID
}
const root = computed(() => {
const root = filteredRoot.value || nodeDefStore.nodeTree
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
// Determine which nodes to use
const nodes =
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value
: nodeDefStore.visibleNodeDefs
// Use the service to organize nodes
return nodeOrganizationService.organizeNodes(nodes, {
groupBy: selectedGroupingId.value,
sortBy: selectedSortingId.value
})
})
const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
@@ -144,12 +248,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
})
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
const filteredRoot = computed<TreeNode | null>(() => {
if (!filteredNodeDefs.value.length) {
return null
}
return buildNodeDefTree(filteredNodeDefs.value)
})
const filters: Ref<
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
> = ref([])
@@ -175,8 +273,10 @@ const handleSearch = async (query: string) => {
)
await nextTick()
// @ts-expect-error fixme ts strict error
expandNode(filteredRoot.value)
// Expand the search results tree
if (filteredNodeDefs.value.length > 0) {
expandNode(root.value)
}
}
const onAddFilter = async (

View File

@@ -50,9 +50,11 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
// Mock the useFirebaseAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined)
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
@@ -100,8 +102,7 @@ describe('CurrentUserPopover', () => {
global: {
plugins: [i18n],
stubs: {
Divider: true,
Button: true
Divider: true
}
}
})
@@ -114,6 +115,18 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('renders logout button with correct props', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
@@ -132,12 +145,30 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Click the logout button
await logoutButton.trigger('click')
// Verify logout was called
expect(mockLogout).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the API pricing button (second one)
// Find all buttons and get the API pricing button (third one now)
const buttons = wrapper.findAllComponents(Button)
const apiPricingButton = buttons[1]
const apiPricingButton = buttons[2]
// Click the API pricing button
await apiPricingButton.trigger('click')

View File

@@ -37,6 +37,18 @@
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
@click="handleLogout"
/>
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
@@ -90,6 +102,11 @@ const handleTopUp = () => {
emit('close')
}
const handleLogout = async () => {
await authActions.logout()
emit('close')
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')

View File

@@ -1,11 +1,16 @@
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import type {
IBaseWidget,
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
@@ -235,34 +240,61 @@ const renderPreview = (
}
}
class ImagePreviewWidget implements ICustomWidget {
readonly type: 'custom'
readonly name: string
readonly options: IWidgetOptions<string | object>
/** Dummy value to satisfy type requirements. */
value: string
y: number = 0
/** Don't serialize the widget value. */
serialize: boolean = false
constructor(name: string, options: IWidgetOptions<string | object>) {
this.type = 'custom'
this.name = name
this.options = options
this.value = ''
}
draw(
ctx: CanvasRenderingContext2D,
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
_width: number,
y: number,
_height: number
): void {
renderPreview(ctx, node, y)
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
computeLayoutSize(this: IBaseWidget) {
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
@@ -276,7 +308,7 @@ export const useImagePreviewWidget = () => {
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(inputSpec.name, {
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
)

View File

@@ -28,7 +28,8 @@ export const useTextPreviewWidget = (
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
}
})
addWidget(node, widget)

View File

@@ -121,7 +121,8 @@
"edit": "Edit",
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear"
"clear": "Clear",
"copyURL": "Copy URL"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -415,7 +416,23 @@
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"sortOrder": "Sort Order"
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
"groupStrategies": {
"category": "Category",
"categoryDesc": "Group by node category",
"module": "Module",
"moduleDesc": "Group by module source",
"source": "Source",
"sourceDesc": "Group by source type (Core, Custom, API)"
},
"sortBy": {
"original": "Original",
"originalDesc": "Keep original order",
"alphabetical": "Alphabetical",
"alphabeticalDesc": "Sort alphabetically within groups"
}
},
"modelLibrary": "Model Library",
"downloads": "Downloads",

View File

@@ -268,6 +268,7 @@
"control_before_generate": "control antes de generar",
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"copyURL": "Copiar URL",
"currentUser": "Usuario actual",
"customBackground": "Fondo personalizado",
"customize": "Personalizar",
@@ -1064,7 +1065,23 @@
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
"nodeLibrary": "Biblioteca de nodos",
"nodeLibraryTab": {
"sortOrder": "Orden de clasificación"
"groupBy": "Agrupar por",
"groupStrategies": {
"category": "Categoría",
"categoryDesc": "Agrupar por categoría de nodo",
"module": "Módulo",
"moduleDesc": "Agrupar por fuente del módulo",
"source": "Fuente",
"sourceDesc": "Agrupar por tipo de fuente (Core, Custom, API)"
},
"resetView": "Restablecer vista a la predeterminada",
"sortBy": {
"alphabetical": "Alfabético",
"alphabeticalDesc": "Ordenar alfabéticamente dentro de los grupos",
"original": "Original",
"originalDesc": "Mantener el orden original"
},
"sortMode": "Modo de ordenación"
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"queue": "Cola",

View File

@@ -268,6 +268,7 @@
"control_before_generate": "contrôle avant génération",
"copy": "Copier",
"copyToClipboard": "Copier dans le presse-papiers",
"copyURL": "Copier lURL",
"currentUser": "Utilisateur actuel",
"customBackground": "Arrière-plan personnalisé",
"customize": "Personnaliser",
@@ -1064,7 +1065,23 @@
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
"nodeLibrary": "Bibliothèque de nœuds",
"nodeLibraryTab": {
"sortOrder": "Ordre de tri"
"groupBy": "Grouper par",
"groupStrategies": {
"category": "Catégorie",
"categoryDesc": "Grouper par catégorie de nœud",
"module": "Module",
"moduleDesc": "Grouper par source du module",
"source": "Source",
"sourceDesc": "Grouper par type de source (Core, Custom, API)"
},
"resetView": "Réinitialiser la vue par défaut",
"sortBy": {
"alphabetical": "Alphabétique",
"alphabeticalDesc": "Trier alphabétiquement dans les groupes",
"original": "Original",
"originalDesc": "Conserver l'ordre d'origine"
},
"sortMode": "Mode de tri"
},
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
"queue": "File d'attente",

View File

@@ -268,6 +268,7 @@
"control_before_generate": "生成前の制御",
"copy": "コピー",
"copyToClipboard": "クリップボードにコピー",
"copyURL": "URLをコピー",
"currentUser": "現在のユーザー",
"customBackground": "カスタム背景",
"customize": "カスタマイズ",
@@ -1064,7 +1065,23 @@
"newBlankWorkflow": "新しい空のワークフローを作成",
"nodeLibrary": "ノードライブラリ",
"nodeLibraryTab": {
"sortOrder": "並び順"
"groupBy": "グループ化",
"groupStrategies": {
"category": "カテゴリ",
"categoryDesc": "ノードカテゴリでグループ化",
"module": "モジュール",
"moduleDesc": "モジュールソースでグループ化",
"source": "ソース",
"sourceDesc": "ソースタイプCore、Custom、APIでグループ化"
},
"resetView": "ビューをデフォルトにリセット",
"sortBy": {
"alphabetical": "アルファベット順",
"alphabeticalDesc": "グループ内でアルファベット順に並び替え",
"original": "元の順序",
"originalDesc": "元の順序を維持"
},
"sortMode": "並び替えモード"
},
"openWorkflow": "ローカルでワークフローを開く",
"queue": "キュー",

View File

@@ -268,6 +268,7 @@
"control_before_generate": "생성 전 제어",
"copy": "복사",
"copyToClipboard": "클립보드에 복사",
"copyURL": "URL 복사",
"currentUser": "현재 사용자",
"customBackground": "맞춤 배경",
"customize": "사용자 정의",
@@ -1064,7 +1065,23 @@
"newBlankWorkflow": "새 빈 워크플로 만들기",
"nodeLibrary": "노드 라이브러리",
"nodeLibraryTab": {
"sortOrder": "정렬 순서"
"groupBy": "그룹 기준",
"groupStrategies": {
"category": "카테고리",
"categoryDesc": "노드 카테고리별로 그룹화",
"module": "모듈",
"moduleDesc": "모듈 소스별로 그룹화",
"source": "소스",
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
},
"resetView": "기본 보기로 재설정",
"sortBy": {
"alphabetical": "알파벳순",
"alphabeticalDesc": "그룹 내에서 알파벳순으로 정렬",
"original": "원본 순서",
"originalDesc": "원래 순서를 유지"
},
"sortMode": "정렬 방식"
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 대기열",

View File

@@ -268,6 +268,7 @@
"control_before_generate": "управление до генерации",
"copy": "Копировать",
"copyToClipboard": "Скопировать в буфер обмена",
"copyURL": "Скопировать URL",
"currentUser": "Текущий пользователь",
"customBackground": "Пользовательский фон",
"customize": "Настроить",
@@ -1064,7 +1065,23 @@
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
"nodeLibrary": "Библиотека нод",
"nodeLibraryTab": {
"sortOrder": "Порядок сортировки"
"groupBy": "Группировать по",
"groupStrategies": {
"category": "Категория",
"categoryDesc": "Группировать по категории узла",
"module": "Модуль",
"moduleDesc": "Группировать по источнику модуля",
"source": "Источник",
"sourceDesc": "Группировать по типу источника (Core, Custom, API)"
},
"resetView": "Сбросить вид по умолчанию",
"sortBy": {
"alphabetical": "По алфавиту",
"alphabeticalDesc": "Сортировать по алфавиту внутри групп",
"original": "Оригинальный порядок",
"originalDesc": "Сохранять исходный порядок"
},
"sortMode": "Режим сортировки"
},
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
"queue": "Очередь",

View File

@@ -268,6 +268,7 @@
"control_before_generate": "生成前控制",
"copy": "复制",
"copyToClipboard": "复制到剪贴板",
"copyURL": "复制链接",
"currentUser": "当前用户",
"customBackground": "自定义背景",
"customize": "自定义",
@@ -1064,7 +1065,23 @@
"newBlankWorkflow": "创建空白工作流",
"nodeLibrary": "节点库",
"nodeLibraryTab": {
"sortOrder": "排序顺序"
"groupBy": "分组方式",
"groupStrategies": {
"category": "类别",
"categoryDesc": "按节点类别分组",
"module": "模块",
"moduleDesc": "按模块来源分组",
"source": "来源",
"sourceDesc": "按来源类型分组核心自定义API"
},
"resetView": "重置视图为默认",
"sortBy": {
"alphabetical": "字母顺序",
"alphabeticalDesc": "在分组内按字母顺序排序",
"original": "原始顺序",
"originalDesc": "保持原始顺序"
},
"sortMode": "排序模式"
},
"openWorkflow": "在本地文件系统中打开工作流",
"queue": "队列",

View File

@@ -1074,11 +1074,11 @@ export class ComfyApp {
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (restore_view) {
if (
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
) {
if (graphData.extra?.ds) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {

View File

@@ -32,7 +32,7 @@ export class ChangeTracker {
/**
* Whether the redo/undo restoring is in progress.
*/
private restoringState: boolean = false
_restoringState: boolean = false
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
@@ -55,7 +55,7 @@ export class ChangeTracker {
*/
reset(state?: ComfyWorkflowJSON) {
// Do not reset the state if we are restoring.
if (this.restoringState) return
if (this._restoringState) return
logger.debug('Reset State')
if (state) this.activeState = clone(state)
@@ -124,7 +124,7 @@ export class ChangeTracker {
const prevState = source.pop()
if (prevState) {
target.push(this.activeState)
this.restoringState = true
this._restoringState = true
try {
await app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false,
@@ -134,7 +134,7 @@ export class ChangeTracker {
this.activeState = prevState
this.updateModified()
} finally {
this.restoringState = false
this._restoringState = false
}
}
}

View File

@@ -379,6 +379,24 @@ export const useDialogService = () => {
})
}
/**
* Shows a dialog from a third party extension.
* @param options - The dialog options.
* @param options.key - The dialog key.
* @param options.title - The dialog title.
* @param options.headerComponent - The dialog header component.
* @param options.footerComponent - The dialog footer component.
* @param options.component - The dialog component.
* @param options.props - The dialog props.
* @returns The dialog instance and a function to close the dialog.
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
return {
dialog: dialogStore.showExtensionDialog(options),
closeDialog: () => dialogStore.closeDialog({ key: options.key })
}
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
@@ -394,6 +412,7 @@ export const useDialogService = () => {
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
confirm
}

View File

@@ -0,0 +1,159 @@
import { ComfyNodeDefImpl, buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
NodeGroupingStrategy,
NodeOrganizationOptions,
NodeSortStrategy
} from '@/types/nodeOrganizationTypes'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export class NodeOrganizationService {
private readonly groupingStrategies: NodeGroupingStrategy[] = [
{
id: 'category',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.category',
icon: 'pi pi-folder',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.categoryDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
},
{
id: 'module',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.module',
icon: 'pi pi-box',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.moduleDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const pythonModule = nodeDef.python_module || ''
if (!pythonModule) {
return ['unknown_module', nodeDef.name]
}
// Split the module path into components
const parts = pythonModule.split('.')
// Remove common prefixes and organize
if (parts[0] === 'nodes') {
// Core nodes - just use 'core'
return ['core', nodeDef.name]
} else if (parts[0] === 'custom_nodes') {
// Custom nodes - use the package name as the folder
if (parts.length > 1) {
// Return the custom node package name
return [parts[1], nodeDef.name]
}
return ['custom_nodes', nodeDef.name]
}
// For other modules, use the full path structure plus node name
return [...parts, nodeDef.name]
}
},
{
id: 'source',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.source',
icon: 'pi pi-server',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.sourceDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
if (nodeDef.api_node) {
return ['API nodes', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.Core) {
return ['Core', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.CustomNodes) {
return ['Custom nodes', nodeDef.name]
} else {
return ['Unknown', nodeDef.name]
}
}
}
]
private readonly sortingStrategies: NodeSortStrategy[] = [
{
id: 'original',
label: 'sideToolbar.nodeLibraryTab.sortBy.original',
icon: 'pi pi-sort-alt',
description: 'sideToolbar.nodeLibraryTab.sortBy.originalDesc',
compare: () => 0
},
{
id: 'alphabetical',
label: 'sideToolbar.nodeLibraryTab.sortBy.alphabetical',
icon: 'pi pi-sort-alpha-down',
description: 'sideToolbar.nodeLibraryTab.sortBy.alphabeticalDesc',
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) =>
(a.display_name ?? '').localeCompare(b.display_name ?? '')
}
]
getGroupingStrategies(): NodeGroupingStrategy[] {
return [...this.groupingStrategies]
}
getGroupingStrategy(id: string): NodeGroupingStrategy | undefined {
return this.groupingStrategies.find((strategy) => strategy.id === id)
}
getSortingStrategies(): NodeSortStrategy[] {
return [...this.sortingStrategies]
}
getSortingStrategy(id: string): NodeSortStrategy | undefined {
return this.sortingStrategies.find((strategy) => strategy.id === id)
}
organizeNodes(
nodes: ComfyNodeDefImpl[],
options: NodeOrganizationOptions = {}
): TreeNode {
const { groupBy = DEFAULT_GROUPING_ID, sortBy = DEFAULT_SORTING_ID } =
options
const groupingStrategy = this.getGroupingStrategy(groupBy)
const sortingStrategy = this.getSortingStrategy(sortBy)
if (!groupingStrategy) {
throw new Error(`Unknown grouping strategy: ${groupBy}`)
}
if (!sortingStrategy) {
throw new Error(`Unknown sorting strategy: ${sortBy}`)
}
const sortedNodes =
sortingStrategy.id !== 'original'
? [...nodes].sort(sortingStrategy.compare)
: nodes
const tree = buildNodeDefTree(sortedNodes, {
pathExtractor: groupingStrategy.getNodePath
})
if (sortBy === 'alphabetical') {
return sortedTree(tree, { groupLeaf: true })
}
return tree
}
getGroupingIcon(groupingId: string): string {
const strategy = this.getGroupingStrategy(groupingId)
return strategy?.icon || DEFAULT_ICON
}
getSortingIcon(sortingId: string): string {
const strategy = this.getSortingStrategy(sortingId)
return strategy?.icon || DEFAULT_ICON
}
}
export const nodeOrganizationService = new NodeOrganizationService()

View File

@@ -147,10 +147,33 @@ export const useDialogStore = defineStore('dialog', () => {
return dialog
}
/**
* Shows a dialog from a third party extension.
* Explicitly keys extension dialogs with `extension-` prefix,
* to avoid conflicts & prevent use of internal dialogs (available via `dialogService`).
*/
function showExtensionDialog(options: ShowDialogOptions & { key: string }) {
const { key } = options
if (!key) {
console.error('Extension dialog key is required')
return
}
const extKey = key.startsWith('extension-') ? key : `extension-${key}`
const dialog = dialogStack.value.find((d) => d.key === extKey)
if (!dialog) return createDialog({ ...options, key: extKey })
dialog.visible = true
riseDialog(dialog)
return dialog
}
return {
dialogStack,
riseDialog,
showDialog,
closeDialog
closeDialog,
showExtensionDialog
}
})

View File

@@ -216,10 +216,22 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
}
}
export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
export interface BuildNodeDefTreeOptions {
/**
* Custom function to extract the tree path from a node definition.
* If not provided, uses the default path based on nodeDef.nodePath.
*/
pathExtractor?: (nodeDef: ComfyNodeDefImpl) => string[]
}
export function buildNodeDefTree(
nodeDefs: ComfyNodeDefImpl[],
options: BuildNodeDefTreeOptions = {}
): TreeNode {
const { pathExtractor } = options
const defaultPathExtractor = (nodeDef: ComfyNodeDefImpl) =>
nodeDef.nodePath.split('/')
)
return buildTree(nodeDefs, pathExtractor || defaultPathExtractor)
}
export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {

View File

@@ -23,7 +23,7 @@ export class ComfyWorkflow extends UserFile {
/**
* Whether the workflow has been modified comparing to the initial state.
*/
private _isModified: boolean = false
_isModified: boolean = false
/**
* @param options The path, modified, and size of the workflow.
@@ -131,7 +131,7 @@ export interface WorkflowStore {
activeWorkflow: LoadedComfyWorkflow | null
isActive: (workflow: ComfyWorkflow) => boolean
openWorkflows: ComfyWorkflow[]
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
openWorkflowsInBackground: (paths: {
left?: string[]
@@ -477,7 +477,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
isSubgraphActive,
updateActiveGraph
}
}) as () => WorkflowStore
}) satisfies () => WorkflowStore
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
const bookmarks = ref<Set<string>>(new Set())

View File

@@ -0,0 +1,44 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export type GroupingStrategyId = 'category' | 'module' | 'source'
export type SortingStrategyId = 'original' | 'alphabetical'
/**
* Strategy for grouping nodes into tree structure
*/
export interface NodeGroupingStrategy {
/** Unique identifier for the grouping strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the grouping option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Function to extract the tree path from a node definition */
getNodePath: (nodeDef: ComfyNodeDefImpl) => string[]
}
/**
* Strategy for sorting nodes within groups
*/
export interface NodeSortStrategy {
/** Unique identifier for the sort strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the sort option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Compare function for sorting nodes within the same group */
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) => number
}
/**
* Options for organizing nodes
*/
export interface NodeOrganizationOptions {
groupBy?: string
sortBy?: string
}

View File

@@ -0,0 +1,330 @@
import { describe, expect, it } from 'vitest'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
describe('nodeOrganizationService', () => {
const createMockNodeDef = (overrides: any = {}) => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test/subcategory',
python_module: 'custom_nodes.MyPackage.nodes',
api_node: false,
nodeSource: {
type: NodeSourceType.CustomNodes,
className: 'comfy-custom',
displayText: 'Custom',
badgeText: 'C'
},
...overrides
}
Object.setPrototypeOf(mockNodeDef, ComfyNodeDefImpl.prototype)
return mockNodeDef as ComfyNodeDefImpl
}
describe('getGroupingStrategies', () => {
it('should return all grouping strategies', () => {
const strategies = nodeOrganizationService.getGroupingStrategies()
expect(strategies).toHaveLength(3)
expect(strategies.map((s) => s.id)).toEqual([
'category',
'module',
'source'
])
})
it('should return immutable copy', () => {
const strategies1 = nodeOrganizationService.getGroupingStrategies()
const strategies2 = nodeOrganizationService.getGroupingStrategies()
expect(strategies1).not.toBe(strategies2)
expect(strategies1).toEqual(strategies2)
})
})
describe('getGroupingStrategy', () => {
it('should return strategy by id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('category')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('getSortingStrategies', () => {
it('should return all sorting strategies', () => {
const strategies = nodeOrganizationService.getSortingStrategies()
expect(strategies).toHaveLength(2)
expect(strategies.map((s) => s.id)).toEqual(['original', 'alphabetical'])
})
})
describe('getSortingStrategy', () => {
it('should return strategy by id', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('alphabetical')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getSortingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('organizeNodes', () => {
const mockNodes = [
createMockNodeDef({ name: 'NodeA', display_name: 'Zebra Node' }),
createMockNodeDef({ name: 'NodeB', display_name: 'Apple Node' })
]
it('should organize nodes with default options', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes)
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom grouping', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'module'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom sorting', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'alphabetical'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should throw error for unknown grouping strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'unknown'
})
}).toThrow('Unknown grouping strategy: unknown')
})
it('should throw error for unknown sorting strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'unknown'
})
}).toThrow('Unknown sorting strategy: unknown')
})
})
describe('getGroupingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getGroupingIcon('category')
expect(icon).toBe('pi pi-folder')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getGroupingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('getSortingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getSortingIcon('alphabetical')
expect(icon).toBe('pi pi-sort-alpha-down')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getSortingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('grouping path extraction', () => {
const mockNodeDef = createMockNodeDef()
it('category grouping should use category path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['test', 'subcategory', 'TestNode'])
})
it('module grouping should extract module path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['MyPackage', 'TestNode'])
})
it('source grouping should categorize by source type', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['Custom nodes', 'TestNode'])
})
})
describe('edge cases', () => {
describe('module grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
it('should handle empty python_module', () => {
const nodeDef = createMockNodeDef({ python_module: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle undefined python_module', () => {
const nodeDef = createMockNodeDef({ python_module: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle modules with spaces in the name', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.My Package With Spaces.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['My Package With Spaces', 'TestNode'])
})
it('should handle modules with special characters', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.my-package_v2.0.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['my-package_v2', 'TestNode'])
})
it('should handle deeply nested modules', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.package.subpackage.module.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['package', 'TestNode'])
})
it('should handle core nodes module path', () => {
const nodeDef = createMockNodeDef({ python_module: 'nodes' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['core', 'TestNode'])
})
it('should handle non-standard module paths', () => {
const nodeDef = createMockNodeDef({
python_module: 'some.other.module.path'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['some', 'other', 'module', 'path', 'TestNode'])
})
})
describe('category grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
it('should handle empty category', () => {
const nodeDef = createMockNodeDef({ category: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle undefined category', () => {
const nodeDef = createMockNodeDef({ category: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle category with trailing slash', () => {
const nodeDef = createMockNodeDef({ category: 'test/subcategory/' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', '', 'TestNode'])
})
it('should handle category with multiple consecutive slashes', () => {
const nodeDef = createMockNodeDef({ category: 'test//subcategory' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', '', 'subcategory', 'TestNode'])
})
})
describe('source grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
it('should handle API nodes', () => {
const nodeDef = createMockNodeDef({
api_node: true,
nodeSource: {
type: NodeSourceType.Core,
className: 'comfy-core',
displayText: 'Core',
badgeText: 'C'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['API nodes', 'TestNode'])
})
it('should handle unknown source type', () => {
const nodeDef = createMockNodeDef({
nodeSource: {
type: 'unknown' as any,
className: 'unknown',
displayText: 'Unknown',
badgeText: '?'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['Unknown', 'TestNode'])
})
})
describe('node name edge cases', () => {
it('should handle nodes with special characters in name', () => {
const nodeDef = createMockNodeDef({
name: 'Test/Node:With*Special<Chars>'
})
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual([
'test',
'subcategory',
'Test/Node:With*Special<Chars>'
])
})
it('should handle nodes with very long names', () => {
const longName = 'A'.repeat(100)
const nodeDef = createMockNodeDef({ name: longName })
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', longName])
})
})
})
describe('sorting comparison', () => {
it('original sort should keep order', () => {
const strategy = nodeOrganizationService.getSortingStrategy('original')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
})
it('alphabetical sort should compare display names', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
})
})
})

18
tsconfig.eslint.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
/* Test files should not be compiled */
"noEmit": true,
// "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"*.ts",
"*.mts",
"*.config.js",
"browser_tests/**/*.ts",
"tests-ui/**/*.ts"
]
}

View File

@@ -8,7 +8,11 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import type { UserConfigExport } from 'vitest/config'
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
import {
addElementVnodeExportPlugin,
comfyAPIPlugin,
generateImportMapPlugin
} from './build/plugins'
dotenv.config()
@@ -73,40 +77,11 @@ export default defineConfig({
: [vue()]),
comfyAPIPlugin(IS_DEV),
generateImportMapPlugin([
{
name: 'vue',
pattern: 'vue',
entry: './dist/vue.esm-browser.prod.js'
},
{
name: 'vue-i18n',
pattern: 'vue-i18n',
entry: './dist/vue-i18n.esm-browser.prod.js'
},
{
name: 'primevue',
pattern: /^primevue\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/themes',
pattern: /^@primevue\/themes\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/forms',
pattern: /^@primevue\/forms\/?.*/,
entry: './index.mjs',
recursiveDependence: true,
override: {
'@primeuix/forms': {
entry: ''
}
}
}
{ name: 'vue', pattern: /[\\/]node_modules[\\/]vue[\\/]/ },
{ name: 'primevue', pattern: /[\\/]node_modules[\\/]primevue[\\/]/ },
{ name: 'vue-i18n', pattern: /[\\/]node_modules[\\/]vue-i18n[\\/]/ }
]),
addElementVnodeExportPlugin(),
Icons({
compiler: 'vue3'