mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-15 18:07:35 +00:00
Compare commits
5 Commits
conditiona
...
queue_text
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33dc74fa2a | ||
|
|
bd37da7161 | ||
|
|
0b8d98e1e7 | ||
|
|
25a6b9f393 | ||
|
|
bdf32790c9 |
@@ -1,3 +1,4 @@
|
||||
|
||||
- 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
|
||||
@@ -35,4 +36,3 @@
|
||||
- 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: 57 KiB After Width: | Height: | Size: 80 KiB |
@@ -32,9 +32,7 @@ test.describe('Templates', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 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 ({
|
||||
test('should have all required thumbnail media for each template', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
59
build/plugins/addElementVnodeExportPlugin.ts
Normal file
59
build/plugins/addElementVnodeExportPlugin.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
@@ -24,7 +24,7 @@ export default [
|
||||
},
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json', './tsconfig.eslint.json'],
|
||||
project: './tsconfig.json',
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions: ['.vue']
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.21.2",
|
||||
"version": "1.21.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.21.2",
|
||||
"version": "1.21.0",
|
||||
"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.14",
|
||||
"@comfyorg/litegraph": "^0.15.11",
|
||||
"@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.14",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.14.tgz",
|
||||
"integrity": "sha512-9yERUwRVFPFspXowyg5z97QyF6+UbHG6ZNygvxSOisTCVSPOUeX/E02xcnhB5BHk0bTZCJGg9v2iztXBE5brnA==",
|
||||
"version": "0.15.11",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.11.tgz",
|
||||
"integrity": "sha512-gU8KK9cid7dXSK1yh3ReUolG0HGT3piKgKLd8YDr21PWl64pQvzy8BIh7W1vKH8ZWictKmNBaG9IRKlsJ667Zw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.21.2",
|
||||
"version": "1.21.0",
|
||||
"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.14",
|
||||
"@comfyorg/litegraph": "^0.15.11",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -30,15 +30,6 @@
|
||||
@click="download.triggerBrowserDownload"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
:label="$t('g.copyURL')"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
@click="copyURL"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +38,6 @@ 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'
|
||||
|
||||
@@ -59,15 +49,9 @@ 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>
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -7,12 +7,15 @@
|
||||
>
|
||||
<!-- Email Field -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
|
||||
<label
|
||||
class="opacity-80 text-base font-medium mb-2"
|
||||
for="comfy-org-sign-in-email"
|
||||
>
|
||||
{{ t('auth.login.emailLabel') }}
|
||||
</label>
|
||||
<InputText
|
||||
:id="emailInputId"
|
||||
autocomplete="email"
|
||||
pt:root:id="comfy-org-sign-in-email"
|
||||
pt:root:autocomplete="email"
|
||||
class="h-10"
|
||||
name="email"
|
||||
type="text"
|
||||
@@ -34,11 +37,8 @@
|
||||
{{ t('auth.login.passwordLabel') }}
|
||||
</label>
|
||||
<span
|
||||
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)"
|
||||
class="text-muted text-base font-medium cursor-pointer"
|
||||
@click="handleForgotPassword($form.email?.value)"
|
||||
>
|
||||
{{ t('auth.login.forgotPassword') }}
|
||||
</span>
|
||||
@@ -77,7 +77,6 @@ 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'
|
||||
|
||||
@@ -88,7 +87,6 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
|
||||
const authStore = useFirebaseAuthStore()
|
||||
const firebaseAuthActions = useFirebaseAuthActions()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const toast = useToast()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -96,34 +94,14 @@ 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,
|
||||
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
|
||||
}
|
||||
const handleForgotPassword = async (email: string) => {
|
||||
if (!email) return
|
||||
await firebaseAuthActions.sendPasswordReset(email)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-link-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ 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')
|
||||
|
||||
|
||||
@@ -166,11 +166,10 @@ const showContextMenu = (e: CanvasPointerEvent) => {
|
||||
showSearchBox(e)
|
||||
}
|
||||
}
|
||||
const afterRerouteId = firstLink.fromReroute?.id
|
||||
const connectionOptions =
|
||||
toType === 'input'
|
||||
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
|
||||
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
|
||||
? { nodeFrom: node, slotFrom: fromSlot }
|
||||
: { nodeTo: node, slotTo: fromSlot }
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
const menu = canvas.showConnectionMenu({
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
/>
|
||||
<ResultVideo v-else-if="item.isVideo" :result="item" />
|
||||
<ResultAudio v-else-if="item.isAudio" :result="item" />
|
||||
<ResultText v-else-if="item.isText" :result="item" />
|
||||
</template>
|
||||
</Galleria>
|
||||
</template>
|
||||
@@ -48,12 +49,13 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultText from './ResultText.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const galleryVisible = ref(false)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:activeIndex', value: number): void
|
||||
'update:activeIndex': [number]
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
/>
|
||||
<ResultVideo v-else-if="result.isVideo" :result="result" />
|
||||
<ResultAudio v-else-if="result.isAudio" :result="result" />
|
||||
<ResultText v-else-if="result.isText" :result="result" />
|
||||
<div v-else class="task-result-preview">
|
||||
<i class="pi pi-file" />
|
||||
<span>{{ result.mediaType }}</span>
|
||||
@@ -28,6 +29,7 @@ import { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import ResultAudio from './ResultAudio.vue'
|
||||
import ResultText from './ResultText.vue'
|
||||
import ResultVideo from './ResultVideo.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
81
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
81
src/components/sidebar/tabs/queue/ResultText.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="result-text-container">
|
||||
<div class="text-content">
|
||||
{{ result.text }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="copy-button"
|
||||
icon="pi pi-copy"
|
||||
text
|
||||
@click.stop="copyToClipboard(result.text ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-text-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color);
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
max-width: 80vw;
|
||||
overflow-y: auto;
|
||||
line-height: 1.5;
|
||||
padding-right: 0.5rem;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.text-content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
color: var(--text-color-secondary);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--surface-ground);
|
||||
}
|
||||
|
||||
.result-text-container:hover .copy-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
</style>
|
||||
@@ -28,8 +28,7 @@ export const useTextPreviewWidget = (
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
serialize: false
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
|
||||
@@ -266,7 +266,7 @@ useExtensionService().registerExtension({
|
||||
LOAD_3D_ANIMATION(node) {
|
||||
const fileInput = document.createElement('input')
|
||||
fileInput.type = 'file'
|
||||
fileInput.accept = '.gltf,.glb,.fbx'
|
||||
fileInput.accept = '.fbx,glb,gltf'
|
||||
fileInput.style.display = 'none'
|
||||
fileInput.onchange = async () => {
|
||||
if (fileInput.files?.length) {
|
||||
@@ -452,43 +452,31 @@ useExtensionService().registerExtension({
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
node.onExecuted = function (message: any) {
|
||||
onExecuted?.apply(this, arguments as any)
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
let filePath = message.result[0]
|
||||
|
||||
if (modelWidget) {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
}
|
||||
|
||||
if (lastTimeModelFile) {
|
||||
modelWidget.value = lastTimeModelFile
|
||||
let cameraState = message.result[1]
|
||||
|
||||
const cameraState = node.properties['Camera Info']
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
}
|
||||
|
||||
node.onExecuted = function (message: any) {
|
||||
onExecuted?.apply(this, arguments as any)
|
||||
|
||||
let filePath = message.result[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
}
|
||||
|
||||
let cameraState = message.result[1]
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
|
||||
if (modelWidget) {
|
||||
modelWidget.value = filePath.replaceAll('\\', '/')
|
||||
|
||||
node.properties['Last Time Model File'] = modelWidget.value
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -538,42 +526,29 @@ useExtensionService().registerExtension({
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
node.onExecuted = function (message: any) {
|
||||
onExecuted?.apply(this, arguments as any)
|
||||
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
let filePath = message.result[0]
|
||||
|
||||
if (modelWidget) {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
}
|
||||
|
||||
if (lastTimeModelFile) {
|
||||
modelWidget.value = lastTimeModelFile
|
||||
|
||||
const cameraState = node.properties['Camera Info']
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
}
|
||||
|
||||
node.onExecuted = function (message: any) {
|
||||
onExecuted?.apply(this, arguments as any)
|
||||
|
||||
let filePath = message.result[0]
|
||||
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
}
|
||||
|
||||
let cameraState = message.result[1]
|
||||
let cameraState = message.result[1]
|
||||
|
||||
useLoad3dService().waitForLoad3d(node, (load3d) => {
|
||||
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
|
||||
if (modelWidget) {
|
||||
modelWidget.value = filePath.replaceAll('\\', '/')
|
||||
|
||||
node.properties['Last Time Model File'] = modelWidget.value
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
|
||||
config.configure('output', modelWidget, cameraState)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -132,14 +132,6 @@ export class LoaderManager implements LoaderManagerInterface {
|
||||
if (this.modelManager.materialMode === 'original') {
|
||||
const mtlUrl = url.replace(/(filename=.*?)\.obj/, '$1.mtl')
|
||||
|
||||
const subfolderMatch = url.match(/[?&]subfolder=([^&]*)/)
|
||||
|
||||
const subfolder = subfolderMatch
|
||||
? decodeURIComponent(subfolderMatch[1])
|
||||
: '3d'
|
||||
|
||||
this.mtlLoader.setSubfolder(subfolder)
|
||||
|
||||
try {
|
||||
const materials = await this.mtlLoader.loadAsync(mtlUrl)
|
||||
materials.preload()
|
||||
|
||||
@@ -38,10 +38,6 @@ class OverrideMTLLoader extends Loader {
|
||||
this.loadRootFolder = loadRootFolder
|
||||
}
|
||||
|
||||
setSubfolder(subfolder) {
|
||||
this.subfolder = subfolder
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts loading from the given URL and passes the loaded MTL asset
|
||||
* to the `onLoad()` callback.
|
||||
@@ -139,8 +135,7 @@ class OverrideMTLLoader extends Loader {
|
||||
const materialCreator = new OverrideMaterialCreator(
|
||||
this.resourcePath || path,
|
||||
this.materialOptions,
|
||||
this.loadRootFolder,
|
||||
this.subfolder
|
||||
this.loadRootFolder
|
||||
)
|
||||
materialCreator.setCrossOrigin(this.crossOrigin)
|
||||
materialCreator.setManager(this.manager)
|
||||
@@ -160,7 +155,7 @@ class OverrideMTLLoader extends Loader {
|
||||
*/
|
||||
|
||||
class OverrideMaterialCreator {
|
||||
constructor(baseUrl = '', options = {}, loadRootFolder, subfolder) {
|
||||
constructor(baseUrl = '', options = {}, loadRootFolder) {
|
||||
this.baseUrl = baseUrl
|
||||
this.options = options
|
||||
this.materialsInfo = {}
|
||||
@@ -169,7 +164,6 @@ class OverrideMaterialCreator {
|
||||
this.nameLookup = {}
|
||||
|
||||
this.loadRootFolder = loadRootFolder
|
||||
this.subfolder = subfolder
|
||||
|
||||
this.crossOrigin = 'anonymous'
|
||||
|
||||
@@ -289,25 +283,16 @@ class OverrideMaterialCreator {
|
||||
/**
|
||||
* Override for ComfyUI api url
|
||||
*/
|
||||
function resolveURL(baseUrl, url, loadRootFolder, subfolder) {
|
||||
function resolveURL(baseUrl, url, loadRootFolder) {
|
||||
if (typeof url !== 'string' || url === '') return ''
|
||||
|
||||
if (baseUrl.endsWith('/')) {
|
||||
baseUrl = baseUrl.slice(0, -1)
|
||||
}
|
||||
|
||||
if (!baseUrl.endsWith('api')) {
|
||||
baseUrl = '/api'
|
||||
}
|
||||
|
||||
baseUrl =
|
||||
baseUrl +
|
||||
'/view?filename=' +
|
||||
url +
|
||||
'&type=' +
|
||||
loadRootFolder +
|
||||
'&subfolder=' +
|
||||
subfolder
|
||||
'&subfolder=3d'
|
||||
|
||||
return baseUrl
|
||||
}
|
||||
@@ -317,12 +302,7 @@ class OverrideMaterialCreator {
|
||||
|
||||
const texParams = scope.getTextureParams(value, params)
|
||||
const map = scope.loadTexture(
|
||||
resolveURL(
|
||||
scope.baseUrl,
|
||||
texParams.url,
|
||||
scope.loadRootFolder,
|
||||
scope.subfolder
|
||||
)
|
||||
resolveURL(scope.baseUrl, texParams.url, scope.loadRootFolder)
|
||||
)
|
||||
|
||||
map.repeat.copy(texParams.scale)
|
||||
|
||||
@@ -121,8 +121,7 @@
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"imageUrl": "Image URL",
|
||||
"clear": "Clear",
|
||||
"copyURL": "Copy URL"
|
||||
"clear": "Clear"
|
||||
},
|
||||
"manager": {
|
||||
"title": "Custom Nodes Manager",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"control_before_generate": "control antes de generar",
|
||||
"copy": "Copiar",
|
||||
"copyToClipboard": "Copiar al portapapeles",
|
||||
"copyURL": "Copiar URL",
|
||||
"currentUser": "Usuario actual",
|
||||
"customBackground": "Fondo personalizado",
|
||||
"customize": "Personalizar",
|
||||
|
||||
@@ -3403,7 +3403,7 @@
|
||||
"clear": {
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
"name": "altura"
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
@@ -3417,26 +3417,20 @@
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "imagen"
|
||||
},
|
||||
"1": {
|
||||
"name": "mask"
|
||||
},
|
||||
"2": {
|
||||
"name": "ruta_malla"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "normal"
|
||||
},
|
||||
"4": {
|
||||
{
|
||||
"name": "lineart"
|
||||
},
|
||||
"5": {
|
||||
"name": "info_cámara"
|
||||
{
|
||||
"name": "camera_info"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Load3DAnimation": {
|
||||
"display_name": "Cargar 3D - Animación",
|
||||
@@ -3444,7 +3438,7 @@
|
||||
"clear": {
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
"name": "altura"
|
||||
},
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
@@ -3458,23 +3452,17 @@
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "imagen"
|
||||
},
|
||||
"1": {
|
||||
"name": "mask"
|
||||
},
|
||||
"2": {
|
||||
"name": "ruta_malla"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "normal"
|
||||
},
|
||||
"4": {
|
||||
"name": "info_cámara"
|
||||
{
|
||||
"name": "camera_info"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "CargarAudio",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"control_before_generate": "contrôle avant génération",
|
||||
"copy": "Copier",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"copyURL": "Copier l’URL",
|
||||
"currentUser": "Utilisateur actuel",
|
||||
"customBackground": "Arrière-plan personnalisé",
|
||||
"customize": "Personnaliser",
|
||||
|
||||
@@ -3417,26 +3417,20 @@
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image"
|
||||
},
|
||||
"1": {
|
||||
"name": "masque"
|
||||
},
|
||||
"2": {
|
||||
"name": "chemin_maillage"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "normale"
|
||||
},
|
||||
"4": {
|
||||
"name": "lineart"
|
||||
{
|
||||
"name": "ligne artistique"
|
||||
},
|
||||
"5": {
|
||||
"name": "info_caméra"
|
||||
{
|
||||
"name": "informations caméra"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Load3DAnimation": {
|
||||
"display_name": "Charger 3D - Animation",
|
||||
@@ -3458,23 +3452,17 @@
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image"
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "normal"
|
||||
},
|
||||
"1": {
|
||||
"name": "masque"
|
||||
},
|
||||
"2": {
|
||||
"name": "chemin_maillage"
|
||||
},
|
||||
"3": {
|
||||
"name": "normale"
|
||||
},
|
||||
"4": {
|
||||
"name": "info_caméra"
|
||||
{
|
||||
"name": "camera_info"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "ChargerAudio",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"control_before_generate": "生成前の制御",
|
||||
"copy": "コピー",
|
||||
"copyToClipboard": "クリップボードにコピー",
|
||||
"copyURL": "URLをコピー",
|
||||
"currentUser": "現在のユーザー",
|
||||
"customBackground": "カスタム背景",
|
||||
"customize": "カスタマイズ",
|
||||
|
||||
@@ -3417,29 +3417,23 @@
|
||||
"name": "幅"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "画像"
|
||||
},
|
||||
"1": {
|
||||
"name": "マスク"
|
||||
},
|
||||
"2": {
|
||||
"name": "メッシュパス"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "法線"
|
||||
},
|
||||
"4": {
|
||||
{
|
||||
"name": "線画"
|
||||
},
|
||||
"5": {
|
||||
{
|
||||
"name": "カメラ情報"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Load3DAnimation": {
|
||||
"display_name": "3D読み込み - アニメーション",
|
||||
"display_name": "3Dを読み込む - アニメーション",
|
||||
"inputs": {
|
||||
"clear": {
|
||||
},
|
||||
@@ -3458,23 +3452,17 @@
|
||||
"name": "幅"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "画像"
|
||||
},
|
||||
"1": {
|
||||
"name": "マスク"
|
||||
},
|
||||
"2": {
|
||||
"name": "メッシュパス"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "法線"
|
||||
},
|
||||
"4": {
|
||||
{
|
||||
"name": "カメラ情報"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "音声を読み込む",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"control_before_generate": "생성 전 제어",
|
||||
"copy": "복사",
|
||||
"copyToClipboard": "클립보드에 복사",
|
||||
"copyURL": "URL 복사",
|
||||
"currentUser": "현재 사용자",
|
||||
"customBackground": "맞춤 배경",
|
||||
"customize": "사용자 정의",
|
||||
|
||||
@@ -3398,7 +3398,7 @@
|
||||
}
|
||||
},
|
||||
"Load3D": {
|
||||
"display_name": "3D 불러오기",
|
||||
"display_name": "3D 로드",
|
||||
"inputs": {
|
||||
"clear": {
|
||||
},
|
||||
@@ -3417,29 +3417,23 @@
|
||||
"name": "너비"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "이미지"
|
||||
},
|
||||
"1": {
|
||||
"name": "마스크"
|
||||
},
|
||||
"2": {
|
||||
"name": "메시 경로"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "노멀"
|
||||
},
|
||||
"4": {
|
||||
{
|
||||
"name": "라인아트"
|
||||
},
|
||||
"5": {
|
||||
{
|
||||
"name": "카메라 정보"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Load3DAnimation": {
|
||||
"display_name": "3D 불러오기 - 애니메이션",
|
||||
"display_name": "3D 로드 - 애니메이션",
|
||||
"inputs": {
|
||||
"clear": {
|
||||
},
|
||||
@@ -3458,23 +3452,17 @@
|
||||
"name": "너비"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "이미지"
|
||||
},
|
||||
"1": {
|
||||
"name": "마스크"
|
||||
},
|
||||
"2": {
|
||||
"name": "메시 경로"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "노멀"
|
||||
},
|
||||
"4": {
|
||||
{
|
||||
"name": "카메라 정보"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "오디오 로드",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"control_before_generate": "управление до генерации",
|
||||
"copy": "Копировать",
|
||||
"copyToClipboard": "Скопировать в буфер обмена",
|
||||
"copyURL": "Скопировать URL",
|
||||
"currentUser": "Текущий пользователь",
|
||||
"customBackground": "Пользовательский фон",
|
||||
"customize": "Настроить",
|
||||
|
||||
@@ -3409,7 +3409,7 @@
|
||||
"name": "изображение"
|
||||
},
|
||||
"model_file": {
|
||||
"name": "файл модели"
|
||||
"name": "файл_модели"
|
||||
},
|
||||
"upload 3d model": {
|
||||
},
|
||||
@@ -3417,29 +3417,23 @@
|
||||
"name": "ширина"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "изображение"
|
||||
},
|
||||
"1": {
|
||||
"name": "mask"
|
||||
},
|
||||
"2": {
|
||||
"name": "путь к mesh"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "нормаль"
|
||||
},
|
||||
"4": {
|
||||
"name": "линейный рисунок"
|
||||
{
|
||||
"name": "линеарт"
|
||||
},
|
||||
"5": {
|
||||
"name": "информация о камере"
|
||||
{
|
||||
"name": "информация_камеры"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Load3DAnimation": {
|
||||
"display_name": "Загрузить 3D - Анимация",
|
||||
"display_name": "Загрузить 3D — Анимация",
|
||||
"inputs": {
|
||||
"clear": {
|
||||
},
|
||||
@@ -3458,23 +3452,17 @@
|
||||
"name": "ширина"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "изображение"
|
||||
},
|
||||
"1": {
|
||||
"name": "mask"
|
||||
},
|
||||
"2": {
|
||||
"name": "путь_к_модели"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "нормаль"
|
||||
},
|
||||
"4": {
|
||||
"name": "информация_о_камере"
|
||||
{
|
||||
"name": "информация_камеры"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "Загрузить аудио",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"control_before_generate": "生成前控制",
|
||||
"copy": "复制",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"copyURL": "复制链接",
|
||||
"currentUser": "当前用户",
|
||||
"customBackground": "自定义背景",
|
||||
"customize": "自定义",
|
||||
|
||||
@@ -3417,26 +3417,20 @@
|
||||
"name": "宽度"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "image"
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "法线"
|
||||
},
|
||||
"1": {
|
||||
"name": "mask"
|
||||
{
|
||||
"name": "线稿"
|
||||
},
|
||||
"2": {
|
||||
"name": "mesh_path"
|
||||
},
|
||||
"3": {
|
||||
"name": "normal"
|
||||
},
|
||||
"4": {
|
||||
"name": "lineart"
|
||||
},
|
||||
"5": {
|
||||
"name": "camera_info"
|
||||
{
|
||||
"name": "相机信息"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"Load3DAnimation": {
|
||||
"display_name": "加载3D动画",
|
||||
@@ -3458,23 +3452,17 @@
|
||||
"name": "宽度"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "图像"
|
||||
},
|
||||
"1": {
|
||||
"name": "遮罩"
|
||||
},
|
||||
"2": {
|
||||
"name": "mesh_path"
|
||||
},
|
||||
"3": {
|
||||
"outputs": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
{
|
||||
"name": "法线"
|
||||
},
|
||||
"4": {
|
||||
{
|
||||
"name": "相机信息"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"LoadAudio": {
|
||||
"display_name": "加载音频",
|
||||
|
||||
@@ -22,7 +22,8 @@ const zOutputs = z
|
||||
audio: z.array(zResultItem).optional(),
|
||||
images: z.array(zResultItem).optional(),
|
||||
video: z.array(zResultItem).optional(),
|
||||
animated: z.array(z.boolean()).optional()
|
||||
animated: z.array(z.boolean()).optional(),
|
||||
text: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@ export const zKeybinding = z.object({
|
||||
// Note: Currently only used to distinguish between global keybindings
|
||||
// and litegraph canvas keybindings.
|
||||
// Do NOT use this field in extensions as it has no effect.
|
||||
targetElementId: z.string().optional(),
|
||||
condition: z.string().optional()
|
||||
targetElementId: z.string().optional()
|
||||
})
|
||||
|
||||
// Infer types from schemas
|
||||
|
||||
@@ -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 &&
|
||||
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
|
||||
) {
|
||||
if (graphData.extra?.ds) {
|
||||
if (restore_view) {
|
||||
if (
|
||||
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
|
||||
graphData.extra?.ds
|
||||
) {
|
||||
this.canvas.ds.offset = graphData.extra.ds.offset
|
||||
this.canvas.ds.scale = graphData.extra.ds.scale
|
||||
} else {
|
||||
|
||||
@@ -32,7 +32,7 @@ export class ChangeTracker {
|
||||
/**
|
||||
* Whether the redo/undo restoring is in progress.
|
||||
*/
|
||||
_restoringState: boolean = false
|
||||
private 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useContextKeyStore } from '@/stores/contextKeyStore'
|
||||
import {
|
||||
KeyComboImpl,
|
||||
KeybindingImpl,
|
||||
@@ -12,7 +11,6 @@ export const useKeybindingService = () => {
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const commandStore = useCommandStore()
|
||||
const settingStore = useSettingStore()
|
||||
const contextKeyStore = useContextKeyStore()
|
||||
|
||||
const keybindHandler = async function (event: KeyboardEvent) {
|
||||
const keyCombo = KeyComboImpl.fromEvent(event)
|
||||
@@ -34,14 +32,6 @@ export const useKeybindingService = () => {
|
||||
|
||||
const keybinding = keybindingStore.getKeybinding(keyCombo)
|
||||
if (keybinding && keybinding.targetElementId !== 'graph-canvas') {
|
||||
// If condition exists and evaluates to false
|
||||
// TODO: Complex context key evaluation
|
||||
if (
|
||||
keybinding.condition &&
|
||||
contextKeyStore.evaluateCondition(keybinding.condition) !== true
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Prevent default browser behavior first, then execute the command
|
||||
event.preventDefault()
|
||||
await commandStore.execute(keybinding.commandId)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { get, set, unset } from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { ContextValue, evaluateExpression } from '@/utils/expressionParserUtil'
|
||||
|
||||
export const useContextKeyStore = defineStore('contextKeys', () => {
|
||||
const contextKeys = reactive<Record<string, ContextValue>>({})
|
||||
|
||||
/**
|
||||
* Get a stored context key by path.
|
||||
* @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c').
|
||||
* @returns {ContextValue | undefined} The value of the context key, or undefined if not found.
|
||||
*/
|
||||
function getContextKey(path: string): ContextValue | undefined {
|
||||
return get(contextKeys, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update a context key value at a given path.
|
||||
* @param {string} path - The dot-separated path to the context key (e.g., 'a.b.c').
|
||||
* @param {ContextValue} value - The value to set for the context key.
|
||||
*/
|
||||
function setContextKey(path: string, value: ContextValue) {
|
||||
set(contextKeys, path, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a context key by path.
|
||||
* @param {string} path - The dot-separated path to the context key to remove (e.g., 'a.b.c').
|
||||
*/
|
||||
function removeContextKey(path: string) {
|
||||
unset(contextKeys, path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all context keys from the store.
|
||||
*/
|
||||
function clearAllContextKeys() {
|
||||
for (const key in contextKeys) {
|
||||
delete contextKeys[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a context key expression string using the current context keys.
|
||||
* Returns false if the expression is invalid or if any referenced key is undefined.
|
||||
* @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2 || (key3 == 'type2')").
|
||||
* @returns {boolean} The result of the expression evaluation. Returns false if the expression is invalid.
|
||||
*/
|
||||
function evaluateCondition(expr: string): boolean {
|
||||
return evaluateExpression(expr, getContextKey)
|
||||
}
|
||||
|
||||
return {
|
||||
contextKeys,
|
||||
getContextKey,
|
||||
setContextKey,
|
||||
removeContextKey,
|
||||
clearAllContextKeys,
|
||||
evaluateCondition
|
||||
}
|
||||
})
|
||||
@@ -9,13 +9,11 @@ export class KeybindingImpl implements Keybinding {
|
||||
commandId: string
|
||||
combo: KeyComboImpl
|
||||
targetElementId?: string
|
||||
condition?: string
|
||||
|
||||
constructor(obj: Keybinding) {
|
||||
this.commandId = obj.commandId
|
||||
this.combo = new KeyComboImpl(obj.combo)
|
||||
this.targetElementId = obj.targetElementId
|
||||
this.condition = obj.condition
|
||||
}
|
||||
|
||||
equals(other: unknown): boolean {
|
||||
@@ -24,8 +22,7 @@ export class KeybindingImpl implements Keybinding {
|
||||
return raw instanceof KeybindingImpl
|
||||
? this.commandId === raw.commandId &&
|
||||
this.combo.equals(raw.combo) &&
|
||||
this.targetElementId === raw.targetElementId &&
|
||||
this.condition === raw.condition
|
||||
this.targetElementId === raw.targetElementId
|
||||
: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export class ResultItemImpl {
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
text?: string
|
||||
|
||||
nodeId: NodeId
|
||||
// 'audio' | 'images' | ...
|
||||
@@ -43,6 +44,7 @@ export class ResultItemImpl {
|
||||
this.filename = obj.filename ?? ''
|
||||
this.subfolder = obj.subfolder ?? ''
|
||||
this.type = obj.type ?? ''
|
||||
this.text = obj.text
|
||||
|
||||
this.nodeId = obj.nodeId
|
||||
this.mediaType = obj.mediaType
|
||||
@@ -193,8 +195,12 @@ export class ResultItemImpl {
|
||||
)
|
||||
}
|
||||
|
||||
get isText(): boolean {
|
||||
return ['text', 'json', 'display_text'].includes(this.mediaType)
|
||||
}
|
||||
|
||||
get supportsPreview(): boolean {
|
||||
return this.isImage || this.isVideo || this.isAudio
|
||||
return this.isImage || this.isVideo || this.isAudio || this.isText
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,14 +239,21 @@ export class TaskItemImpl {
|
||||
}
|
||||
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
|
||||
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
|
||||
(items as ResultItem[]).map(
|
||||
(item: ResultItem) =>
|
||||
new ResultItemImpl({
|
||||
(items as (ResultItem | string)[]).map((item: ResultItem | string) => {
|
||||
if (typeof item === 'string') {
|
||||
return new ResultItemImpl({
|
||||
text: item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
} else {
|
||||
return new ResultItemImpl({
|
||||
...item,
|
||||
nodeId,
|
||||
mediaType
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export class ComfyWorkflow extends UserFile {
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
private _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) => ComfyWorkflow | null
|
||||
openedWorkflowIndexShift: (shift: number) => LoadedComfyWorkflow | null
|
||||
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
||||
openWorkflowsInBackground: (paths: {
|
||||
left?: string[]
|
||||
@@ -477,7 +477,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
isSubgraphActive,
|
||||
updateActiveGraph
|
||||
}
|
||||
}) satisfies () => WorkflowStore
|
||||
}) as () => WorkflowStore
|
||||
|
||||
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
|
||||
const bookmarks = ref<Set<string>>(new Set())
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
type Token = { t: string }
|
||||
interface IdentifierNode {
|
||||
type: 'Identifier'
|
||||
name: string
|
||||
}
|
||||
interface UnaryNode {
|
||||
type: 'Unary'
|
||||
op: '!'
|
||||
left?: never
|
||||
right?: never
|
||||
arg: ASTNode
|
||||
}
|
||||
interface BinaryNode {
|
||||
type: 'Binary'
|
||||
op: '&&' | '||' | '==' | '!=' | '<' | '>' | '<=' | '>='
|
||||
left: ASTNode
|
||||
right: ASTNode
|
||||
}
|
||||
interface LiteralNode {
|
||||
type: 'Literal'
|
||||
value: ContextValue
|
||||
}
|
||||
type ASTNode = IdentifierNode | UnaryNode | BinaryNode | LiteralNode
|
||||
export type ContextValue = string | number | boolean
|
||||
|
||||
const OP_PRECEDENCE: Record<string, number> = {
|
||||
'||': 1,
|
||||
'&&': 2,
|
||||
'==': 3,
|
||||
'!=': 3,
|
||||
'<': 3,
|
||||
'>': 3,
|
||||
'<=': 3,
|
||||
'>=': 3
|
||||
}
|
||||
|
||||
// Regular expression for tokenizing expressions
|
||||
const TOKEN_REGEX =
|
||||
/\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|==|!=|<=|>=|&&|\|\||<|>|[A-Za-z0-9_.]+|!|\(|\))\s*/g
|
||||
|
||||
// Cache for storing parsed ASTs to improve performance
|
||||
const astCache = new Map<string, ASTNode>()
|
||||
|
||||
/**
|
||||
* Tokenizes a context key expression string into an array of tokens.
|
||||
*
|
||||
* This function breaks down an expression string into smaller components (tokens)
|
||||
* that can be parsed into an Abstract Syntax Tree (AST).
|
||||
*
|
||||
* @param {string} expr - The expression string to tokenize (e.g., "key1 && !key2 || (key3 && key4)").
|
||||
* @returns {Token[]} An array of tokens representing the components of the expression.
|
||||
* @throws {Error} If invalid characters are found in the expression.
|
||||
*/
|
||||
export function tokenize(expr: string): Token[] {
|
||||
const tokens: Token[] = []
|
||||
let pos = 0
|
||||
const re = new RegExp(TOKEN_REGEX) // Clone/reset regex state
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(expr))) {
|
||||
if (m.index !== pos) {
|
||||
throw new Error(`Invalid character in expression at position ${pos}`)
|
||||
}
|
||||
tokens.push({ t: m[1] })
|
||||
pos = re.lastIndex
|
||||
}
|
||||
if (pos !== expr.length) {
|
||||
throw new Error(`Invalid character in expression at position ${pos}`)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a sequence of tokens into an Abstract Syntax Tree (AST).
|
||||
*
|
||||
* This function implements a recursive descent parser for boolean expressions
|
||||
* with support for operator precedence and parentheses.
|
||||
*
|
||||
* @param {Token[]} tokens - The array of tokens generated by `tokenize`.
|
||||
* @returns {ASTNode} The root node of the parsed AST.
|
||||
* @throws {Error} If there are syntax errors, such as mismatched parentheses or unexpected tokens.
|
||||
*/
|
||||
export function parseAST(tokens: Token[]): ASTNode {
|
||||
let i = 0
|
||||
|
||||
function peek(): string | undefined {
|
||||
return tokens[i]?.t
|
||||
}
|
||||
|
||||
function consume(expected?: string): string {
|
||||
const tok = tokens[i++]?.t
|
||||
if (expected && tok !== expected) {
|
||||
throw new Error(`Expected ${expected}, got ${tok ?? 'end of input'}`)
|
||||
}
|
||||
if (!tok) {
|
||||
throw new Error(`Expected ${expected}, got end of input`)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
function parsePrimary(): ASTNode {
|
||||
if (peek() === '!') {
|
||||
consume('!')
|
||||
return { type: 'Unary', op: '!', arg: parsePrimary() }
|
||||
}
|
||||
if (peek() === '(') {
|
||||
consume('(')
|
||||
const expr = parseExpression(0)
|
||||
consume(')')
|
||||
return expr
|
||||
}
|
||||
const tok = consume()
|
||||
// string literal?
|
||||
if (
|
||||
(tok[0] === '"' && tok[tok.length - 1] === '"') ||
|
||||
(tok[0] === "'" && tok[tok.length - 1] === "'")
|
||||
) {
|
||||
const raw = tok.slice(1, -1).replace(/\\(.)/g, '$1')
|
||||
return { type: 'Literal', value: raw }
|
||||
}
|
||||
// numeric literal?
|
||||
if (/^\d+(\.\d+)?$/.test(tok)) {
|
||||
return { type: 'Literal', value: Number(tok) }
|
||||
}
|
||||
// identifier
|
||||
if (!/^[A-Za-z0-9_.]+$/.test(tok)) {
|
||||
throw new Error(`Invalid identifier: ${tok}`)
|
||||
}
|
||||
return { type: 'Identifier', name: tok }
|
||||
}
|
||||
|
||||
function parseExpression(minPrec: number): ASTNode {
|
||||
let left = parsePrimary()
|
||||
while (true) {
|
||||
const tok = peek()
|
||||
const prec = tok ? OP_PRECEDENCE[tok] : undefined
|
||||
if (prec === undefined || prec < minPrec) break
|
||||
consume(tok)
|
||||
const right = parseExpression(prec + 1)
|
||||
left = { type: 'Binary', op: tok as BinaryNode['op'], left, right }
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
const ast = parseExpression(0)
|
||||
if (i < tokens.length) {
|
||||
throw new Error(`Unexpected token ${peek()}`)
|
||||
}
|
||||
return ast
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a ContextValue or undefined to a boolean value.
|
||||
*
|
||||
* This utility ensures consistent truthy/falsy evaluation for different types of values.
|
||||
*
|
||||
* @param {ContextValue | undefined} val - The value to convert.
|
||||
* @returns {boolean} The boolean representation of the value.
|
||||
*/
|
||||
function toBoolean(val: ContextValue | undefined): boolean {
|
||||
if (val === undefined) return false
|
||||
if (typeof val === 'boolean') return val
|
||||
if (typeof val === 'number') return val !== 0
|
||||
if (typeof val === 'string') return val.length > 0
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the raw value of an AST node for equality checks.
|
||||
*
|
||||
* This function resolves the value of a node, whether it's a literal, identifier,
|
||||
* or a nested expression, for comparison purposes.
|
||||
*
|
||||
* @param {ASTNode} node - The AST node to evaluate.
|
||||
* @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values.
|
||||
* @returns {ContextValue | boolean} The raw value of the node.
|
||||
*/
|
||||
function getRawValue(
|
||||
node: ASTNode,
|
||||
getContextKey: (key: string) => ContextValue | undefined
|
||||
): ContextValue | boolean {
|
||||
if (node.type === 'Literal') return node.value
|
||||
if (node.type === 'Identifier') {
|
||||
const val = getContextKey(node.name)
|
||||
return val === undefined ? false : val
|
||||
}
|
||||
return evalAst(node, getContextKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates an AST node recursively to compute its boolean value.
|
||||
*
|
||||
* This function traverses the AST and evaluates each node based on its type
|
||||
* (e.g., literal, identifier, unary, or binary).
|
||||
*
|
||||
* @param {ASTNode} node - The AST node to evaluate.
|
||||
* @param {(key: string) => ContextValue | undefined} getContextKey - A function to retrieve context key values.
|
||||
* @returns {boolean} The boolean result of the evaluation.
|
||||
* @throws {Error} If the AST node type is unknown or unsupported.
|
||||
*/
|
||||
export function evalAst(
|
||||
node: ASTNode,
|
||||
getContextKey: (key: string) => ContextValue | undefined
|
||||
): boolean {
|
||||
switch (node.type) {
|
||||
case 'Literal':
|
||||
return toBoolean(node.value)
|
||||
case 'Identifier':
|
||||
return toBoolean(getContextKey(node.name))
|
||||
case 'Unary':
|
||||
return !evalAst(node.arg, getContextKey)
|
||||
case 'Binary': {
|
||||
const { op, left, right } = node
|
||||
if (op === '&&' || op === '||') {
|
||||
const l = evalAst(left, getContextKey)
|
||||
const r = evalAst(right, getContextKey)
|
||||
return op === '&&' ? l && r : l || r
|
||||
}
|
||||
const lRaw = getRawValue(left, getContextKey)
|
||||
const rRaw = getRawValue(right, getContextKey)
|
||||
switch (op) {
|
||||
case '==':
|
||||
return lRaw === rRaw
|
||||
case '!=':
|
||||
return lRaw !== rRaw
|
||||
case '<':
|
||||
return (lRaw as any) < (rRaw as any)
|
||||
case '>':
|
||||
return (lRaw as any) > (rRaw as any)
|
||||
case '<=':
|
||||
return (lRaw as any) <= (rRaw as any)
|
||||
case '>=':
|
||||
return (lRaw as any) >= (rRaw as any)
|
||||
default:
|
||||
throw new Error(`Unsupported operator: ${op}`)
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown AST node type: ${(node as ASTNode).type}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and evaluates a context key expression string.
|
||||
*
|
||||
* This function combines tokenization, parsing, and evaluation to compute
|
||||
* the boolean result of an expression. It also caches parsed ASTs for performance.
|
||||
*
|
||||
* @param {string} expr - The expression string to evaluate (e.g., "key1 && !key2").
|
||||
* @param {(key: string) => ContextValue | undefined} getContextKey - A function to resolve context key identifiers.
|
||||
* @returns {boolean} The boolean result of the expression.
|
||||
* @throws {Error} If there are parsing or evaluation errors.
|
||||
*/
|
||||
export function evaluateExpression(
|
||||
expr: string,
|
||||
getContextKey: (key: string) => ContextValue | undefined
|
||||
): boolean {
|
||||
if (!expr) return true
|
||||
|
||||
try {
|
||||
let ast: ASTNode
|
||||
if (astCache.has(expr)) {
|
||||
ast = astCache.get(expr)!
|
||||
} else {
|
||||
const tokens = tokenize(expr)
|
||||
ast = parseAST(tokens)
|
||||
astCache.set(expr, ast)
|
||||
}
|
||||
return evalAst(ast, getContextKey)
|
||||
} catch (error) {
|
||||
console.error(`Error evaluating expression "${expr}":`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useContextKeyStore } from '@/stores/contextKeyStore'
|
||||
|
||||
describe('contextKeyStore', () => {
|
||||
let store: ReturnType<typeof useContextKeyStore>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
store = useContextKeyStore()
|
||||
})
|
||||
|
||||
it('should set and get a context key', () => {
|
||||
store.setContextKey('key1', true)
|
||||
expect(store.getContextKey('key1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should remove a context key', () => {
|
||||
store.setContextKey('key1', true)
|
||||
store.removeContextKey('key1')
|
||||
expect(store.getContextKey('key1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear all context keys', () => {
|
||||
store.setContextKey('key1', true)
|
||||
store.setContextKey('key2', false)
|
||||
store.clearAllContextKeys()
|
||||
expect(Object.keys(store.contextKeys)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should evaluate a simple condition', () => {
|
||||
store.setContextKey('key1', true)
|
||||
store.setContextKey('key2', false)
|
||||
expect(store.evaluateCondition('key1 && !key2')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
ContextValue,
|
||||
evaluateExpression,
|
||||
parseAST,
|
||||
tokenize
|
||||
} from '@/utils/expressionParserUtil'
|
||||
|
||||
describe('tokenize()', () => {
|
||||
it('splits identifiers, literals, operators and parentheses', () => {
|
||||
const tokens = tokenize('a && !b || (c == "d")')
|
||||
expect(tokens.map((t) => t.t)).toEqual([
|
||||
'a',
|
||||
'&&',
|
||||
'!',
|
||||
'b',
|
||||
'||',
|
||||
'(',
|
||||
'c',
|
||||
'==',
|
||||
'"d"',
|
||||
')'
|
||||
])
|
||||
})
|
||||
|
||||
it('throws on encountering invalid characters', () => {
|
||||
expect(() => tokenize('a & b')).toThrowError(/Invalid character/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAST()', () => {
|
||||
it('parses a single identifier', () => {
|
||||
const ast = parseAST(tokenize('x'))
|
||||
expect(ast).toEqual({ type: 'Identifier', name: 'x' })
|
||||
})
|
||||
|
||||
it('respects default precedence (&& over ||)', () => {
|
||||
const ast = parseAST(tokenize('a || b && c'))
|
||||
expect(ast).toEqual({
|
||||
type: 'Binary',
|
||||
op: '||',
|
||||
left: { type: 'Identifier', name: 'a' },
|
||||
right: {
|
||||
type: 'Binary',
|
||||
op: '&&',
|
||||
left: { type: 'Identifier', name: 'b' },
|
||||
right: { type: 'Identifier', name: 'c' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('honors parentheses to override precedence', () => {
|
||||
const ast = parseAST(tokenize('(a || b) && c'))
|
||||
expect(ast).toEqual({
|
||||
type: 'Binary',
|
||||
op: '&&',
|
||||
left: {
|
||||
type: 'Binary',
|
||||
op: '||',
|
||||
left: { type: 'Identifier', name: 'a' },
|
||||
right: { type: 'Identifier', name: 'b' }
|
||||
},
|
||||
right: { type: 'Identifier', name: 'c' }
|
||||
})
|
||||
})
|
||||
|
||||
it('parses unary NOT correctly', () => {
|
||||
const ast = parseAST(tokenize('!a && b'))
|
||||
expect(ast).toEqual({
|
||||
type: 'Binary',
|
||||
op: '&&',
|
||||
left: { type: 'Unary', op: '!', arg: { type: 'Identifier', name: 'a' } },
|
||||
right: { type: 'Identifier', name: 'b' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateExpression()', () => {
|
||||
const context: Record<string, ContextValue> = {
|
||||
a: true,
|
||||
b: false,
|
||||
c: true,
|
||||
d: '',
|
||||
num1: 1,
|
||||
num2: 2,
|
||||
num3: 3
|
||||
}
|
||||
const getContextKey = (key: string) => context[key]
|
||||
|
||||
it('returns true for empty expression', () => {
|
||||
expect(evaluateExpression('', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('evaluates literals and basic comparisons', () => {
|
||||
expect(evaluateExpression('"hi"', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression("''", getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('1', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('0', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('1 == 1', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('1 != 2', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression("'x' == 'y'", getContextKey)).toBe(false)
|
||||
})
|
||||
|
||||
it('evaluates logical AND, OR and NOT', () => {
|
||||
expect(evaluateExpression('a && b', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('a || b', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('!b', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('evaluates comparison operators correctly', () => {
|
||||
expect(evaluateExpression('num1 < num2', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('num1 > num2', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('num1 <= num1', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('num3 >= num2', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('respects operator precedence and parentheses', () => {
|
||||
expect(evaluateExpression('a || b && c', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('(a || b) && c', getContextKey)).toBe(true)
|
||||
expect(evaluateExpression('!(a && b) || c', getContextKey)).toBe(true)
|
||||
})
|
||||
|
||||
it('safely handles syntax errors by returning false', () => {
|
||||
expect(evaluateExpression('a &&', getContextKey)).toBe(false)
|
||||
expect(evaluateExpression('foo $ bar', getContextKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user