Compare commits

..

3 Commits

Author SHA1 Message Date
christian-byrne
a7cdb27d48 Remove border style 2025-01-18 16:22:49 -07:00
christian-byrne
a437fe90fc Remove unnecessary dialog ref 2025-01-18 14:55:00 -07:00
christian-byrne
d6c1036c68 Issue report view 2025-01-18 14:51:31 -07:00
158 changed files with 3127 additions and 6836 deletions

View File

@@ -40,10 +40,9 @@ jobs:
files: |
dist.zip
tag_name: v${{ steps.current_version.outputs.version }}
draft: false
draft: true
prerelease: false
make_latest: "true"
generate_release_notes: true
publish_types:
runs-on: ubuntu-latest
if: >

3
.gitignore vendored
View File

@@ -16,9 +16,6 @@ dist-ssr
.vscode/*
*.code-workspace
!.vscode/extensions.json
!.vscode/tailwind.json
!.vscode/settings.json.default
!.vscode/launch.json.default
.idea
.DS_Store
*.suo

View File

@@ -1,16 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome on frontend dev",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src",
"sourceMaps": true,
}
]
}

View File

@@ -1,5 +0,0 @@
{
"css.customData": [
".vscode/tailwind.json"
]
}

55
.vscode/tailwind.json vendored
View File

@@ -1,55 +0,0 @@
{
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
}
]
},
{
"name": "@apply",
"description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that youd like to extract to a new component.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#apply"
}
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
}
]
}
]
}

View File

@@ -1,163 +0,0 @@
{
"last_node_id": 19,
"last_link_id": 14,
"nodes": [
{
"id": 19,
"type": "workflow>two_VAE_decode",
"pos": [
1368.800048828125,
768.7999877929688
],
"size": [
418.1999816894531,
86
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null
},
{
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
},
{
"name": "VAEDecode IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {}
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
},
"node_versions": {},
"ue_links": [],
"groupNodes": {
"two_VAE_decode": {
"nodes": [
{
"id": -1,
"type": "VAEDecode",
"pos": [
1368.800048828125,
768.7999877929688
],
"size": [
210,
46
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null,
"localized_name": "samples"
},
{
"name": "vae",
"type": "VAE",
"link": null,
"localized_name": "vae"
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null,
"localized_name": "IMAGE"
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"index": 0
},
{
"id": -1,
"type": "VAEDecode",
"pos": [
1368.800048828125,
873.7999877929688
],
"size": [
210,
46
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": null,
"localized_name": "samples"
},
{
"name": "vae",
"type": "VAE",
"link": null,
"localized_name": "vae"
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null,
"localized_name": "IMAGE"
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"index": 1
}
],
"links": [],
"external": [],
"config": {
"1": {
"input": {
"samples": {
"visible": false
},
"vae": {
"visible": false
}
}
}
}
}
}
},
"version": 0.4
}

View File

@@ -1,35 +0,0 @@
{
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "DevToolsNodeWithBooleanInput",
"pos": [
0,
30
],
"size": [
315,
58
],
"flags": {
"collapsed": false
},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"Node name for S&R": "DevToolsNodeWithBooleanInput"
},
"widgets_values": [
false
]
}
],
"links": [],
"groups": [],
"config": {},
"version": 0.4
}

View File

@@ -1,4 +1,4 @@
import { Locator, expect } from '@playwright/test'
import { expect } from '@playwright/test'
import { Keybinding } from '../src/types/keyBindingTypes'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
@@ -66,71 +66,9 @@ test.describe('Missing models warning', () => {
}, comfyPage.url)
})
test('Should display a warning when missing models are found', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const downloadButton = missingModelsWarning.getByLabel('Download')
await expect(downloadButton).toBeVisible()
})
test('Should not display a warning when no missing models are found', async ({
comfyPage
}) => {
const modelFoldersRes = {
status: 200,
body: JSON.stringify([
{
name: 'clip',
folders: ['ComfyUI/models/clip']
}
])
}
comfyPage.page.route(
'**/api/experiment/models',
(route) => route.fulfill(modelFoldersRes),
{ times: 1 }
)
// Reload page to trigger indexing of model folders
await comfyPage.setup()
const clipModelsRes = {
status: 200,
body: JSON.stringify([
{
name: 'fake_model.safetensors',
pathIndex: 0
}
])
}
comfyPage.page.route(
'**/api/experiment/models/clip',
(route) => route.fulfill(clipModelsRes),
{ times: 1 }
)
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
test('should show on tutorial workflow', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.TutorialCompleted', false)
await comfyPage.setup({ clearStorage: true })
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
expect(await comfyPage.getSetting('Comfy.TutorialCompleted')).toBe(true)
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should download missing model when clicking download button', async ({
test.skip('Should display a warning when missing models are found', async ({
comfyPage
}) => {
// The fake_model.safetensors is served by
@@ -148,49 +86,6 @@ test.describe('Missing models warning', () => {
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
})
test.describe('Do not show again checkbox', () => {
let checkbox: Locator
let closeButton: Locator
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',
true
)
await comfyPage.loadWorkflow('missing_models')
checkbox = comfyPage.page.getByLabel("Don't show this again")
closeButton = comfyPage.page.getByLabel('Close')
})
test('Should disable warning dialog when checkbox is checked', async ({
comfyPage
}) => {
await checkbox.click()
const changeSettingPromise = comfyPage.page.waitForRequest(
'**/api/settings/Comfy.Workflow.ShowMissingModelsWarning'
)
await closeButton.click()
await changeSettingPromise
const settingValue = await comfyPage.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(false)
})
test('Should keep warning dialog enabled when checkbox is unchecked', async ({
comfyPage
}) => {
await closeButton.click()
const settingValue = await comfyPage.getSetting(
'Comfy.Workflow.ShowMissingModelsWarning'
)
expect(settingValue).toBe(true)
})
})
})
test.describe('Settings', () => {
@@ -253,7 +148,7 @@ test.describe('Settings', () => {
// Save keybinding
const saveButton = comfyPage.page
.getByLabel('New Blank Workflow')
.getByLabel('Comfy.NewBlankWorkflow')
.getByLabel('Save')
await saveButton.click()
@@ -270,37 +165,3 @@ test.describe('Settings', () => {
expect(request.postData()).toContain(JSON.stringify(expectedSetting))
})
})
test.describe('Feedback dialog', () => {
test('Should open from topmenu help command', async ({ comfyPage }) => {
// Open feedback dialog from top menu
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Feedback'])
// Verify feedback dialog content is visible
const feedbackHeader = comfyPage.page.getByRole('heading', {
name: 'Feedback'
})
await expect(feedbackHeader).toBeVisible()
})
test('Should close when close button clicked', async ({ comfyPage }) => {
// Open feedback dialog
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.menu.topbar.triggerTopbarCommand(['Help', 'Feedback'])
const feedbackHeader = comfyPage.page.getByRole('heading', {
name: 'Feedback'
})
// Close feedback dialog
await comfyPage.page
.getByLabel('', { exact: true })
.getByLabel('Close')
.click()
await feedbackHeader.waitFor({ state: 'hidden' })
// Verify dialog is closed
await expect(feedbackHeader).not.toBeVisible()
})
})

View File

@@ -904,9 +904,7 @@ export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
// Disable tooltips by default to avoid flakiness.
'Comfy.EnableTooltips': false,
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true
'Comfy.userId': userId
})
} catch (e) {
console.error(e)

View File

@@ -83,12 +83,6 @@ export class NodeWidgetReference {
y: pos[1]
}
}
async click() {
await this.node.comfyPage.canvas.click({
position: await this.getPosition()
})
}
}
export class NodeReference {

View File

@@ -134,37 +134,6 @@ test.describe('Group Node', () => {
expect(await manage2.getSelectedNodeType()).toBe('g2')
})
test('Preserves hidden input configuration when containing duplicate node types', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('group_node_identical_nodes_hidden_inputs')
await comfyPage.nextFrame()
const groupNodeId = 19
const groupNodeName = 'two_VAE_decode'
const totalInputCount = await comfyPage.page.evaluate((nodeName) => {
const {
extra: { groupNodes }
} = window['app'].graph
const { nodes } = groupNodes[nodeName]
return nodes.reduce((acc: number, node) => {
return acc + node.inputs.length
}, 0)
}, groupNodeName)
const visibleInputCount = await comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return node.inputs.length
}, groupNodeId)
// Verify there are 4 total inputs (2 VAE decode nodes with 2 inputs each)
expect(totalInputCount).toBe(4)
// Verify there are 2 visible inputs (2 have been hidden in config)
expect(visibleInputCount).toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {

View File

@@ -82,14 +82,10 @@ test.describe('Node search box', () => {
test('Has correct aria-labels on search results', async ({ comfyPage }) => {
const node = 'Load Checkpoint'
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(node)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
// Wait for some time for the auto complete list to update.
// The auto complete list is debounced and may take some time to update.
await comfyPage.page.waitForTimeout(500)
const firstResult = comfyPage.searchBox.dropdown.locator('li').first()
await comfyPage.searchBox.fillAndSelectFirstNode(node)
const firstResult = comfyPage.page
.locator('li.p-autocomplete-option')
.first()
await expect(firstResult).toHaveAttribute('aria-label', node)
})

View File

@@ -102,18 +102,6 @@ test.describe('Node Right Click Menu', () => {
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert Widget to Input').click()
await comfyPage.nextFrame()
// The submenu has an identical entry as the base menu - use last
await comfyPage.page.getByText('Convert width to input').last().click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-widget-converted.png'
)
})
test('Can convert widget without submenu', async ({ comfyPage }) => {
// Right-click the width widget
await comfyPage.rightClickEmptyLatentNode()
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
await comfyPage.page.getByText('Convert width to input').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -27,16 +27,3 @@ test.describe('Combo text widget', () => {
await expect(comfyPage.canvas).toHaveScreenshot('resized-to-original.png')
})
})
test.describe('Boolean widget', () => {
test('Can toggle', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/boolean_widget')
await expect(comfyPage.canvas).toHaveScreenshot('boolean_widget.png')
const node = (await comfyPage.getFirstNodeRef())!
const widget = await node.getWidget(0)
await widget.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'boolean_widget_toggled.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

12
global.d.ts vendored
View File

@@ -1,15 +1,3 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
interface Navigator {
/**
* Used by the electron API. This is a WICG non-standard API, but is guaranteed to exist in Electron.
* It is `undefined` in Firefox and older browsers.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/windowControlsOverlay
*/
windowControlsOverlay?: {
/** When `true`, the window is using custom window style. */
visible: boolean
}
}

View File

@@ -11,7 +11,7 @@ export default {
function formatAndEslint(fileNames) {
return [
`eslint --fix ${fileNames.join(' ')}`,
`prettier --write ${fileNames.join(' ')}`
`prettier --write ${fileNames.join(' ')} --plugin @trivago/prettier-plugin-sort-imports`,
`eslint --fix ${fileNames.join(' ')}`
]
}

717
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.9.3",
"version": "1.7.14",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -76,17 +76,16 @@
"vite": "^5.4.6",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.1.9",
"vitest": "^2.0.5",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.16",
"@comfyorg/litegraph": "^0.8.67",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@comfyorg/comfyui-electron-types": "^0.4.10",
"@comfyorg/litegraph": "^0.8.60",
"@primevue/themes": "^4.0.5",
"@sentry/vue": "^8.48.0",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-link": "^2.10.4",
@@ -97,7 +96,6 @@
"@tiptap/starter-kit": "^2.10.4",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-serialize": "^0.13.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.7.4",
"dotenv": "^16.4.5",
@@ -107,10 +105,10 @@
"loglevel": "^1.9.2",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"primevue": "^4.0.5",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
"zod": "^3.23.8",

View File

@@ -266,7 +266,7 @@
],
"properties": {},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
"v1-5-pruned-emaonly.safetensors"
]
}
],
@@ -349,8 +349,8 @@
"extra": {},
"version": 0.4,
"models": [{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"name": "v1-5-pruned-emaonly.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly.safetensors?download=true",
"directory": "checkpoints"
}]
}

View File

@@ -330,7 +330,7 @@
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly-fp16.safetensors"
"v1-5-pruned-emaonly.safetensors"
]
}
],
@@ -440,8 +440,8 @@
"extra": {},
"version": 0.4,
"models": [{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"name": "v1-5-pruned-emaonly.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly.safetensors?download=true",
"directory": "checkpoints"
}]
}

View File

@@ -1,43 +0,0 @@
import { execSync } from 'child_process'
import { readFileSync } from 'fs'
const packageName = '@comfyorg/comfyui-electron-types'
const description = 'desktop API types'
try {
// Create a new branch
console.log('Creating new branch...')
const date = new Date()
const isoDate = date.toISOString().split('T')[0]
const timestamp = date.getTime()
const branchName = `update-electron-types-${isoDate}-${timestamp}`
execSync(`git checkout -b ${branchName} -t origin/main`, { stdio: 'inherit' })
// Update npm package to latest version
console.log(`Updating ${description}...`)
execSync(`npm install ${packageName}@latest`, {
stdio: 'inherit'
})
// Get the new version from package.json
const packageLock = JSON.parse(readFileSync('./package-lock.json', 'utf8'))
const newVersion = packageLock.packages[`node_modules/${packageName}`].version
// Stage changes
const message = `[chore] Update electron-types to ${newVersion}`
execSync('git add package.json package-lock.json', { stdio: 'inherit' })
execSync(`git commit -m "${message}"`, { stdio: 'inherit' })
// Create the PR
console.log('Creating PR...')
execSync(
`gh pr create --title "${message}" --label "dependencies" --body "Automated update of ${description} to version ${newVersion}."`,
{ stdio: 'inherit' }
)
console.log(
`✅ Successfully created PR for ${description} update to ${newVersion}`
)
} catch (error) {
console.error('❌ Error during update process:', error.message)
}

View File

@@ -18,7 +18,7 @@ import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from './utils/envUtil'
import { isElectron, showNativeMenu } from './utils/envUtil'
const workspaceStore = useWorkspaceStore()
const isLoading = computed<boolean>(() => workspaceStore.spinner)
@@ -34,7 +34,7 @@ const showContextMenu = (event: PointerEvent) => {
case target instanceof HTMLTextAreaElement:
case target instanceof HTMLInputElement && target.type === 'text':
// TODO: Context input menu explicitly for text input
electronAPI()?.showContextMenu({ type: 'text' })
showNativeMenu({ type: 'text' })
return
}
}

View File

@@ -765,30 +765,6 @@ audio.comfy-audio.empty-audio-widget {
padding: var(--comfy-tree-explorer-item-padding) !important;
}
/* Load3d styles */
.comfy-load-3d,
.comfy-load-3d-animation,
.comfy-preview-3d,
.comfy-preview-3d-animation{
display: flex;
flex-direction: column;
background: transparent;
flex: 1;
position: relative;
overflow: hidden;
}
.comfy-load-3d canvas,
.comfy-load-3d-animation canvas,
.comfy-preview-3d canvas,
.comfy-preview-3d-animation canvas{
display: flex;
width: 100% !important;
height: 100% !important;
}
/* End of Load3d styles */
/* [Desktop] Electron window specific styles */
.app-drag {
app-region: drag;

View File

@@ -22,7 +22,7 @@
</template>
<template #item="{ item }">
<Button
:label="String(item.label)"
:label="item.label"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
size="small"

View File

@@ -7,19 +7,16 @@
</template>
<script setup lang="ts">
import { Ref, onUnmounted, ref } from 'vue'
import { Ref, ref } from 'vue'
import { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
const emit = defineEmits<{
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement>]
unmounted: []
}>()
const terminalEl = ref<HTMLElement>()
const rootEl = ref<HTMLElement>()
emit('created', useTerminal(terminalEl), rootEl)
onUnmounted(() => emit('unmounted'))
</script>
<style scoped>

View File

@@ -0,0 +1,40 @@
<template>
<div :class="['flex flex-wrap', $attrs.class]">
<div
v-for="checkbox in checkboxes"
:key="checkbox.value"
class="flex items-center gap-2"
>
<Checkbox
v-model="internalSelection"
:inputId="checkbox.value"
:value="checkbox.value"
/>
<label :for="checkbox.value" class="ml-2">{{ checkbox.label }}</label>
</div>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { computed } from 'vue'
interface CheckboxItem {
label: string
value: string
}
const props = defineProps<{
checkboxes: CheckboxItem[]
modelValue: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const internalSelection = computed({
get: () => props.modelValue,
set: (value: string[]) => emit('update:modelValue', value)
})
</script>

View File

@@ -42,7 +42,7 @@
v-else
class="pi pi-palette"
:style="{ fontSize: '1.2rem' }"
v-tooltip="$t('color.custom')"
v-tooltip="$t('g.customColor')"
></i>
</template>
</SelectButton>

View File

@@ -18,7 +18,7 @@
:label="$t('g.download') + ' (' + fileSize + ')'"
size="small"
outlined
:disabled="!!props.error"
:disabled="props.error"
@click="triggerDownload"
v-if="status === null || status === 'error'"
icon="pi pi-download"
@@ -30,7 +30,7 @@
v-if="status === 'in_progress' || status === 'paused'"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
@@ -42,7 +42,7 @@
class="file-action-button"
size="small"
outlined
:disabled="!!props.error"
:disabled="props.error"
@click="triggerPauseDownload"
v-if="status === 'in_progress'"
icon="pi pi-pause-circle"
@@ -53,7 +53,7 @@
class="file-action-button"
size="small"
outlined
:disabled="!!props.error"
:disabled="props.error"
@click="triggerResumeDownload"
v-if="status === 'paused'"
icon="pi pi-play-circle"
@@ -64,7 +64,7 @@
class="file-action-button"
size="small"
outlined
:disabled="!!props.error"
:disabled="props.error"
@click="triggerCancelDownload"
icon="pi pi-times-circle"
severity="danger"

View File

@@ -15,7 +15,7 @@
:label="$t('g.download') + ' (' + fileSize + ')'"
size="small"
outlined
:disabled="!!props.error"
:disabled="props.error"
:title="props.url"
@click="download.triggerBrowserDownload"
/>

View File

@@ -1,17 +1,35 @@
<template>
<div class="color-picker-wrapper flex items-center gap-2">
<ColorPicker v-model="modelValue" />
<ColorPicker v-model="modelValue">
<template #header>
<div class="flex items-center justify-between p-2">
<span>{{ props.label }}</span>
<Button
v-if="props.defaultValue"
icon="pi pi-refresh"
text
size="small"
@click="resetColor"
/>
</div>
</template>
</ColorPicker>
<InputText v-model="modelValue" class="w-28" :placeholder="label" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ColorPicker from 'primevue/colorpicker'
import InputText from 'primevue/inputtext'
const modelValue = defineModel<string>('modelValue')
defineProps<{
const props = defineProps<{
defaultValue?: string
label?: string
}>()
const resetColor = () => {
modelValue.value = props.defaultValue || '#000000'
}
</script>

View File

@@ -35,7 +35,6 @@ import CustomFormValue from '@/components/common/CustomFormValue.vue'
import FormColorPicker from '@/components/common/FormColorPicker.vue'
import FormImageUpload from '@/components/common/FormImageUpload.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import UrlInput from '@/components/common/UrlInput.vue'
import { FormItem } from '@/types/settingTypes'
const formValue = defineModel<any>('formValue')
@@ -92,8 +91,6 @@ function getFormComponent(item: FormItem): Component {
return FormImageUpload
case 'color':
return FormColorPicker
case 'url':
return UrlInput
default:
return InputText
}

View File

@@ -0,0 +1,66 @@
<!-- A simple read-only terminal component that displays logs. -->
<template>
<div class="p-terminal rounded-none h-full w-full">
<ScrollPanel class="h-full w-full" ref="scrollPanelRef">
<pre class="px-4 whitespace-pre-wrap">{{ log }}</pre>
</ScrollPanel>
</div>
</template>
<script setup lang="ts">
import ScrollPanel from 'primevue/scrollpanel'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = defineProps<{
fetchLogs: () => Promise<string>
fetchInterval: number
}>()
const log = ref<string>('')
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
/**
* Whether the user has scrolled to the bottom of the terminal.
* This is used to prevent the terminal from scrolling to the bottom
* when new logs are fetched.
*/
const scrolledToBottom = ref(false)
let intervalId: number = 0
onMounted(async () => {
const element = scrollPanelRef.value?.$el
const scrollContainer = element?.querySelector('.p-scrollpanel-content')
if (scrollContainer) {
scrollContainer.addEventListener('scroll', () => {
scrolledToBottom.value =
scrollContainer.scrollTop + scrollContainer.clientHeight ===
scrollContainer.scrollHeight
})
}
const scrollToBottom = () => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight
}
}
watch(log, () => {
if (scrolledToBottom.value) {
scrollToBottom()
}
})
const fetchLogs = async () => {
log.value = await props.fetchLogs()
}
await fetchLogs()
scrollToBottom()
intervalId = window.setInterval(fetchLogs, props.fetchInterval)
})
onBeforeUnmount(() => {
window.clearInterval(intervalId)
})
</script>

View File

@@ -1,53 +0,0 @@
<!--
A refresh button that disables and shows a progress spinner whilst active.
Usage:
```vue
<RefreshButton
v-model="isRefreshing"
:outlined="false"
@refresh="refresh"
/>
```
-->
<template>
<Button
class="relative p-button-icon-only"
:outlined="props.outlined"
:severity="props.severity"
:disabled="active || props.disabled"
@click="(event) => $emit('refresh', event)"
>
<span
class="p-button-icon pi pi-refresh transition-all"
:class="{ 'opacity-0': active }"
data-pc-section="icon"
></span>
<span class="p-button-label" data-pc-section="label">&nbsp;</span>
<ProgressSpinner v-show="active" class="absolute w-1/2 h-1/2" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { VueSeverity } from '@/types/primeVueTypes'
// Properties
interface Props {
outlined?: boolean
disabled?: boolean
severity?: VueSeverity
}
const props = withDefaults(defineProps<Props>(), {
outlined: true,
severity: 'secondary'
})
// Model
const active = defineModel<boolean>({ required: true })
// Emits
defineEmits(['refresh'])
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div>
<div :class="props.class">
<IconField>
<Button
v-if="filterIcon"
v-if="props.filterIcon"
class="p-inputicon filter-button"
:icon="filterIcon"
:icon="props.filterIcon"
text
severity="contrast"
@click="$emit('showFilter', $event)"
@@ -12,12 +12,12 @@
<InputText
class="search-box-input w-full"
@input="handleInput"
:modelValue="modelValue"
:placeholder="placeholder"
:modelValue="props.modelValue"
:placeholder="props.placeholder"
/>
<InputIcon v-if="!modelValue" :class="icon" />
<InputIcon v-if="!props.modelValue" :class="props.icon" />
<Button
v-if="modelValue"
v-if="props.modelValue"
class="p-inputicon clear-button"
icon="pi pi-times"
text
@@ -47,36 +47,40 @@ import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { toRefs } from 'vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const {
modelValue,
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = []
} = defineProps<{
modelValue: string
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
}>()
const props = withDefaults(
defineProps<{
class?: string
modelValue: string
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
}>(),
{
placeholder: 'Search...',
icon: 'pi pi-search',
debounceTime: 300
}
)
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const { filters } = toRefs(props)
const emit = defineEmits([
'update:modelValue',
'search',
'showFilter',
'removeFilter'
])
const emitSearch = debounce((value: string) => {
emit('search', value, filters)
}, debounceTime)
emit('search', value, props.filters)
}, props.debounceTime)
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement

View File

@@ -19,7 +19,6 @@
v-for="device in props.stats.devices"
:key="device.index"
:header="device.name"
:value="device.index"
>
<DeviceInfo :device="device" />
</TabPanel>

View File

@@ -8,12 +8,9 @@
selectionMode="single"
:pt="{
nodeLabel: 'tree-explorer-node-label',
nodeContent: ({ context }) => ({
class: 'group/tree-node',
onClick: (e: MouseEvent) =>
onNodeContentClick(e, context.node as RenderedTreeExplorerNode),
onContextmenu: (e: MouseEvent) =>
handleContextMenu(e, context.node as RenderedTreeExplorerNode)
nodeContent: ({ props }) => ({
onClick: (e: MouseEvent) => onNodeContentClick(e, props.node),
onContextmenu: (e: MouseEvent) => handleContextMenu(props.node, e)
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
@@ -70,7 +67,7 @@ const renderedRoots = computed<RenderedTreeExplorerNode[]>(() => {
})
const getTreeNodeIcon = (node: TreeExplorerNode) => {
if (node.getIcon) {
const icon = node.getIcon()
const icon = node.getIcon(node)
if (icon) {
return icon
}
@@ -95,7 +92,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves,
badgeText: node.getBadgeText ? node.getBadgeText() : null
badgeText: node.getBadgeText ? node.getBadgeText(node) : null
}
}
const onNodeContentClick = async (
@@ -106,7 +103,7 @@ const onNodeContentClick = async (
selectionKeys.value = {}
}
if (node.handleClick) {
await node.handleClick(e)
await node.handleClick(node, e)
}
emit('nodeClick', node, e)
}
@@ -128,7 +125,7 @@ const renameCommand = (node: RenderedTreeExplorerNode) => {
renameEditingNode.value = node
}
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
await node.handleDelete?.()
await node.handleDelete?.(node)
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() =>
@@ -155,7 +152,7 @@ const menuItems = computed<MenuItem[]>(() =>
}))
)
const handleContextMenu = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
if (menuItems.value.filter((item) => item.visible).length > 0) {

View File

@@ -27,16 +27,13 @@
class="leaf-count-badge"
/>
</div>
<div
class="node-actions motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
>
<div class="node-actions">
<slot name="actions" :node="props.node"></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'
import Badge from 'primevue/badge'
import { Ref, computed, inject, ref } from 'vue'
@@ -83,7 +80,7 @@ const isEditing = computed(
const errorHandling = useErrorHandling()
const handleRename = errorHandling.wrapWithErrorHandlingAsync(
async (newName: string) => {
await props.node.handleRename(newName)
await props.node.handleRename(props.node, newName)
},
props.node.handleError,
() => {
@@ -105,17 +102,7 @@ if (props.node.draggable) {
}
},
onDragStart: () => emit('dragStart', props.node),
onDrop: () => emit('dragEnd', props.node),
onGenerateDragPreview: props.node.renderDragPreview
? ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render: ({ container }) => {
return props.node.renderDragPreview(container)
},
nativeSetDragImage
})
}
: undefined
onDrop: () => emit('dragEnd', props.node)
})
}
@@ -124,7 +111,7 @@ if (props.node.droppable) {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
await props.node.handleDrop?.(dndData)
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}

View File

@@ -1,116 +0,0 @@
<template>
<IconField class="w-full">
<InputText
v-bind="$attrs"
:model-value="internalValue"
class="w-full"
:invalid="validationState === ValidationState.INVALID"
@update:model-value="handleInput"
@blur="handleBlur"
/>
<InputIcon
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500 cursor-pointer':
validationState === ValidationState.VALID,
'pi pi-times text-red-500 cursor-pointer':
validationState === ValidationState.INVALID
}"
@click="validateUrl(props.modelValue)"
/>
</IconField>
</template>
<script setup lang="ts">
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { onMounted, ref, watch } from 'vue'
import { isValidUrl } from '@/utils/formatUtil'
import { checkUrlReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const props = defineProps<{
modelValue: string
validateUrlFn?: (url: string) => Promise<boolean>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'state-change': [state: ValidationState]
}>()
const validationState = ref<ValidationState>(ValidationState.IDLE)
// Add internal value state
const internalValue = ref(props.modelValue)
// Watch for external modelValue changes
watch(
() => props.modelValue,
async (newValue: string) => {
internalValue.value = newValue
await validateUrl(newValue)
}
)
watch(validationState, (newState) => {
emit('state-change', newState)
})
// Validate on mount
onMounted(async () => {
await validateUrl(props.modelValue)
})
const handleInput = (value: string) => {
// Update internal value without emitting
internalValue.value = value
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}
const handleBlur = async () => {
// Emit the update only on blur
emit('update:modelValue', internalValue.value)
}
// Default validation implementation
const defaultValidateUrl = async (url: string): Promise<boolean> => {
if (!isValidUrl(url)) return false
try {
return await checkUrlReachable(url)
} catch {
return false
}
}
const validateUrl = async (value: string) => {
if (validationState.value === ValidationState.LOADING) return
const url = value.trim()
// Reset state
validationState.value = ValidationState.IDLE
// Skip validation if empty
if (!url) return
validationState.value = ValidationState.LOADING
try {
const isValid = await (props.validateUrlFn ?? defaultValidateUrl)(url)
validationState.value = isValid
? ValidationState.VALID
: ValidationState.INVALID
} catch {
validationState.value = ValidationState.INVALID
}
}
// Add inheritAttrs option to prevent attrs from being applied to root element
defineOptions({
inheritAttrs: false
})
</script>

View File

@@ -1,158 +0,0 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from '../UrlInput.vue'
describe('UrlInput', () => {
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props: any, options = {}) => {
return mount(UrlInput, {
global: {
plugins: [PrimeVue],
components: { IconField, InputIcon, InputText }
},
props,
...options
})
}
it('passes through additional attributes to input element', () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
disabled: true
})
expect(wrapper.find('input').attributes('disabled')).toBe('')
})
it('emits update:modelValue on blur', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL'
})
const input = wrapper.find('input')
await input.setValue('https://test.com')
await input.trigger('blur')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([
'https://test.com'
])
})
it('renders spinner when validation is loading', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () =>
new Promise(() => {
// Never resolves, simulating perpetual loading state
})
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-spinner').exists()).toBe(true)
})
it('renders check icon when validation is valid', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(true)
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
})
it('renders cross icon when validation is invalid', async () => {
const wrapper = mountComponent({
modelValue: '',
placeholder: 'Enter URL',
validateUrlFn: () => Promise.resolve(false)
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
expect(wrapper.find('.pi-times').exists()).toBe(true)
})
it('validates on mount', async () => {
const wrapper = mountComponent({
modelValue: 'https://test.com',
validateUrlFn: () => Promise.resolve(true)
})
await nextTick()
await nextTick()
expect(wrapper.find('.pi-check').exists()).toBe(true)
})
it('triggers validation when clicking the validation icon', async () => {
let validationCount = 0
const wrapper = mountComponent({
modelValue: 'https://test.com',
validateUrlFn: () => {
validationCount++
return Promise.resolve(true)
}
})
// Wait for initial validation
await nextTick()
await nextTick()
// Click the validation icon
await wrapper.find('.pi-check').trigger('click')
await nextTick()
await nextTick()
expect(validationCount).toBe(2) // Once on mount, once on click
})
it('prevents multiple simultaneous validations', async () => {
let validationCount = 0
const wrapper = mountComponent({
modelValue: '',
validateUrlFn: () => {
validationCount++
return new Promise(() => {
// Never resolves, simulating perpetual loading state
})
}
})
wrapper.setProps({ modelValue: 'https://test.com' })
await nextTick()
await nextTick()
// Trigger multiple validations in quick succession
wrapper.find('.pi-spinner').trigger('click')
wrapper.find('.pi-spinner').trigger('click')
wrapper.find('.pi-spinner').trigger('click')
await nextTick()
await nextTick()
expect(validationCount).toBe(1) // Only the initial validation should occur
})
})

View File

@@ -28,7 +28,6 @@
</template>
<ReportIssuePanel
v-if="sendReportOpen"
:title="$t('issueReport.submitErrorReport')"
error-type="graphExecutionError"
:extra-fields="[stackTraceField]"
:tags="{ exceptionMessage: props.error.exception_message }"
@@ -90,10 +89,10 @@ const stackTraceField = computed<ReportField>(() => {
label: t('issueReport.stackTrace'),
value: 'StackTrace',
optIn: true,
getData: () => ({
data: {
nodeType: props.error.node_type,
stackTrace: props.error.traceback?.join('\n')
})
}
}
})

View File

@@ -1,31 +0,0 @@
<template>
<div class="p-2 h-full" aria-labelledby="issue-report-title">
<Panel
:pt="{
root: 'border-none',
content: 'p-0'
}"
>
<template #header>
<header class="flex flex-col items-center w-full">
<h2 id="issue-report-title" class="text-4xl">{{ title }}</h2>
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
</header>
</template>
<ReportIssuePanel v-bind="panelProps" :pt="{ root: 'border-none' }" />
</Panel>
</div>
</template>
<script setup lang="ts">
import Panel from 'primevue/panel'
import ReportIssuePanel from '@/components/dialog/content/error/ReportIssuePanel.vue'
import type { IssueReportPanelProps } from '@/types/issueReportTypes'
defineProps<{
title: string
subtitle?: string
panelProps: IssueReportPanelProps
}>()
</script>

View File

@@ -2,15 +2,9 @@
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="t('missingModelsDialog.missingModels')"
:message="t('missingModelsDialog.missingModelsMessage')"
title="Missing Models"
message="When loading the graph, the following models were not found"
/>
<div class="flex gap-1 mb-4">
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
<label for="doNotAskAgain">{{
t('missingModelsDialog.doNotAskAgain')
}}</label>
</div>
<ListBox :options="missingModels" class="comfy-missing-models">
<template #option="{ option }">
<Suspense v-if="isElectron()">
@@ -31,14 +25,11 @@
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import { computed, onBeforeUnmount, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, ref } from 'vue'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useSettingStore } from '@/stores/settingStore'
import { isElectron } from '@/utils/envUtil'
// TODO: Read this from server internal API rather than hardcoding here
@@ -67,10 +58,6 @@ const props = defineProps<{
paths: Record<string, string[]>
}>()
const { t } = useI18n()
const doNotAskAgain = ref(false)
const modelDownloads = ref<Record<string, ModelInfo>>({})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
@@ -120,12 +107,6 @@ const missingModels = computed(() => {
}
})
})
onBeforeUnmount(() => {
if (doNotAskAgain.value) {
useSettingStore().set('Comfy.Workflow.ShowMissingModelsWarning', false)
}
})
</script>
<style scoped>

View File

@@ -1,252 +1,201 @@
<template>
<Form
v-slot="$form"
@submit="submit"
:resolver="zodResolver(issueReportSchema)"
>
<Panel :pt="$attrs.pt">
<template #header>
<div class="flex items-center gap-2">
<span class="font-bold">{{ title }}</span>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-4">
<Button
v-tooltip="!submitted ? $t('g.reportIssueTooltip') : undefined"
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
:severity="submitted ? 'secondary' : 'primary'"
:icon="submitted ? 'pi pi-check' : 'pi pi-send'"
:disabled="submitted"
type="submit"
/>
</div>
</template>
<div class="p-4 mt-2 border border-round surface-border shadow-1">
<div class="flex flex-row gap-3 mb-2">
<div v-for="field in fields" :key="field.value">
<FormField
v-if="field.optIn"
v-slot="$field"
:name="field.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
:inputId="field.value"
:value="field.value"
v-model="selection"
/>
<label :for="field.value">{{ field.label }}</label>
</FormField>
</div>
</div>
<FormField class="mb-4" v-slot="$field" name="details">
<Textarea
v-bind="$field"
class="w-full"
rows="5"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
</Message>
</FormField>
<FormField v-slot="$field" name="contactInfo">
<InputText
v-bind="$field"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value !== ''"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.invalidEmail') }}
</Message>
</FormField>
<div class="flex flex-row gap-3 mt-2">
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
<FormField
v-slot="$field"
:name="checkbox.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
:inputId="checkbox.value"
:value="checkbox.value"
v-model="contactPrefs"
:disabled="
$form.contactInfo?.error || !$form.contactInfo?.value
"
/>
<label :for="checkbox.value">{{ checkbox.label }}</label>
</FormField>
</div>
</div>
<Panel>
<template #header>
<div class="flex items-center gap-2">
<span class="font-bold">{{ $t('issueReport.submitErrorReport') }}</span>
</div>
</Panel>
</Form>
</template>
<template #footer>
<div class="flex justify-end">
<Button
v-tooltip="$t('g.reportIssueTooltip')"
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
:severity="isButtonDisabled ? 'secondary' : 'primary'"
:icon="icon"
:disabled="isButtonDisabled"
@click="reportIssue"
/>
</div>
</template>
<div class="p-4 mt-4 border border-round surface-border shadow-1">
<CheckboxGroup
v-model="selection"
class="gap-4 mb-4"
:checkboxes="reportCheckboxes"
/>
<div class="mb-4">
<InputText
v-model="contactInfo"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
:maxlength="CONTACT_MAX_LEN"
/>
<CheckboxGroup
v-model="contactPrefs"
class="gap-3 mt-2"
:checkboxes="contactCheckboxes"
/>
</div>
<div class="mb-4">
<Textarea
v-model="details"
class="w-full"
rows="4"
:maxlength="DETAILS_MAX_LEN"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
</div>
</div>
</Panel>
</template>
<script setup lang="ts">
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'
import _ from 'lodash'
import cloneDeep from 'lodash/cloneDeep'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Panel from 'primevue/panel'
import Textarea from 'primevue/textarea'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import {
type IssueReportFormData,
type ReportField,
issueReportSchema
} from '@/types/issueReportTypes'
import type {
DefaultField,
IssueReportPanelProps
} from '@/types/issueReportTypes'
import { isElectron } from '@/utils/envUtil'
import type { DefaultField, ReportField } from '@/types/issueReportTypes'
const ISSUE_NAME = 'User reported issue'
const DETAILS_MAX_LEN = 5_000
const CONTACT_MAX_LEN = 320
const props = defineProps<IssueReportPanelProps>()
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
props
const props = defineProps<{
errorType: string
defaultFields?: DefaultField[]
extraFields?: ReportField[]
tags?: Record<string, string>
}>()
const {
defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'],
tags = {}
} = props
const { t } = useI18n()
const toast = useToast()
const selection = ref<string[]>([])
const contactPrefs = ref<string[]>([])
const contactInfo = ref('')
const details = ref('')
const submitting = ref(false)
const submitted = ref(false)
const followUp = computed(() => contactPrefs.value.includes('FollowUp'))
const notifyResolve = computed(() => contactPrefs.value.includes('Resolution'))
const icon = computed(() => {
if (submitting.value) return 'pi pi-spin pi-spinner'
if (submitted.value) return 'pi pi-check'
return 'pi pi-send'
})
const isFormEmpty = computed(() => !selection.value.length && !details.value)
const isButtonDisabled = computed(
() => submitted.value || submitting.value || isFormEmpty.value
)
const contactCheckboxes = [
{ label: t('issueReport.contactFollowUp'), value: 'followUp' },
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
{ label: t('issueReport.contactFollowUp'), value: 'FollowUp' },
{ label: t('issueReport.notifyResolve'), value: 'Resolution' }
]
const defaultFieldsConfig: ReportField[] = [
{
label: t('issueReport.systemStats'),
value: 'SystemStats',
getData: () => api.getSystemStats(),
optIn: true
},
{
label: t('g.workflow'),
value: 'Workflow',
getData: () => cloneDeep(app.graph.asSerialisable()),
optIn: true
},
{
label: t('g.logs'),
value: 'Logs',
getData: () => api.getLogs(),
optIn: true
},
{
label: t('g.settings'),
value: 'Settings',
getData: () => api.getSettings(),
optIn: true
}
const defaultReportCheckboxes = [
{ label: t('g.workflow'), value: 'Workflow' },
{ label: t('g.logs'), value: 'Logs' },
{ label: t('issueReport.systemStats'), value: 'SystemStats' },
{ label: t('g.settings'), value: 'Settings' }
]
const fields = computed(() => [
...defaultFieldsConfig.filter(({ value }) =>
const reportCheckboxes = computed(() => [
...(props.extraFields?.map(({ label, value }) => ({ label, value })) ?? []),
...defaultReportCheckboxes.filter(({ value }) =>
defaultFields.includes(value as DefaultField)
),
...(props.extraFields ?? [])
)
])
const createUser = (formData: IssueReportFormData): User => ({
email: formData.contactInfo || undefined
})
const getUserInfo = (): User => ({ email: contactInfo.value })
const createExtraData = async (formData: IssueReportFormData) => {
const result = {}
const isChecked = (fieldValue: string) => formData[fieldValue]
const getLogs = async () =>
selection.value.includes('Logs') ? api.getLogs() : null
await Promise.all(
fields.value
.filter((field) => !field.optIn || isChecked(field.value))
.map(async (field) => {
try {
result[field.value] = await field.getData()
} catch (error) {
console.error(`Failed to collect ${field.value}:`, error)
result[field.value] = { error: String(error) }
}
})
)
const getSystemStats = async () =>
selection.value.includes('SystemStats') ? api.getSystemStats() : null
return result
const getSettings = async () =>
selection.value.includes('Settings') ? api.getSettings() : null
const getWorkflow = () =>
selection.value.includes('Workflow')
? cloneDeep(app.graph.asSerialisable())
: null
const createDefaultFields = async () => {
const [settings, systemStats, logs, workflow] = await Promise.all([
getSettings(),
getSystemStats(),
getLogs(),
getWorkflow()
])
return { settings, systemStats, logs, workflow }
}
const createCaptureContext = async (
formData: IssueReportFormData
): Promise<CaptureContext> => {
const createExtraFields = (): Record<string, unknown> | undefined => {
if (!props.extraFields) return undefined
return props.extraFields
.filter((field) => !field.optIn || selection.value.includes(field.value))
.reduce((acc, field) => ({ ...acc, ...cloneDeep(field.data) }), {})
}
const createFeedback = () => {
return {
user: createUser(formData),
details: details.value,
contactPreferences: {
followUp: followUp.value,
notifyOnResolution: notifyResolve.value
}
}
}
const createCaptureContext = async (): Promise<CaptureContext> => {
return {
user: getUserInfo(),
level: 'error',
tags: {
errorType: props.errorType,
followUp: formData.contactInfo ? formData.followUp : false,
notifyOnResolution: formData.contactInfo
? formData.notifyOnResolution
: false,
isElectron: isElectron(),
..._.mapValues(props.tags, (tag) => _.trim(tag).replace(/[\n\r\t]/g, ' '))
...tags
},
extra: {
details: formData.details,
...(await createExtraData(formData))
...createFeedback(),
...(await createDefaultFields()),
...createExtraFields()
}
}
}
const submit = async (event: FormSubmitEvent) => {
if (event.valid) {
try {
const captureContext = await createCaptureContext(event.values)
captureMessage(ISSUE_NAME, captureContext)
submitted.value = true
toast.add({
severity: 'success',
summary: t('g.reportSent'),
life: 3000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.message,
life: 3000
})
}
const reportIssue = async () => {
if (isButtonDisabled.value) return
submitting.value = true
try {
captureMessage(ISSUE_NAME, await createCaptureContext())
submitted.value = true
toast.add({
severity: 'success',
summary: t('g.reportSent'),
life: 3000
})
} finally {
submitting.value = false
}
}
</script>

View File

@@ -1,51 +1,26 @@
// @ts-strict-ignore
import { Form } from '@primevue/forms'
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Checkbox from 'primevue/checkbox'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Panel from 'primevue/panel'
import Textarea from 'primevue/textarea'
import Tooltip from 'primevue/tooltip'
import { describe, expect, it, vi } from 'vitest'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
import enMesages from '@/locales/en/main.json'
import { IssueReportPanelProps } from '@/types/issueReportTypes'
import { DefaultField, ReportField } from '@/types/issueReportTypes'
import ReportIssuePanel from '../ReportIssuePanel.vue'
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
const CUSTOM_FIELDS = [
{
label: 'Custom Field',
value: 'CustomField',
optIn: true,
getData: () => 'mock data'
}
]
async function getSubmittedContext() {
const { captureMessage } = (await import('@sentry/core')) as any
return captureMessage.mock.calls[0][1]
}
async function submitForm(wrapper: any) {
await wrapper.findComponent(Form).trigger('submit')
return getSubmittedContext()
}
async function findAndUpdateCheckbox(
wrapper: any,
value: string,
checked = true
) {
const checkbox = wrapper
.findAllComponents(Checkbox)
.find((c: any) => c.props('value') === value)
if (!checkbox) throw new Error(`Checkbox with value "${value}" not found`)
await checkbox.vm.$emit('update:modelValue', checked)
return checkbox
type ReportIssuePanelProps = {
errorType: string
defaultFields?: DefaultField[]
extraFields?: ReportField[]
}
const i18n = createI18n({
@@ -82,64 +57,18 @@ vi.mock('@sentry/core', () => ({
captureMessage: vi.fn()
}))
vi.mock('@primevue/forms', () => ({
Form: {
name: 'Form',
template:
'<form @submit.prevent="onSubmit"><slot :values="formValues" /></form>',
props: ['resolver'],
data() {
return {
formValues: {}
}
},
methods: {
onSubmit() {
this.$emit('submit', {
valid: true,
values: this.formValues
})
},
updateFieldValue(name: string, value: any) {
this.formValues[name] = value
}
}
},
FormField: {
name: 'FormField',
template:
'<div><slot :modelValue="modelValue" @update:modelValue="updateValue" /></div>',
props: ['name'],
data() {
return {
modelValue: ''
}
},
methods: {
updateValue(value) {
this.modelValue = value
let parent = this.$parent
while (parent && parent.$options.name !== 'Form') {
parent = parent.$parent
}
if (parent) {
parent.updateFieldValue(this.name, value)
}
}
}
}
}))
describe('ReportIssuePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
const mountComponent = (props: ReportIssuePanelProps, options = {}): any => {
return mount(ReportIssuePanel, {
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }
plugins: [PrimeVue, createTestingPinia(), i18n],
directives: { tooltip: Tooltip },
components: { InputText, Button, Panel, Textarea, CheckboxGroup }
},
props,
...options
@@ -149,66 +78,44 @@ describe('ReportIssuePanel', () => {
it('renders the panel with all required components', () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
expect(wrapper.find('.p-panel').exists()).toBe(true)
expect(wrapper.findAllComponents(Checkbox).length).toBe(6)
expect(wrapper.findAllComponents(CheckboxGroup).length).toBe(2)
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Textarea).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('updates selection when checkboxes are selected', async () => {
const wrapper = mountComponent({
errorType: 'Test Error'
})
const checkboxes = wrapper.findAllComponents(Checkbox)
for (const field of DEFAULT_FIELDS) {
const checkbox = checkboxes.find(
(checkbox) => checkbox.props('value') === field
)
expect(checkbox).toBeDefined()
await checkbox?.vm.$emit('update:modelValue', [field])
expect(wrapper.vm.selection).toContain(field)
}
const wrapper = mountComponent({ errorType: 'Test Error' })
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.setValue(['Workflow', 'Logs'])
expect(wrapper.vm.selection).toEqual(['Workflow', 'Logs'])
})
it('updates contactInfo when input is changed', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const input = wrapper.findComponent(InputText)
await input.vm.$emit('update:modelValue', 'test@example.com')
const context = await submitForm(wrapper)
expect(context.user.email).toBe('test@example.com')
await input.setValue('test@example.com')
expect(wrapper.vm.contactInfo).toBe('test@example.com')
})
it('updates additional details when textarea is changed', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const textarea = wrapper.findComponent(Textarea)
await textarea.vm.$emit('update:modelValue', 'This is a test detail.')
const context = await submitForm(wrapper)
expect(context.extra.details).toBe('This is a test detail.')
await textarea.setValue('This is a test detail.')
expect(wrapper.vm.details).toBe('This is a test detail.')
})
it('set contact preferences back to false if email is removed', async () => {
it('updates contactPrefs when preferences are selected', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const input = wrapper.findComponent(InputText)
const preferences = wrapper.findAllComponents(CheckboxGroup).at(1)
await preferences?.setValue(['FollowUp'])
expect(wrapper.vm.contactPrefs).toEqual(['FollowUp'])
})
// Set a valid email, enabling the contact preferences to be changed
await input.vm.$emit('update:modelValue', 'name@example.com')
// Enable both contact preferences
for (const pref of ['followUp', 'notifyOnResolution']) {
await findAndUpdateCheckbox(wrapper, pref)
}
// Change the email back to empty
await input.vm.$emit('update:modelValue', '')
const context = await submitForm(wrapper)
// Check that the contact preferences are back to false automatically
expect(context.tags.followUp).toBe(false)
expect(context.tags.notifyOnResolution).toBe(false)
it('does not allow submission if the form is empty', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
await wrapper.vm.reportIssue()
expect(wrapper.vm.submitted).toBe(false)
})
it('renders with overridden default fields', () => {
@@ -216,87 +123,83 @@ describe('ReportIssuePanel', () => {
errorType: 'Test Error',
defaultFields: ['Settings']
})
// Filter out the contact preferences checkboxes
const fieldCheckboxes = wrapper
.findAllComponents(Checkbox)
.filter(
(checkbox) =>
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
)
expect(fieldCheckboxes.length).toBe(1)
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
expect(checkboxes?.props('checkboxes')).toEqual([
{ label: 'Settings', value: 'Settings' }
])
})
it('renders additional fields when extraFields prop is provided', () => {
const wrapper = mountComponent({
errorType: 'Test Error',
extraFields: CUSTOM_FIELDS
const extraFields = [
{ label: 'Custom Field', value: 'CustomField', optIn: true, data: {} }
]
const wrapper = mountComponent({ errorType: 'Test Error', extraFields })
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
expect(checkboxes?.props('checkboxes')).toContainEqual({
label: 'Custom Field',
value: 'CustomField'
})
const customCheckbox = wrapper
.findAllComponents(Checkbox)
.find((checkbox) => checkbox.props('value') === 'CustomField')
expect(customCheckbox).toBeDefined()
})
it('allows custom fields to be selected', async () => {
const wrapper = mountComponent({
errorType: 'Test Error',
extraFields: CUSTOM_FIELDS
})
await findAndUpdateCheckbox(wrapper, 'CustomField')
const context = await submitForm(wrapper)
expect(context.extra.CustomField).toBe('mock data')
})
it('does not submit unchecked fields', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const textarea = wrapper.findComponent(Textarea)
// Set details but don't check any field checkboxes
await textarea.vm.$emit(
'update:modelValue',
'Report with only text but no fields selected'
)
const context = await submitForm(wrapper)
await textarea.setValue('Report with only text but no fields selected')
await wrapper.vm.reportIssue()
// Verify none of the optional fields were included
for (const field of DEFAULT_FIELDS) {
expect(context.extra[field]).toBeUndefined()
}
const { captureMessage } = (await import('@sentry/core')) as any
const captureContext = captureMessage.mock.calls[0][1]
expect(captureContext.extra.logs).toBeNull()
expect(captureContext.extra.systemStats).toBeNull()
expect(captureContext.extra.settings).toBeNull()
expect(captureContext.extra.workflow).toBeNull()
})
it.each([
{
checkbox: 'Logs',
apiMethod: 'getLogs',
expectedKey: 'Logs',
expectedKey: 'logs',
mockValue: 'mock logs'
},
{
checkbox: 'SystemStats',
apiMethod: 'getSystemStats',
expectedKey: 'SystemStats',
expectedKey: 'systemStats',
mockValue: 'mock stats'
},
{
checkbox: 'Settings',
apiMethod: 'getSettings',
expectedKey: 'Settings',
expectedKey: 'settings',
mockValue: 'mock settings'
}
])(
'submits $checkbox data when checkbox is selected',
'submits (%s) when the (%s) checkbox is selected',
async ({ checkbox, apiMethod, expectedKey, mockValue }) => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const { api } = (await import('@/scripts/api')) as any
vi.spyOn(api, apiMethod).mockResolvedValue(mockValue)
await findAndUpdateCheckbox(wrapper, checkbox)
const context = await submitForm(wrapper)
expect(context.extra[expectedKey]).toBe(mockValue)
const { captureMessage } = await import('@sentry/core')
// Select the checkbox
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.vm.$emit('update:modelValue', [checkbox])
await wrapper.vm.reportIssue()
expect(api[apiMethod]).toHaveBeenCalled()
// Verify the message includes the associated data
expect(captureMessage).toHaveBeenCalledWith(
'User reported issue',
expect.objectContaining({
extra: expect.objectContaining({ [expectedKey]: mockValue })
})
)
}
)
@@ -304,12 +207,24 @@ describe('ReportIssuePanel', () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const { app } = (await import('@/scripts/app')) as any
const { captureMessage } = await import('@sentry/core')
const mockWorkflow = { nodes: [], edges: [] }
vi.spyOn(app.graph, 'asSerialisable').mockReturnValue(mockWorkflow)
await findAndUpdateCheckbox(wrapper, 'Workflow')
const context = await submitForm(wrapper)
// Select the "Workflow" checkbox
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.vm.$emit('update:modelValue', ['Workflow'])
expect(context.extra.Workflow).toEqual(mockWorkflow)
await wrapper.vm.reportIssue()
expect(app.graph.asSerialisable).toHaveBeenCalled()
// Verify the message includes the workflow
expect(captureMessage).toHaveBeenCalledWith(
'User reported issue',
expect.objectContaining({
extra: expect.objectContaining({ workflow: mockWorkflow })
})
)
})
})

View File

@@ -13,13 +13,13 @@
optionValue="id"
/>
<Button
icon="pi pi-file-export"
icon="pi pi-upload"
text
:title="$t('g.export')"
@click="colorPaletteService.exportColorPalette(activePaletteId)"
/>
<Button
icon="pi pi-file-import"
icon="pi pi-download"
text
:title="$t('g.import')"
@click="importCustomPalette"

View File

@@ -68,7 +68,7 @@
class="min-w-96"
v-model:visible="editDialogVisible"
modal
:header="currentEditingCommand?.label"
:header="currentEditingCommand?.id"
@hide="cancelEdit"
>
<div>
@@ -151,7 +151,6 @@ const { t } = useI18n()
interface ICommandData {
id: string
keybinding: KeybindingImpl | null
label: string
}
const commandsData = computed<ICommandData[]>(() => {
@@ -214,17 +213,6 @@ function removeKeybinding(commandData: ICommandData) {
}
function captureKeybinding(event: KeyboardEvent) {
// Allow the use of keyboard shortcuts when adding keyboard shortcuts
if (!event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey) {
switch (event.key) {
case 'Escape':
cancelEdit()
return
case 'Enter':
saveKeybinding()
return
}
}
const keyCombo = KeyComboImpl.fromEvent(event)
newBindingKeyCombo.value = keyCombo
}

View File

@@ -29,7 +29,18 @@
</template>
<script setup lang="ts">
import { CanvasPointer, LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import {
CanvasPointer,
ContextMenu,
DragAndScale,
LGraph,
LGraphBadge,
LGraphCanvas,
LGraphGroup,
LGraphNode,
LLink,
LiteGraph
} from '@comfyorg/litegraph'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
@@ -42,30 +53,37 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { useCanvasDrop } from '@/hooks/canvasDropHooks'
import { useGlobalLitegraph } from '@/hooks/litegraphHooks'
import { useContextMenuTranslation } from '@/hooks/translationHooks'
import { useWorkflowPersistence } from '@/hooks/workflowPersistenceHooks'
import { i18n } from '@/i18n'
import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { ComfyModelDef } from '@/stores/modelStore'
import {
ModelNodeProvider,
useModelToNodeStore
} from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
const litegraphService = useLitegraphService()
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const modelToNodeStore = useModelToNodeStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -77,6 +95,29 @@ const canvasMenuEnabled = computed(() =>
)
const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
const storedWorkflows = JSON.parse(
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
)
const storedActiveIndex = JSON.parse(
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
)
const openWorkflows = computed(() => workspaceStore?.workflow?.openWorkflows)
const activeWorkflow = computed(() => workspaceStore?.workflow?.activeWorkflow)
const restoreState = computed<{ paths: string[]; activeIndex: number }>(() => {
if (!openWorkflows.value || !activeWorkflow.value) {
return { paths: [], activeIndex: -1 }
}
const paths = openWorkflows.value
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
.map((workflow) => workflow.path)
const activeIndex = openWorkflows.value.findIndex(
(workflow) => workflow.path === activeWorkflow.value?.path
)
return { paths, activeIndex }
})
watchEffect(() => {
const canvasInfoEnabled = settingStore.get('Comfy.Graph.CanvasInfo')
if (canvasStore.canvas) {
@@ -143,17 +184,6 @@ watchEffect(() => {
}
})
watchEffect(() => {
const lowQualityRenderingZoomThreshold = settingStore.get(
'LiteGraph.Canvas.LowQualityRenderingZoomThreshold'
)
if (canvasStore.canvas) {
canvasStore.canvas.low_quality_zoom_threshold =
lowQualityRenderingZoomThreshold
canvasStore.canvas.setDirty(/* fg */ true, /* bg */ true)
}
})
watchEffect(() => {
const linkMarkerShape = settingStore.get('Comfy.Graph.LinkMarkers')
const { canvas } = canvasStore
@@ -239,30 +269,98 @@ watch(
}
)
watchEffect(() => {
LiteGraph.context_menu_scaling = settingStore.get(
'LiteGraph.ContextMenu.Scaling'
)
})
const loadCustomNodesI18n = async () => {
try {
const i18nData = await api.getCustomNodesI18n()
Object.entries(i18nData).forEach(([locale, message]) => {
i18n.global.mergeLocaleMessage(locale, message)
})
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
const workflowStore = useWorkflowStore()
const persistCurrentWorkflow = () => {
const workflow = JSON.stringify(comfyApp.serializeGraph())
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
}
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
useCanvasDrop(canvasRef)
watchEffect(() => {
if (workflowStore.activeWorkflow) {
const workflow = workflowStore.activeWorkflow
setStorageValue('Comfy.PreviousWorkflow', workflow.key)
// When the activeWorkflow changes, the graph has already been loaded.
// Saving the current state of the graph to the localStorage.
persistCurrentWorkflow()
}
})
api.addEventListener('graphChanged', persistCurrentWorkflow)
usePragmaticDroppable(() => canvasRef.value, {
onDrop: (event) => {
const loc = event.location.current.input
const dndData = event.source.data
if (dndData.type === 'tree-explorer-node') {
const node = dndData.data as RenderedTreeExplorerNode
if (node.data instanceof ComfyNodeDefImpl) {
const nodeDef = node.data
// Add an offset on x to make sure after adding the node, the cursor
// is on the node (top left corner)
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX - 20,
loc.clientY
])
litegraphService.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const pos = comfyApp.clientPosToCanvasPos([loc.clientX, loc.clientY])
const nodeAtPos = comfyApp.graph.getNodeOnPos(pos[0], pos[1])
let targetProvider: ModelNodeProvider | null = null
let targetGraphNode: LGraphNode | null = null
if (nodeAtPos) {
const providers = modelToNodeStore.getAllNodeProviders(
model.directory
)
for (const provider of providers) {
if (provider.nodeDef.name === nodeAtPos.comfyClass) {
targetGraphNode = nodeAtPos
targetProvider = provider
}
}
}
if (!targetGraphNode) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
targetGraphNode = litegraphService.addNodeOnGraph(
provider.nodeDef,
{
pos
}
)
targetProvider = provider
}
}
if (targetGraphNode) {
const widget = targetGraphNode.widgets.find(
(widget) => widget.name === targetProvider.key
)
if (widget) {
widget.value = model.file_name
}
}
}
}
}
})
const comfyAppReady = ref(false)
onMounted(async () => {
useGlobalLitegraph()
useContextMenuTranslation()
// Backward compatible
// Assign all properties of lg to window
window['LiteGraph'] = LiteGraph
window['LGraph'] = LGraph
window['LLink'] = LLink
window['LGraphNode'] = LGraphNode
window['LGraphGroup'] = LGraphGroup
window['DragAndScale'] = DragAndScale
window['LGraphCanvas'] = LGraphCanvas
window['ContextMenu'] = ContextMenu
window['LGraphBadge'] = LGraphBadge
comfyApp.vueAppReady = true
@@ -270,7 +368,6 @@ onMounted(async () => {
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
ChangeTracker.init(comfyApp)
await loadCustomNodesI18n()
await settingStore.loadSettingValues()
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
@@ -290,9 +387,17 @@ onMounted(async () => {
'Comfy.CustomColorPalettes'
)
// Restore workflow and workflow tabs state from storage
await workflowPersistence.restorePreviousWorkflow()
workflowPersistence.restoreWorkflowTabsState()
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
if (isRestorable)
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
watch(restoreState, ({ paths, activeIndex }) => {
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
})
// Start watching for locale change after the initial value is loaded.
watch(

View File

@@ -17,12 +17,10 @@ import { nextTick, ref } from 'vue'
import { st } from '@/i18n'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const settingStore = useSettingStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipText = ref('')
const left = ref<string>()
@@ -112,10 +110,7 @@ const onMouseMove = (e: MouseEvent) => {
clearTimeout(idleTimeout)
if ((e.target as Node).nodeName !== 'CANVAS') return
idleTimeout = window.setTimeout(
onIdle,
settingStore.get('LiteGraph.Node.TooltipDelay')
)
idleTimeout = window.setTimeout(onIdle, 500)
}
useEventListener(window, 'mousemove', onMouseMove)

View File

@@ -130,8 +130,8 @@ import ToggleSwitch from 'primevue/toggleswitch'
import { ref } from 'vue'
const showDialog = ref(false)
const autoUpdate = defineModel<boolean>('autoUpdate', { required: true })
const allowMetrics = defineModel<boolean>('allowMetrics', { required: true })
const autoUpdate = defineModel('autoUpdate', { required: true })
const allowMetrics = defineModel('allowMetrics', { required: true })
const showMetricsInfo = () => {
showDialog.value = true

View File

@@ -1,114 +0,0 @@
<template>
<Panel
:header="$t('install.settings.mirrorSettings')"
toggleable
:collapsed="!showMirrorInputs"
pt:root="bg-neutral-800 border-none w-[600px]"
>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" />
<MirrorItem
:item="item"
v-model="modelValue.value"
@state-change="validationStates[index] = $event"
/>
</template>
<template #icons>
<i
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500':
validationState === ValidationState.VALID,
'pi pi-times text-red-500':
validationState === ValidationState.INVALID
}"
v-tooltip="validationStateTooltip"
/>
</template>
</Panel>
</template>
<script setup lang="ts">
import {
CUDA_TORCH_URL,
NIGHTLY_CPU_TORCH_URL,
TorchDeviceType
} from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
import { ModelRef, computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: NIGHTLY_CPU_TORCH_URL,
fallbackMirror: NIGHTLY_CPU_TORCH_URL
}
case 'nvidia':
return {
settingId,
mirror: CUDA_TORCH_URL,
fallbackMirror: CUDA_TORCH_URL
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() => [
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device), torchMirror]
])
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
const validationState = computed(() => {
return mergeValidationStates(validationStates.value)
})
const validationStateTooltip = computed(() => {
switch (validationState.value) {
case ValidationState.INVALID:
return t('install.settings.mirrorsUnreachable')
case ValidationState.VALID:
return t('install.settings.mirrorsReachable')
default:
return t('install.settings.checkingMirrors')
}
})
onMounted(async () => {
// Check if user is in China and set fallback mirrors directly
if (await isInChina()) {
for (const [item, modelValue] of mirrors.value) {
modelValue.value = item.fallbackMirror
}
}
})
</script>

View File

@@ -1,64 +0,0 @@
<template>
<div class="flex flex-col items-center gap-4">
<div class="w-full">
<h3 class="text-lg font-medium text-neutral-100">
{{ $t(`settings.${normalizedSettingId}.name`) }}
</h3>
<p class="text-sm text-neutral-400 mt-1">
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
</p>
</div>
<UrlInput
v-model="modelValue"
:validate-url-fn="
(mirror: string) =>
checkMirrorReachable(mirror + (item.validationPathSuffix ?? ''))
"
@state-change="validationState = $event"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
import { UVMirror } from '@/constants/uvMirrors'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const { item } = defineProps<{
item: UVMirror
}>()
const emit = defineEmits<{
'state-change': [state: ValidationState]
}>()
const modelValue = defineModel<string>('modelValue', { required: true })
const validationState = ref<ValidationState>(ValidationState.IDLE)
const normalizedSettingId = computed(() => {
return normalizeI18nKey(item.settingId)
})
onMounted(() => {
// Set mirror value if not already set
if (!modelValue.value) {
modelValue.value = item.mirror
}
})
watch(validationState, (newState) => {
emit('state-change', newState)
// Set fallback mirror if default mirror is invalid
if (
newState === ValidationState.INVALID &&
modelValue.value === item.mirror
) {
modelValue.value = item.fallbackMirror
}
})
</script>

View File

@@ -1,36 +0,0 @@
<template>
<Tag :icon :severity :value />
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import Tag from 'primevue/tag'
import { computed } from 'vue'
import { t } from '@/i18n'
// Properties
const props = defineProps<{
error: boolean
refreshing?: boolean
}>()
// Bindings
const icon = computed(() => {
if (props.refreshing) return PrimeIcons.QUESTION
if (props.error) return PrimeIcons.TIMES
return PrimeIcons.CHECK
})
const severity = computed(() => {
if (props.refreshing) return 'info'
if (props.error) return 'danger'
return 'success'
})
const value = computed(() => {
if (props.refreshing) return t('maintenance.refreshing')
if (props.error) return t('g.error')
return t('maintenance.OK')
})
</script>

View File

@@ -1,127 +0,0 @@
<template>
<div
class="task-div max-w-48 min-h-52 grid relative"
:class="{ 'opacity-75': isLoading }"
>
<Card
class="max-w-48 relative h-full overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
<i
v-if="runner.state === 'error'"
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
style="font-size: 10rem"
/>
<img
v-if="task.headerImg"
:src="task.headerImg"
class="object-contain w-full h-full opacity-25 pt-4 px-4"
/>
</template>
<template #title>{{ task.name }}</template>
<template #content>{{ description }}</template>
<template #footer>
<div class="flex gap-4 mt-1">
<Button
:icon="task.button?.icon"
:label="task.button?.text"
class="w-full"
raised
icon-pos="right"
@click="(event) => $emit('execute', event)"
:loading="isExecuting"
/>
</div>
</template>
</Card>
<i
v-if="!isLoading && runner.state === 'OK'"
class="task-card-ok pi pi-check"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Card from 'primevue/card'
import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
task: MaintenanceTask
}>()
// Events
defineEmits<{
execute: [event: MouseEvent]
}>()
// Bindings
const description = computed(() =>
runner.value.state === 'error'
? props.task.errorDescription ?? props.task.shortDescription
: props.task.shortDescription
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
</script>
<style scoped>
.task-card-ok {
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
font-size: 4rem;
text-shadow: 0.25rem 0 0.5rem black;
z-index: 10;
}
.p-card {
@apply transition-opacity;
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
&.opacity-65 {
opacity: 0.4;
}
&:hover {
opacity: 1;
}
}
:deep(.p-card-header) {
z-index: 0;
}
:deep(.p-card-body) {
z-index: 1;
flex-grow: 1;
justify-content: space-between;
}
.task-div {
> i {
pointer-events: none;
}
&:hover > i {
opacity: 0.2;
}
}
</style>

View File

@@ -1,86 +0,0 @@
<template>
<tr
class="border-neutral-700 border-solid border-y"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
<p class="inline-block">{{ task.name }}</p>
<Button
class="inline-block mx-2"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
:text="true"
@click="toggle"
/>
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="text-right px-4">
<Button
:icon="task.button?.icon"
:label="task.button?.text"
:severity
icon-pos="right"
@click="(event) => $emit('execute', event)"
:loading="isExecuting"
/>
</td>
</tr>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { VueSeverity } from '@/types/primeVueTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
import TaskListStatusIcon from './TaskListStatusIcon.vue'
const taskStore = useMaintenanceTaskStore()
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
task: MaintenanceTask
}>()
// Events
defineEmits<{
execute: [event: MouseEvent]
}>()
// Binding
const severity = computed<VueSeverity>(() =>
runner.value.state === 'error' || runner.value.state === 'warning'
? 'primary'
: 'secondary'
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
// Popover
const infoPopover = ref()
const toggle = (event: Event) => {
infoPopover.value.toggle(event)
}
</script>

View File

@@ -1,115 +0,0 @@
<template>
<!-- Tasks -->
<section class="my-4">
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="text-neutral-400 w-full text-center">
{{ $t('maintenance.allOk') }}
</p>
</template>
<template v-else>
<!-- Display: List -->
<table
v-if="displayAsList === PrimeIcons.LIST"
class="w-full border-collapse border-hidden"
>
<TaskListItem
v-for="task in filter.tasks"
:key="task.id"
:task
@execute="(event) => confirmButton(event, task)"
/>
</table>
<!-- Display: Cards -->
<template v-else>
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
:task
@execute="(event) => confirmButton(event, task)"
/>
</div>
</template>
</template>
<ConfirmPopup />
</section>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
MaintenanceTask
} from '@/types/desktop/maintenanceTypes'
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
// Properties
const props = defineProps<{
displayAsList: string
filter: MaintenanceFilter
isRefreshing: boolean
}>()
const executeTask = async (task: MaintenanceTask) => {
let message: string | undefined
try {
// Success
if ((await taskStore.execute(task)) === true) return
message = t('maintenance.error.taskFailed')
} catch (error) {
message = (error as Error)?.message
}
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
})
}
// Commands
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
if (!task.requireConfirm) {
await executeTask(task)
return
}
confirm.require({
target: event.currentTarget as HTMLElement,
message: task.confirmText ?? t('maintenance.confirmTitle'),
icon: 'pi pi-exclamation-circle',
rejectProps: {
label: t('g.cancel'),
severity: 'secondary',
outlined: true
},
acceptProps: {
label: task.button?.text ?? t('g.save'),
severity: task.severity ?? 'primary'
},
// TODO: Not awaited.
accept: async () => {
await executeTask(task)
}
})
}
</script>

View File

@@ -1,44 +0,0 @@
<template>
<ProgressSpinner v-if="!state || loading" class="h-8 w-8" />
<template v-else>
<i :class="cssClasses" v-tooltip.top="{ value: tooltip, showDelay: 250 }" />
</template>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import ProgressSpinner from 'primevue/progressspinner'
import { MaybeRef, computed } from 'vue'
import { t } from '@/i18n'
// Properties
const tooltip = computed(() => {
if (props.state === 'error') {
return t('g.error')
} else if (props.state === 'OK') {
return t('maintenance.OK')
} else {
return t('maintenance.Skipped')
}
})
const cssClasses = computed(() => {
let classes: string
if (props.state === 'error') {
classes = `${PrimeIcons.EXCLAMATION_TRIANGLE} text-red-500`
} else if (props.state === 'OK') {
classes = `${PrimeIcons.CHECK} text-green-500`
} else {
classes = PrimeIcons.MINUS
}
return `text-3xl pi ${classes}`
})
// Model
const props = defineProps<{
state: 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
loading?: MaybeRef<boolean>
}>()
</script>

View File

@@ -6,27 +6,11 @@ export default {
name: 'AutoCompletePlus',
extends: AutoComplete,
emits: ['focused-option-changed'],
data() {
return {
// Flag to determine if IME is active
isComposing: false
}
},
mounted() {
if (typeof AutoComplete.mounted === 'function') {
AutoComplete.mounted.call(this)
}
// Retrieve the actual <input> element and attach IME events
const inputEl = this.$el.querySelector('input')
if (inputEl) {
inputEl.addEventListener('compositionstart', () => {
this.isComposing = true
})
inputEl.addEventListener('compositionend', () => {
this.isComposing = false
})
}
// Add a watcher on the focusedOptionIndex property
this.$watch(
() => this.focusedOptionIndex,
@@ -35,18 +19,6 @@ export default {
this.$emit('focused-option-changed', newVal)
}
)
},
methods: {
// Override onKeyDown to block Enter when IME is active
onKeyDown(event) {
if (event.key === 'Enter' && this.isComposing) {
event.preventDefault()
event.stopPropagation()
return
}
AutoComplete.methods.onKeyDown.call(this, event)
}
}
}
</script>

View File

@@ -60,9 +60,8 @@
<!-- FilterAndValue -->
<template v-slot:chip="{ value }">
<SearchFilterChip
v-if="Array.isArray(value) && value.length === 2"
:key="`${value[0].id}-${value[1]}`"
@remove="onRemoveFilter($event, value as FilterAndValue)"
@remove="onRemoveFilter($event, value)"
:text="value[1]"
:badge="value[0].invokeSequence.toUpperCase()"
:badge-class="value[0].invokeSequence + '-badge'"

View File

@@ -64,7 +64,10 @@ import {
} from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useSettingStore } from '@/stores/settingStore'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { isElectron } from '@/utils/envUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -123,7 +126,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
: node.label,
leaf: node.leaf,
data: node.data,
getIcon() {
getIcon: () => {
if (model) {
return model.image ? 'pi pi-image' : 'pi pi-file'
}
@@ -134,7 +137,7 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
}
return 'pi pi-folder'
},
getBadgeText() {
getBadgeText: () => {
// Return null to apply default badge text
// Return empty string to hide badge
if (!folder) {
@@ -144,8 +147,11 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
},
children,
draggable: node.leaf,
handleClick(e: MouseEvent) {
if (this.leaf) {
handleClick: (
node: RenderedTreeExplorerNode<ModelOrFolder>,
e: MouseEvent
) => {
if (node.leaf) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const node = useLitegraphService().addNodeOnGraph(provider.nodeDef)

View File

@@ -66,12 +66,11 @@ import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
import type { TreeNode } from 'primevue/treenode'
import { Ref, computed, h, nextTick, ref, render } from 'vue'
import { Ref, computed, nextTick, ref } from 'vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { SearchFilter } from '@/components/common/SearchFilterChip.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
@@ -84,7 +83,10 @@ import {
buildNodeDefTree,
useNodeDefStore
} from '@/stores/nodeDefStore'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { sortedTree } from '@/utils/treeUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
@@ -116,25 +118,21 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
label: node.leaf ? node.data.display_name : node.label,
leaf: node.leaf,
data: node.data,
getIcon() {
if (this.leaf) {
getIcon: (node: TreeExplorerNode<ComfyNodeDefImpl>) => {
if (node.leaf) {
return 'pi pi-circle-fill'
}
},
children,
draggable: node.leaf,
renderDragPreview(container) {
const vnode = h(NodePreview, { nodeDef: node.data })
render(vnode, container)
return () => {
render(null, container)
}
},
handleClick(e: MouseEvent) {
if (this.leaf) {
useLitegraphService().addNodeOnGraph(this.data)
handleClick: (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
useLitegraphService().addNodeOnGraph(node.data)
} else {
toggleNodeOnEvent(e, this)
toggleNodeOnEvent(e, node)
}
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div
class="comfy-vue-side-bar-container flex flex-col h-full group/sidebar-tab"
class="comfy-vue-side-bar-container flex flex-col h-full"
:class="props.class"
>
<div class="comfy-vue-side-bar-header">
@@ -11,11 +11,7 @@
</span>
</template>
<template #end>
<div
class="flex flex-row motion-safe:w-0 motion-safe:opacity-0 motion-safe:group-hover/sidebar-tab:w-auto motion-safe:group-hover/sidebar-tab:opacity-100 motion-safe:group-focus-within/sidebar-tab:w-auto motion-safe:group-focus-within/sidebar-tab:opacity-100 touch:w-auto touch:opacity-100 transition-all duration-200"
>
<slot name="tool-buttons"></slot>
</div>
<slot name="tool-buttons"></slot>
</template>
</Toolbar>
<slot name="header"></slot>

View File

@@ -7,7 +7,6 @@
<Button
class="browse-templates-button"
icon="pi pi-th-large"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.browseTemplates')"
text
@click="() => commandStore.execute('Comfy.BrowseTemplates')"
@@ -15,7 +14,6 @@
<Button
class="open-workflow-button"
icon="pi pi-folder-open"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.openWorkflow')"
text
@click="() => commandStore.execute('Comfy.OpenWorkflow')"
@@ -23,7 +21,6 @@
<Button
class="new-blank-workflow-button"
icon="pi pi-plus"
severity="secondary"
v-tooltip.bottom="$t('sideToolbar.newBlankWorkflow')"
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
text
@@ -245,18 +242,24 @@ const renderTreeNode = (
const workflow: ComfyWorkflow = node.data
function handleClick(this: TreeExplorerNode<ComfyWorkflow>, e: MouseEvent) {
if (this.leaf) {
const handleClick = (
node: TreeExplorerNode<ComfyWorkflow>,
e: MouseEvent
) => {
if (node.leaf) {
workflowService.openWorkflow(workflow)
} else {
toggleNodeOnEvent(e, this)
toggleNodeOnEvent(e, node)
}
}
const actions = node.leaf
? {
handleClick,
async handleRename(newName: string) {
handleRename: async (
node: TreeExplorerNode<ComfyWorkflow>,
newName: string
) => {
const newPath =
type === WorkflowTreeType.Browse
? workflow.directory + '/' + appendJsonExt(newName)
@@ -266,10 +269,10 @@ const renderTreeNode = (
},
handleDelete: workflow.isTemporary
? undefined
: async function () {
: async () => {
await workflowService.deleteWorkflow(workflow)
},
contextMenuItems() {
contextMenuItems: (node: TreeExplorerNode<ComfyWorkflow>) => {
return [
{
label: t('g.insert'),

View File

@@ -139,8 +139,8 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
label: node.leaf ? node.data.display_name : node.label,
leaf: node.leaf,
data: node.data,
getIcon() {
if (this.leaf) {
getIcon: (node: TreeExplorerNode<ComfyNodeDefImpl>) => {
if (node.leaf) {
return 'pi pi-circle-fill'
}
const customization =
@@ -152,7 +152,10 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
children: sortedChildren,
draggable: node.leaf,
droppable: !node.leaf,
handleDrop(data: TreeExplorerDragAndDropData<ComfyNodeDefImpl>) {
handleDrop: (
node: TreeExplorerNode<ComfyNodeDefImpl>,
data: TreeExplorerDragAndDropData<ComfyNodeDefImpl>
) => {
const nodeDefToAdd = data.data.data
// Remove bookmark if the source is the top level bookmarked node.
if (nodeBookmarkStore.isBookmarked(nodeDefToAdd)) {
@@ -162,9 +165,12 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
const nodePath = folderNodeDef.category + '/' + nodeDefToAdd.name
nodeBookmarkStore.addBookmark(nodePath)
},
handleClick(e: MouseEvent) {
if (this.leaf) {
useLitegraphService().addNodeOnGraph(this.data)
handleClick: (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
useLitegraphService().addNodeOnGraph(node.data)
} else {
toggleNodeOnEvent(e, node)
}
@@ -173,13 +179,9 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
...(node.leaf
? {}
: {
handleRename(newName: string) {
if (this.data && this.data.isDummyFolder) {
nodeBookmarkStore.renameBookmarkFolder(this.data, newName)
}
},
handleDelete() {
nodeBookmarkStore.deleteBookmarkFolder(this.data)
handleRename,
handleDelete: (node: TreeExplorerNode<ComfyNodeDefImpl>) => {
nodeBookmarkStore.deleteBookmarkFolder(node.data)
}
})
}
@@ -210,6 +212,12 @@ defineExpose({
addNewBookmarkFolder
})
const handleRename = (node: TreeNode, newName: string) => {
if (node.data && node.data.isDummyFolder) {
nodeBookmarkStore.renameBookmarkFolder(node.data, newName)
}
}
const showCustomizationDialog = ref(false)
const initialIcon = ref(nodeBookmarkStore.defaultBookmarkIcon)
const initialColor = ref(nodeBookmarkStore.defaultBookmarkColor)

View File

@@ -33,7 +33,7 @@
<!-- Virtual top menu for native window (drag handle) -->
<div
v-show="isNativeWindow() && !showTopMenu"
v-show="isNativeWindow && !showTopMenu"
class="fixed top-0 left-0 app-drag w-full h-[var(--comfy-topbar-height)]"
/>
</template>
@@ -50,12 +50,7 @@ import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { app } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
electronAPI,
isElectron,
isNativeWindow,
showNativeMenu
} from '@/utils/envUtil'
import { electronAPI, isElectron, showNativeMenu } from '@/utils/envUtil'
const workspaceState = useWorkspaceStore()
const settingStore = useSettingStore()
@@ -69,6 +64,10 @@ const teleportTarget = computed(() =>
? '.comfyui-body-top'
: '.comfyui-body-bottom'
)
const isNativeWindow = computed(
() =>
isElectron() && settingStore.get('Comfy-Desktop.WindowStyle') === 'custom'
)
const showTopMenu = computed(
() => betaMenuEnabled.value && !workspaceState.focusMode
)

View File

@@ -19,9 +19,8 @@ export const CORE_MENU_COMMANDS = [
[
'Comfy.Help.OpenComfyUIIssues',
'Comfy.Help.OpenComfyUIDocs',
'Comfy.Help.OpenComfyOrgDiscord',
'Comfy.Help.OpenComfyUIForum'
'Comfy.Help.OpenComfyOrgDiscord'
]
],
[['Help'], ['Comfy.Help.AboutComfyUI', 'Comfy.Feedback']]
[['Help'], ['Comfy.Help.AboutComfyUI']]
]

View File

@@ -157,7 +157,7 @@ export const CORE_SETTINGS: SettingParams[] = [
id: 'Comfy.Workflow.ShowMissingModelsWarning',
name: 'Show missing models warning',
type: 'boolean',
defaultValue: true,
defaultValue: false,
experimental: true
},
{
@@ -290,7 +290,7 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Node ID badge mode',
type: 'combo',
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
defaultValue: NodeBadgeMode.None
defaultValue: NodeBadgeMode.ShowAll
},
{
id: 'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
@@ -363,18 +363,6 @@ export const CORE_SETTINGS: SettingParams[] = [
},
defaultValue: 0
},
{
id: 'LiteGraph.Node.TooltipDelay',
name: 'Tooltip Delay',
type: 'number',
attrs: {
min: 100,
max: 3000,
step: 50
},
defaultValue: 500,
versionAdded: '1.9.0'
},
{
id: 'Comfy.EnableTooltips',
category: ['LiteGraph', 'Node', 'EnableTooltips'],
@@ -666,7 +654,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'LiteGraph.Canvas.MaximumFps',
name: 'Maximum FPS',
name: 'Maxium FPS',
tooltip:
'The maximum frames per second that the canvas is allowed to render. Caps GPU usage at the cost of smoothness. If 0, the screen refresh rate is used. Default: 0',
type: 'slider',
@@ -720,32 +708,5 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: 'after',
options: ['before', 'after'],
versionModified: '1.6.10'
},
{
id: 'Comfy.TutorialCompleted',
name: 'Tutorial completed',
type: 'hidden',
defaultValue: false,
versionAdded: '1.8.7'
},
{
id: 'LiteGraph.ContextMenu.Scaling',
name: 'Scale node combo widget menus (lists) when zoomed in',
defaultValue: false,
type: 'boolean',
versionAdded: '1.8.8'
},
{
id: 'LiteGraph.Canvas.LowQualityRenderingZoomThreshold',
name: 'Low quality rendering zoom threshold',
tooltip: 'Render low quality shapes when zoomed out',
type: 'slider',
attrs: {
min: 0.1,
max: 1,
step: 0.01
},
defaultValue: 0.6,
versionAdded: '1.9.1'
}
]

View File

@@ -1,146 +0,0 @@
import { PrimeIcons } from '@primevue/core'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
const electron = electronAPI()
const openUrl = (url: string) => {
window.open(url, '_blank')
return true
}
export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
{
id: 'basePath',
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
description:
'The base path is the default location where ComfyUI stores data. It is the location fo the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,
button: {
icon: PrimeIcons.QUESTION,
text: 'Select'
}
},
{
id: 'git',
headerImg: '/assets/images/Git-Logo-White.svg',
execute: () => openUrl('https://git-scm.com/downloads/'),
name: 'Download git',
shortDescription: 'Open the git download page.',
errorDescription:
'Git is missing. Please download and install git, then restart ComfyUI Desktop.',
description:
'Git is required to download and manage custom nodes and other extensions. This task opens the download page in your default browser, where you can download the latest version of git. Once you have installed git, please restart ComfyUI Desktop.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'
}
},
{
id: 'vcRedist',
execute: () => openUrl('https://aka.ms/vs/17/release/vc_redist.x64.exe'),
name: 'Download VC++ Redist',
shortDescription: 'Download the latest VC++ Redistributable runtime.',
description:
'The Visual C++ runtime libraries are required to run ComfyUI. You will need to download and install this file.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'
}
},
{
id: 'reinstall',
severity: 'danger',
requireConfirm: true,
execute: async () => {
await electron.reinstall()
return true
},
name: 'Reinstall ComfyUI',
shortDescription:
'Deletes the desktop app config and load the welcome screen.',
description:
'Delete the desktop app config, restart the app, and load the installation screen.',
confirmText: 'Delete all saved config and reinstall?',
button: {
icon: PrimeIcons.EXCLAMATION_TRIANGLE,
text: 'Reinstall'
}
},
{
id: 'pythonPackages',
requireConfirm: true,
execute: async () => {
try {
await electron.uv.installRequirements()
return true
} catch (error) {
return false
}
},
name: 'Install python packages',
shortDescription:
'Installs the base python packages required to run ComfyUI.',
errorDescription:
'Python packages that are required to run ComfyUI are not installed.',
description:
'This will install the python packages required to run ComfyUI. This includes torch, torchvision, and other dependencies.',
usesTerminal: true,
isInstallationFix: true,
button: {
icon: PrimeIcons.DOWNLOAD,
text: 'Install'
}
},
{
id: 'uv',
execute: () =>
openUrl('https://docs.astral.sh/uv/getting-started/installation/'),
name: 'uv executable',
shortDescription: 'uv installs and maintains the python environment.',
description:
"This will open the download page for Astral's uv tool. uv is used to install python and manage python packages.",
button: {
icon: 'pi pi-asterisk',
text: 'Download'
}
},
{
id: 'uvCache',
severity: 'danger',
requireConfirm: true,
execute: async () => await electron.uv.clearCache(),
name: 'uv cache',
shortDescription: 'Remove the Astral uv cache of python packages.',
description:
'This will remove the uv cache directory and its contents. All downloaded python packages will need to be downloaded again.',
confirmText: 'Delete uv cache of python packages?',
isInstallationFix: true,
button: {
icon: PrimeIcons.TRASH,
text: 'Clear cache'
}
},
{
id: 'venvDirectory',
severity: 'danger',
requireConfirm: true,
execute: async () => await electron.uv.resetVenv(),
name: 'Reset virtual environment',
shortDescription:
'Remove and recreate the .venv directory. This removes all python packages.',
description:
'The python environment is where ComfyUI installs python and python packages. It is used to run the ComfyUI server.',
confirmText: 'Delete the .venv directory?',
usesTerminal: true,
isInstallationFix: true,
button: {
icon: PrimeIcons.FOLDER,
text: 'Recreate'
}
}
] as const

View File

@@ -1,34 +0,0 @@
export interface UVMirror {
/**
* The setting id defined for the mirror.
*/
settingId: string
/**
* The default mirror to use.
*/
mirror: string
/**
* The fallback mirror to use.
*/
fallbackMirror: string
/**
* The path suffix to validate the mirror is reachable.
*/
validationPathSuffix?: string
}
export const PYTHON_MIRROR: UVMirror = {
settingId: 'Comfy-Desktop.UV.PythonInstallMirror',
mirror:
'https://github.com/astral-sh/python-build-standalone/releases/download',
fallbackMirror:
'https://bgithub.xyz/astral-sh/python-build-standalone/releases/download',
validationPathSuffix:
'/20250115/cpython-3.10.16+20250115-aarch64-apple-darwin-debug-full.tar.zst.sha256'
}
export const PYPI_MIRROR: UVMirror = {
settingId: 'Comfy-Desktop.UV.PypiInstallMirror',
mirror: 'https://pypi.org/simple/',
fallbackMirror: 'https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple'
}

View File

@@ -1,17 +1,13 @@
import { PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowStore } from '@/stores/workflowStore'
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
;(async () => {
if (!isElectron()) return
const electronAPI = getElectronAPI()
const desktopAppVersion = await electronAPI.getElectronVersion()
const workflowStore = useWorkflowStore()
const onChangeRestartApp = (newValue: string, oldValue: string) => {
// Add a delay to allow changes to take effect before restarting.
@@ -43,52 +39,18 @@ import { checkMirrorReachable } from '@/utils/networkUtil'
id: 'Comfy-Desktop.WindowStyle',
category: ['Comfy-Desktop', 'General', 'Window Style'],
name: 'Window Style',
tooltip: "Custom: Replace the system title bar with ComfyUI's Top menu",
tooltip: 'Choose custom option to hide the system title bar',
type: 'combo',
experimental: true,
defaultValue: 'default',
options: ['default', 'custom'],
onChange: (
newValue: 'default' | 'custom',
oldValue?: 'default' | 'custom'
oldValue: 'default' | 'custom'
) => {
if (!oldValue) return
electronAPI.Config.setWindowStyle(newValue)
}
},
{
id: 'Comfy-Desktop.UV.PythonInstallMirror',
name: 'Python Install Mirror',
tooltip: `Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme.`,
type: 'url',
defaultValue: '',
attrs: {
validateUrlFn(mirror: string) {
return checkMirrorReachable(
mirror + PYTHON_MIRROR.validationPathSuffix
)
}
}
},
{
id: 'Comfy-Desktop.UV.PypiInstallMirror',
name: 'Pypi Install Mirror',
tooltip: `Default pip install mirror`,
type: 'url',
defaultValue: '',
attrs: {
validateUrlFn: checkMirrorReachable
}
},
{
id: 'Comfy-Desktop.UV.TorchInstallMirror',
name: 'Torch Install Mirror',
tooltip: `Pip install mirror for pytorch`,
type: 'url',
defaultValue: '',
attrs: {
validateUrlFn: checkMirrorReachable
onChangeRestartApp(newValue, oldValue)
}
}
],
@@ -150,6 +112,14 @@ import { checkMirrorReachable } from '@/utils/networkUtil'
electronAPI.openDevTools()
}
},
{
id: 'Comfy-Desktop.OpenFeedbackPage',
label: 'Feedback',
icon: 'pi pi-envelope',
function() {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
},
{
id: 'Comfy-Desktop.OpenUserGuide',
label: 'Desktop User Guide',
@@ -179,32 +149,16 @@ import { checkMirrorReachable } from '@/utils/networkUtil'
function() {
electronAPI.restartApp()
}
},
{
id: 'Comfy-Desktop.Quit',
label: 'Quit',
icon: 'pi pi-sign-out',
async function() {
// Confirm if unsaved workflows are open
if (workflowStore.modifiedWorkflows.length > 0) {
const confirmed = await useDialogService().confirm({
message: t('desktopMenu.confirmQuit'),
title: t('desktopMenu.quit'),
type: 'default'
})
if (!confirmed) return
}
electronAPI.quit()
}
}
],
menuCommands: [
{
path: ['Help'],
commands: ['Comfy-Desktop.OpenUserGuide']
commands: [
'Comfy-Desktop.OpenUserGuide',
'Comfy-Desktop.OpenFeedbackPage'
]
},
{
path: ['Help'],

File diff suppressed because it is too large Load Diff

View File

@@ -1,134 +0,0 @@
import type { IWidget } from '@comfyorg/litegraph'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { api } from '@/scripts/api'
class Load3DConfiguration {
constructor(private load3d: Load3d) {}
configure(
loadFolder: 'input' | 'output',
modelWidget: IWidget,
material: IWidget,
lightIntensity: IWidget,
upDirection: IWidget,
fov: IWidget,
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
this.setupModelHandling(
modelWidget,
loadFolder,
cameraState,
postModelUpdateFunc
)
this.setupMaterial(material)
this.setupLighting(lightIntensity)
this.setupDirection(upDirection)
this.setupCamera(fov)
this.setupDefaultProperties()
}
private setupModelHandling(
modelWidget: IWidget,
loadFolder: 'input' | 'output',
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
cameraState,
postModelUpdateFunc
)
if (modelWidget.value) {
onModelWidgetUpdate(modelWidget.value)
}
modelWidget.callback = onModelWidgetUpdate
}
private setupMaterial(material: IWidget) {
material.callback = (value: 'original' | 'normal' | 'wireframe') => {
this.load3d.setMaterialMode(value)
}
this.load3d.setMaterialMode(
material.value as 'original' | 'normal' | 'wireframe'
)
}
private setupLighting(lightIntensity: IWidget) {
lightIntensity.callback = (value: number) => {
this.load3d.setLightIntensity(value)
}
this.load3d.setLightIntensity(lightIntensity.value as number)
}
private setupDirection(upDirection: IWidget) {
upDirection.callback = (
value: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
) => {
this.load3d.setUpDirection(value)
}
this.load3d.setUpDirection(
upDirection.value as 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
)
}
private setupCamera(fov: IWidget) {
fov.callback = (value: number) => {
this.load3d.setFOV(value)
}
this.load3d.setFOV(fov.value as number)
}
private setupDefaultProperties() {
const cameraType = this.load3d.loadNodeProperty(
'Camera Type',
'perspective'
)
this.load3d.toggleCamera(cameraType)
const showGrid = this.load3d.loadNodeProperty('Show Grid', true)
this.load3d.toggleGrid(showGrid)
const bgColor = this.load3d.loadNodeProperty('Background Color', '#282828')
this.load3d.setBackgroundColor(bgColor)
}
private createModelUpdateHandler(
loadFolder: 'input' | 'output',
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value) return
const filename = value as string
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(filename),
loadFolder
)
)
await this.load3d.loadModel(modelUrl, filename)
if (postModelUpdateFunc) {
postModelUpdateFunc(this.load3d)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
this.load3d.setCameraState(cameraState)
} catch (error) {
console.warn('Failed to restore camera state:', error)
}
isFirstLoad = false
}
}
}
}
export default Load3DConfiguration

View File

@@ -1,997 +0,0 @@
import { LGraphNode } from '@comfyorg/litegraph'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { ViewHelper } from 'three/examples/jsm/helpers/ViewHelper'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { useToastStore } from '@/stores/toastStore'
class Load3d {
scene: THREE.Scene
perspectiveCamera: THREE.PerspectiveCamera
orthographicCamera: THREE.OrthographicCamera
activeCamera: THREE.Camera
renderer: THREE.WebGLRenderer
controls: OrbitControls
gltfLoader: GLTFLoader
objLoader: OBJLoader
mtlLoader: MTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
currentModel: THREE.Object3D | null = null
originalModel: THREE.Object3D | THREE.BufferGeometry | GLTF | null = null
animationFrameId: number | null = null
gridHelper: THREE.GridHelper
lights: THREE.Light[] = []
clock: THREE.Clock
normalMaterial: THREE.MeshNormalMaterial
standardMaterial: THREE.MeshStandardMaterial
wireframeMaterial: THREE.MeshBasicMaterial
depthMaterial: THREE.MeshDepthMaterial
originalMaterials: WeakMap<THREE.Mesh, THREE.Material | THREE.Material[]> =
new WeakMap()
materialMode: 'original' | 'normal' | 'wireframe' | 'depth' = 'original'
currentUpDirection: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' =
'original'
originalRotation: THREE.Euler | null = null
viewHelper: ViewHelper = {} as ViewHelper
viewHelperContainer: HTMLDivElement = {} as HTMLDivElement
cameraSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
gridSwitcherContainer: HTMLDivElement = {} as HTMLDivElement
node: LGraphNode = {} as LGraphNode
bgColorInput: HTMLInputElement = {} as HTMLInputElement
constructor(container: Element | HTMLElement) {
this.scene = new THREE.Scene()
this.perspectiveCamera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
this.perspectiveCamera.position.set(5, 5, 5)
const frustumSize = 10
this.orthographicCamera = new THREE.OrthographicCamera(
-frustumSize / 2,
frustumSize / 2,
frustumSize / 2,
-frustumSize / 2,
0.1,
1000
)
this.orthographicCamera.position.set(5, 5, 5)
this.activeCamera = this.perspectiveCamera
this.perspectiveCamera.lookAt(0, 0, 0)
this.orthographicCamera.lookAt(0, 0, 0)
this.renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true })
this.renderer.setSize(300, 300)
this.renderer.setClearColor(0x282828)
this.renderer.autoClear = false
const rendererDomElement: HTMLCanvasElement = this.renderer.domElement
container.appendChild(rendererDomElement)
this.controls = new OrbitControls(
this.activeCamera,
this.renderer.domElement
)
this.controls.enableDamping = true
this.controls.addEventListener('end', () => {
this.storeNodeProperty('Camera Info', this.getCameraState())
})
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader()
this.mtlLoader = new MTLLoader()
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
this.clock = new THREE.Clock()
this.setupLights()
this.gridHelper = new THREE.GridHelper(10, 10)
this.gridHelper.position.set(0, 0, 0)
this.scene.add(this.gridHelper)
this.normalMaterial = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1),
transparent: false,
opacity: 1.0
})
this.wireframeMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
transparent: false,
opacity: 1.0
})
this.depthMaterial = new THREE.MeshDepthMaterial({
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
this.standardMaterial = this.createSTLMaterial()
this.createViewHelper(container)
this.createGridSwitcher(container)
this.createCameraSwitcher(container)
this.createColorPicker(container)
this.handleResize()
this.startAnimation()
}
setNode(node: LGraphNode) {
this.node = node
}
storeNodeProperty(name: string, value: any) {
if (this.node) {
this.node.properties[name] = value
}
}
loadNodeProperty(name: string, defaultValue: any) {
if (
!this.node ||
!this.node.properties ||
!(name in this.node.properties)
) {
return defaultValue
}
return this.node.properties[name]
}
createViewHelper(container: Element | HTMLElement) {
this.viewHelperContainer = document.createElement('div')
this.viewHelperContainer.style.position = 'absolute'
this.viewHelperContainer.style.bottom = '0'
this.viewHelperContainer.style.left = '0'
this.viewHelperContainer.style.width = '128px'
this.viewHelperContainer.style.height = '128px'
this.viewHelperContainer.addEventListener('pointerup', (event) => {
event.stopPropagation()
this.viewHelper.handleClick(event)
})
this.viewHelperContainer.addEventListener('pointerdown', (event) => {
event.stopPropagation()
})
container.appendChild(this.viewHelperContainer)
this.viewHelper = new ViewHelper(
this.activeCamera,
this.viewHelperContainer
)
this.viewHelper.center = this.controls.target
}
createGridSwitcher(container: Element | HTMLElement) {
this.gridSwitcherContainer = document.createElement('div')
this.gridSwitcherContainer.style.position = 'absolute'
this.gridSwitcherContainer.style.top = '28px' // 修改这里,让按钮在相机按钮下方
this.gridSwitcherContainer.style.left = '3px' // 与相机按钮左对齐
this.gridSwitcherContainer.style.width = '20px'
this.gridSwitcherContainer.style.height = '20px'
this.gridSwitcherContainer.style.cursor = 'pointer'
this.gridSwitcherContainer.style.alignItems = 'center'
this.gridSwitcherContainer.style.justifyContent = 'center'
this.gridSwitcherContainer.style.transition = 'background-color 0.2s'
const gridIcon = document.createElement('div')
gridIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M3 3h18v18H3z"/>
<path d="M3 9h18"/>
<path d="M3 15h18"/>
<path d="M9 3v18"/>
<path d="M15 3v18"/>
</svg>
`
const updateButtonState = () => {
if (this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor =
'rgba(255, 255, 255, 0.2)'
} else {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
}
updateButtonState()
this.gridSwitcherContainer.addEventListener('mouseenter', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
}
})
this.gridSwitcherContainer.addEventListener('mouseleave', () => {
if (!this.gridHelper.visible) {
this.gridSwitcherContainer.style.backgroundColor = 'transparent'
}
})
this.gridSwitcherContainer.title = 'Toggle Grid'
this.gridSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleGrid(!this.gridHelper.visible)
updateButtonState()
})
this.gridSwitcherContainer.appendChild(gridIcon)
container.appendChild(this.gridSwitcherContainer)
}
createCameraSwitcher(container: Element | HTMLElement) {
this.cameraSwitcherContainer = document.createElement('div')
this.cameraSwitcherContainer.style.position = 'absolute'
this.cameraSwitcherContainer.style.top = '3px'
this.cameraSwitcherContainer.style.left = '3px'
this.cameraSwitcherContainer.style.width = '20px'
this.cameraSwitcherContainer.style.height = '20px'
this.cameraSwitcherContainer.style.cursor = 'pointer'
this.cameraSwitcherContainer.style.alignItems = 'center'
this.cameraSwitcherContainer.style.justifyContent = 'center'
const cameraIcon = document.createElement('div')
cameraIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M18 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2Z"/>
<path d="m12 12 4-2.4"/>
<circle cx="12" cy="12" r="3"/>
</svg>
`
this.cameraSwitcherContainer.addEventListener('mouseenter', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.cameraSwitcherContainer.addEventListener('mouseleave', () => {
this.cameraSwitcherContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.cameraSwitcherContainer.title =
'Switch Camera (Perspective/Orthographic)'
this.cameraSwitcherContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleCamera()
})
this.cameraSwitcherContainer.appendChild(cameraIcon)
container.appendChild(this.cameraSwitcherContainer)
}
createColorPicker(container: Element | HTMLElement) {
const colorPickerContainer = document.createElement('div')
colorPickerContainer.style.position = 'absolute'
colorPickerContainer.style.top = '53px'
colorPickerContainer.style.left = '3px'
colorPickerContainer.style.width = '20px'
colorPickerContainer.style.height = '20px'
colorPickerContainer.style.cursor = 'pointer'
colorPickerContainer.style.alignItems = 'center'
colorPickerContainer.style.justifyContent = 'center'
colorPickerContainer.title = 'Background Color'
const colorInput = document.createElement('input')
colorInput.type = 'color'
colorInput.style.opacity = '0'
colorInput.style.position = 'absolute'
colorInput.style.width = '100%'
colorInput.style.height = '100%'
colorInput.style.cursor = 'pointer'
const colorIcon = document.createElement('div')
colorIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M12 3v18"/>
<path d="M3 12h18"/>
</svg>
`
colorInput.addEventListener('input', (event) => {
const color = (event.target as HTMLInputElement).value
this.setBackgroundColor(color)
this.storeNodeProperty('Background Color', color)
})
this.bgColorInput = colorInput
colorPickerContainer.appendChild(colorInput)
colorPickerContainer.appendChild(colorIcon)
container.appendChild(colorPickerContainer)
}
setFOV(fov: number) {
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.fov = fov
this.perspectiveCamera.updateProjectionMatrix()
this.renderer.render(this.scene, this.activeCamera)
}
}
getCameraState() {
const currentType = this.getCurrentCameraType()
return {
position: this.activeCamera.position.clone(),
target: this.controls.target.clone(),
zoom:
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: currentType
}
}
setCameraState(state: {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: 'perspective' | 'orthographic'
}) {
if (
this.activeCamera !==
(state.cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera)
) {
//this.toggleCamera(state.cameraType)
}
this.activeCamera.position.copy(state.position)
this.controls.target.copy(state.target)
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
} else if (this.activeCamera instanceof THREE.PerspectiveCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
}
this.controls.update()
}
setUpDirection(
direction: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
) {
if (!this.currentModel) return
if (!this.originalRotation && this.currentModel.rotation) {
this.originalRotation = this.currentModel.rotation.clone()
}
this.currentUpDirection = direction
if (this.originalRotation) {
this.currentModel.rotation.copy(this.originalRotation)
}
switch (direction) {
case 'original':
break
case '-x':
this.currentModel.rotation.z = Math.PI / 2
break
case '+x':
this.currentModel.rotation.z = -Math.PI / 2
break
case '-y':
this.currentModel.rotation.x = Math.PI
break
case '+y':
break
case '-z':
this.currentModel.rotation.x = Math.PI / 2
break
case '+z':
this.currentModel.rotation.x = -Math.PI / 2
break
}
this.renderer.render(this.scene, this.activeCamera)
}
setMaterialMode(mode: 'original' | 'normal' | 'wireframe' | 'depth') {
this.materialMode = mode
if (this.currentModel) {
if (mode === 'depth') {
this.renderer.outputColorSpace = THREE.LinearSRGBColorSpace
} else {
this.renderer.outputColorSpace = THREE.SRGBColorSpace
}
this.currentModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
switch (mode) {
case 'depth':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
const depthMat = new THREE.MeshDepthMaterial({
depthPacking: THREE.BasicDepthPacking,
side: THREE.DoubleSide
})
depthMat.onBeforeCompile = (shader) => {
shader.uniforms.cameraType = {
value:
this.activeCamera instanceof THREE.OrthographicCamera
? 1.0
: 0.0
}
shader.fragmentShader = `
uniform float cameraType;
${shader.fragmentShader}
`
shader.fragmentShader = shader.fragmentShader.replace(
/gl_FragColor\s*=\s*vec4\(\s*vec3\(\s*1.0\s*-\s*fragCoordZ\s*\)\s*,\s*opacity\s*\)\s*;/,
`
float depth = 1.0 - fragCoordZ;
if (cameraType > 0.5) {
depth = pow(depth, 400.0);
} else {
depth = pow(depth, 0.6);
}
gl_FragColor = vec4(vec3(depth), opacity);
`
)
}
depthMat.customProgramCacheKey = () => {
return this.activeCamera instanceof THREE.OrthographicCamera
? 'ortho'
: 'persp'
}
child.material = depthMat
break
case 'normal':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshNormalMaterial({
flatShading: false,
side: THREE.DoubleSide,
normalScale: new THREE.Vector2(1, 1),
transparent: false,
opacity: 1.0
})
child.geometry.computeVertexNormals()
break
case 'wireframe':
if (!this.originalMaterials.has(child)) {
this.originalMaterials.set(child, child.material)
}
child.material = new THREE.MeshBasicMaterial({
color: 0xffffff,
wireframe: true,
transparent: false,
opacity: 1.0
})
break
case 'original':
const originalMaterial = this.originalMaterials.get(child)
if (originalMaterial) {
child.material = originalMaterial
} else {
child.material = this.standardMaterial
}
break
}
}
})
this.renderer.render(this.scene, this.activeCamera)
}
}
setupLights() {
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
this.scene.add(ambientLight)
this.lights.push(ambientLight)
const mainLight = new THREE.DirectionalLight(0xffffff, 0.8)
mainLight.position.set(0, 10, 10)
this.scene.add(mainLight)
this.lights.push(mainLight)
const backLight = new THREE.DirectionalLight(0xffffff, 0.5)
backLight.position.set(0, 10, -10)
this.scene.add(backLight)
this.lights.push(backLight)
const leftFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
leftFillLight.position.set(-10, 0, 0)
this.scene.add(leftFillLight)
this.lights.push(leftFillLight)
const rightFillLight = new THREE.DirectionalLight(0xffffff, 0.3)
rightFillLight.position.set(10, 0, 0)
this.scene.add(rightFillLight)
this.lights.push(rightFillLight)
const bottomLight = new THREE.DirectionalLight(0xffffff, 0.2)
bottomLight.position.set(0, -10, 0)
this.scene.add(bottomLight)
this.lights.push(bottomLight)
}
toggleCamera(cameraType?: 'perspective' | 'orthographic') {
const oldCamera = this.activeCamera
const position = oldCamera.position.clone()
const rotation = oldCamera.rotation.clone()
const target = this.controls.target.clone()
if (!cameraType) {
this.activeCamera =
oldCamera === this.perspectiveCamera
? this.orthographicCamera
: this.perspectiveCamera
} else {
this.activeCamera =
cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera
if (oldCamera === this.activeCamera) {
return
}
}
this.activeCamera.position.copy(position)
this.activeCamera.rotation.copy(rotation)
if (this.materialMode === 'depth' && oldCamera !== this.activeCamera) {
this.setMaterialMode('depth')
}
this.controls.object = this.activeCamera
this.controls.target.copy(target)
this.controls.update()
this.viewHelper.dispose()
this.viewHelper = new ViewHelper(
this.activeCamera,
this.viewHelperContainer
)
this.viewHelper.center = this.controls.target
this.storeNodeProperty('Camera Type', this.getCurrentCameraType())
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.activeCamera === this.perspectiveCamera
? 'perspective'
: 'orthographic'
}
toggleGrid(showGrid: boolean) {
if (this.gridHelper) {
this.gridHelper.visible = showGrid
this.storeNodeProperty('Show Grid', showGrid)
}
}
setLightIntensity(intensity: number) {
this.lights.forEach((light) => {
if (light instanceof THREE.DirectionalLight) {
if (light === this.lights[1]) {
light.intensity = intensity * 0.8
} else if (light === this.lights[2]) {
light.intensity = intensity * 0.5
} else if (light === this.lights[5]) {
light.intensity = intensity * 0.2
} else {
light.intensity = intensity * 0.3
}
} else if (light instanceof THREE.AmbientLight) {
light.intensity = intensity * 0.5
}
})
}
startAnimation() {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
const delta = this.clock.getDelta()
if (this.viewHelper.animating) {
this.viewHelper.update(delta)
if (!this.viewHelper.animating) {
this.storeNodeProperty('Camera Info', this.getCameraState())
}
}
this.renderer.clear()
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
this.viewHelper.render(this.renderer)
}
animate()
}
clearModel() {
const objectsToRemove: THREE.Object3D[] = []
this.scene.traverse((object) => {
const isEnvironmentObject =
object === this.gridHelper ||
this.lights.includes(object as THREE.Light) ||
object === this.perspectiveCamera ||
object === this.orthographicCamera
if (!isEnvironmentObject) {
objectsToRemove.push(object)
}
})
objectsToRemove.forEach((obj) => {
if (obj.parent && obj.parent !== this.scene) {
obj.parent.remove(obj)
} else {
this.scene.remove(obj)
}
if (obj instanceof THREE.Mesh) {
obj.geometry?.dispose()
if (Array.isArray(obj.material)) {
obj.material.forEach((material) => material.dispose())
} else {
obj.material?.dispose()
}
}
})
this.resetScene()
}
protected resetScene() {
this.currentModel = null
this.originalRotation = null
const defaultDistance = 10
this.perspectiveCamera.position.set(
defaultDistance,
defaultDistance,
defaultDistance
)
this.orthographicCamera.position.set(
defaultDistance,
defaultDistance,
defaultDistance
)
this.perspectiveCamera.lookAt(0, 0, 0)
this.orthographicCamera.lookAt(0, 0, 0)
const frustumSize = 10
const aspect =
this.renderer.domElement.width / this.renderer.domElement.height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.perspectiveCamera.updateProjectionMatrix()
this.orthographicCamera.updateProjectionMatrix()
this.controls.target.set(0, 0, 0)
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
this.materialMode = 'original'
this.originalMaterials = new WeakMap()
this.renderer.outputColorSpace = THREE.SRGBColorSpace
}
remove() {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}
this.controls.dispose()
this.viewHelper.dispose()
this.renderer.dispose()
this.renderer.domElement.remove()
this.scene.clear()
}
protected async loadModelInternal(
url: string,
fileExtension: string
): Promise<THREE.Object3D | null> {
let model: THREE.Object3D | null = null
switch (fileExtension) {
case 'stl':
const geometry = await this.stlLoader.loadAsync(url)
this.originalModel = geometry
geometry.computeVertexNormals()
const mesh = new THREE.Mesh(geometry, this.standardMaterial)
const group = new THREE.Group()
group.add(mesh)
model = group
break
case 'fbx':
const fbxModel = await this.fbxLoader.loadAsync(url)
this.originalModel = fbxModel
model = fbxModel
fbxModel.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.originalMaterials.set(child, child.material)
}
})
break
case 'obj':
if (this.materialMode === 'original') {
const mtlUrl = url.replace(/\.obj([^.]*$)/, '.mtl$1')
try {
const materials = await this.mtlLoader.loadAsync(mtlUrl)
materials.preload()
this.objLoader.setMaterials(materials)
} catch (e) {
console.log(
'No MTL file found or error loading it, continuing without materials'
)
}
}
model = await this.objLoader.loadAsync(url)
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.originalMaterials.set(child, child.material)
}
})
break
case 'gltf':
case 'glb':
const gltf = await this.gltfLoader.loadAsync(url)
this.originalModel = gltf
model = gltf.scene
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.computeVertexNormals()
this.originalMaterials.set(child, child.material)
}
})
break
}
return model
}
async loadModel(url: string, originalFileName?: string) {
try {
this.clearModel()
let fileExtension: string | undefined
if (originalFileName) {
fileExtension = originalFileName.split('.').pop()?.toLowerCase()
} else {
const filename = new URLSearchParams(url.split('?')[1]).get('filename')
fileExtension = filename?.split('.').pop()?.toLowerCase()
}
if (!fileExtension) {
useToastStore().addAlert('Could not determine file type')
return
}
let model = await this.loadModelInternal(url, fileExtension)
if (model) {
this.currentModel = model
await this.setupModel(model)
}
} catch (error) {
console.error('Error loading model:', error)
}
}
protected async setupModel(model: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const targetSize = 5
const scale = targetSize / maxDim
model.scale.multiplyScalar(scale)
box.setFromObject(model)
box.getCenter(center)
box.getSize(size)
model.position.set(-center.x, -box.min.y, -center.z)
this.scene.add(model)
if (this.materialMode !== 'original') {
this.setMaterialMode(this.materialMode)
}
if (this.currentUpDirection !== 'original') {
this.setUpDirection(this.currentUpDirection)
}
await this.setupCamera(size)
}
protected async setupCamera(size: THREE.Vector3) {
const distance = Math.max(size.x, size.z) * 2
const height = size.y * 2
this.perspectiveCamera.position.set(distance, height, distance)
this.orthographicCamera.position.set(distance, height, distance)
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.lookAt(0, size.y / 2, 0)
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = Math.max(size.x, size.y, size.z) * 2
const aspect =
this.renderer.domElement.width / this.renderer.domElement.height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.lookAt(0, size.y / 2, 0)
this.orthographicCamera.updateProjectionMatrix()
}
this.controls.target.set(0, size.y / 2, 0)
this.controls.update()
this.renderer.outputColorSpace = THREE.SRGBColorSpace
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
this.renderer.toneMappingExposure = 1
this.handleResize()
}
handleResize() {
const parentElement = this.renderer?.domElement?.parentElement
if (!parentElement) {
console.warn('Parent element not found')
return
}
const width = parentElement?.clientWidth
const height = parentElement?.clientHeight
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.aspect = width / height
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = 10
const aspect = width / height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.updateProjectionMatrix()
}
this.renderer.setSize(width, height)
}
animate = () => {
requestAnimationFrame(this.animate)
this.controls.update()
this.renderer.render(this.scene, this.activeCamera)
}
captureScene(
width: number,
height: number
): Promise<{ scene: string; mask: string }> {
return new Promise(async (resolve, reject) => {
try {
const originalWidth = this.renderer.domElement.width
const originalHeight = this.renderer.domElement.height
const originalClearColor = this.renderer.getClearColor(
new THREE.Color()
)
const originalClearAlpha = this.renderer.getClearAlpha()
this.renderer.setSize(width, height)
if (this.activeCamera === this.perspectiveCamera) {
this.perspectiveCamera.aspect = width / height
this.perspectiveCamera.updateProjectionMatrix()
} else {
const frustumSize = 10
const aspect = width / height
this.orthographicCamera.left = (-frustumSize * aspect) / 2
this.orthographicCamera.right = (frustumSize * aspect) / 2
this.orthographicCamera.top = frustumSize / 2
this.orthographicCamera.bottom = -frustumSize / 2
this.orthographicCamera.updateProjectionMatrix()
}
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
const sceneData = this.renderer.domElement.toDataURL('image/png')
this.renderer.setClearColor(0x000000, 0)
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
const maskData = this.renderer.domElement.toDataURL('image/png')
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
this.renderer.setSize(originalWidth, originalHeight)
this.handleResize()
resolve({ scene: sceneData, mask: maskData })
} catch (error) {
reject(error)
}
})
}
createSTLMaterial() {
return new THREE.MeshStandardMaterial({
color: 0x808080,
metalness: 0.1,
roughness: 0.8,
flatShading: false,
side: THREE.DoubleSide
})
}
setBackgroundColor(color: string) {
this.renderer.setClearColor(new THREE.Color(color))
this.renderer.render(this.scene, this.activeCamera)
if (this.bgColorInput) {
this.bgColorInput.value = color
}
}
}
export default Load3d

View File

@@ -1,359 +0,0 @@
import * as THREE from 'three'
import Load3d from '@/extensions/core/load3d/Load3d'
class Load3dAnimation extends Load3d {
currentAnimation: THREE.AnimationMixer | null = null
animationActions: THREE.AnimationAction[] = []
animationClips: THREE.AnimationClip[] = []
selectedAnimationIndex: number = 0
isAnimationPlaying: boolean = false
animationSpeed: number = 1.0
playPauseContainer: HTMLDivElement = {} as HTMLDivElement
animationSelect: HTMLSelectElement = {} as HTMLSelectElement
speedSelect: HTMLSelectElement = {} as HTMLSelectElement
constructor(container: Element | HTMLElement) {
super(container)
this.createPlayPauseButton(container)
this.createAnimationList(container)
this.createSpeedSelect(container)
}
createAnimationList(container: Element | HTMLElement) {
this.animationSelect = document.createElement('select')
Object.assign(this.animationSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(15px)',
width: '90px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
})
this.animationSelect.addEventListener('mouseenter', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.animationSelect.addEventListener('mouseleave', () => {
this.animationSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.animationSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
this.updateSelectedAnimation(select.selectedIndex)
})
container.appendChild(this.animationSelect)
}
updateAnimationList() {
this.animationSelect.innerHTML = ''
this.animationClips.forEach((clip, index) => {
const option = document.createElement('option')
option.value = index.toString()
option.text = clip.name || `Animation ${index + 1}`
option.selected = index === this.selectedAnimationIndex
this.animationSelect.appendChild(option)
})
}
createPlayPauseButton(container: Element | HTMLElement) {
this.playPauseContainer = document.createElement('div')
this.playPauseContainer.style.position = 'absolute'
this.playPauseContainer.style.top = '3px'
this.playPauseContainer.style.left = '50%'
this.playPauseContainer.style.transform = 'translateX(-50%)'
this.playPauseContainer.style.width = '20px'
this.playPauseContainer.style.height = '20px'
this.playPauseContainer.style.cursor = 'pointer'
this.playPauseContainer.style.alignItems = 'center'
this.playPauseContainer.style.justifyContent = 'center'
const updateButtonState = () => {
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = `
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
`
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = `
<path d="M8 5v14l11-7z"/>
`
this.playPauseContainer.title = 'Play Animation'
}
}
}
const playIcon = document.createElement('div')
playIcon.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2">
<path d="M8 5v14l11-7z"/>
</svg>
`
this.playPauseContainer.addEventListener('mouseenter', () => {
this.playPauseContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.playPauseContainer.addEventListener('mouseleave', () => {
this.playPauseContainer.style.backgroundColor = 'transparent'
})
this.playPauseContainer.addEventListener('click', (event) => {
event.stopPropagation()
this.toggleAnimation()
updateButtonState()
})
this.playPauseContainer.appendChild(playIcon)
container.appendChild(this.playPauseContainer)
this.playPauseContainer.style.display = 'none'
}
createSpeedSelect(container: Element | HTMLElement) {
this.speedSelect = document.createElement('select')
Object.assign(this.speedSelect.style, {
position: 'absolute',
top: '3px',
left: '50%',
transform: 'translateX(-75px)',
width: '60px',
height: '20px',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '12px',
padding: '0 8px',
cursor: 'pointer',
display: 'none',
outline: 'none'
})
const speeds = [0.1, 0.5, 1, 1.5, 2]
speeds.forEach((speed) => {
const option = document.createElement('option')
option.value = speed.toString()
option.text = `${speed}x`
option.selected = speed === 1
this.speedSelect.appendChild(option)
})
this.speedSelect.addEventListener('mouseenter', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
})
this.speedSelect.addEventListener('mouseleave', () => {
this.speedSelect.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'
})
this.speedSelect.addEventListener('change', (event) => {
const select = event.target as HTMLSelectElement
const newSpeed = parseFloat(select.value)
this.setAnimationSpeed(newSpeed)
})
container.appendChild(this.speedSelect)
}
protected async setupModel(model: THREE.Object3D) {
await super.setupModel(model)
if (this.currentAnimation) {
this.currentAnimation.stopAllAction()
this.animationActions = []
}
let animations: THREE.AnimationClip[] = []
if (model.animations?.length > 0) {
animations = model.animations
} else if (this.originalModel && 'animations' in this.originalModel) {
animations = (
this.originalModel as unknown as { animations: THREE.AnimationClip[] }
).animations
}
if (animations.length > 0) {
this.animationClips = animations
if (model.type === 'Scene') {
this.currentAnimation = new THREE.AnimationMixer(model)
} else {
this.currentAnimation = new THREE.AnimationMixer(this.currentModel!)
}
if (this.animationClips.length > 0) {
this.updateSelectedAnimation(0)
}
}
if (this.animationClips.length > 0) {
this.playPauseContainer.style.display = 'block'
} else {
this.playPauseContainer.style.display = 'none'
}
if (this.animationClips.length > 0) {
this.playPauseContainer.style.display = 'block'
this.animationSelect.style.display = 'block'
this.speedSelect.style.display = 'block'
this.updateAnimationList()
} else {
this.playPauseContainer.style.display = 'none'
this.animationSelect.style.display = 'none'
this.speedSelect.style.display = 'none'
}
}
setAnimationSpeed(speed: number) {
this.animationSpeed = speed
this.animationActions.forEach((action) => {
action.setEffectiveTimeScale(speed)
})
}
updateSelectedAnimation(index: number) {
if (
!this.currentAnimation ||
!this.animationClips ||
index >= this.animationClips.length
) {
console.warn('Invalid animation update request')
return
}
this.animationActions.forEach((action) => {
action.stop()
})
this.currentAnimation.stopAllAction()
this.animationActions = []
this.selectedAnimationIndex = index
const clip = this.animationClips[index]
const action = this.currentAnimation.clipAction(clip)
action.setEffectiveTimeScale(this.animationSpeed)
action.reset()
action.clampWhenFinished = false
action.loop = THREE.LoopRepeat
if (this.isAnimationPlaying) {
action.play()
} else {
action.play()
action.paused = true
}
this.animationActions = [action]
this.updateAnimationList()
}
clearModel() {
if (this.currentAnimation) {
this.animationActions.forEach((action) => {
action.stop()
})
this.currentAnimation = null
}
this.animationActions = []
this.animationClips = []
this.selectedAnimationIndex = 0
this.isAnimationPlaying = false
this.animationSpeed = 1.0
super.clearModel()
if (this.animationSelect) {
this.animationSelect.style.display = 'none'
this.animationSelect.innerHTML = ''
}
if (this.speedSelect) {
this.speedSelect.style.display = 'none'
this.speedSelect.value = '1'
}
}
getAnimationNames(): string[] {
return this.animationClips.map((clip, index) => {
return clip.name || `Animation ${index + 1}`
})
}
toggleAnimation(play?: boolean) {
if (!this.currentAnimation || this.animationActions.length === 0) {
console.warn('No animation to toggle')
return
}
this.isAnimationPlaying = play ?? !this.isAnimationPlaying
const icon = this.playPauseContainer.querySelector('svg')
if (icon) {
if (this.isAnimationPlaying) {
icon.innerHTML = '<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>'
this.playPauseContainer.title = 'Pause Animation'
} else {
icon.innerHTML = '<path d="M8 5v14l11-7z"/>'
this.playPauseContainer.title = 'Play Animation'
}
}
this.animationActions.forEach((action) => {
if (this.isAnimationPlaying) {
action.paused = false
if (action.time === 0 || action.time === action.getClip().duration) {
action.reset()
}
} else {
action.paused = true
}
})
}
startAnimation() {
const animate = () => {
this.animationFrameId = requestAnimationFrame(animate)
const delta = this.clock.getDelta()
if (this.currentAnimation && this.isAnimationPlaying) {
this.currentAnimation.update(delta)
}
this.controls.update()
this.renderer.clear()
this.renderer.render(this.scene, this.activeCamera)
if (this.viewHelper.animating) {
this.viewHelper.update(delta)
if (!this.viewHelper.animating) {
this.storeNodeProperty('Camera Info', this.getCameraState())
}
}
this.viewHelper.render(this.renderer)
}
animate()
}
}
export default Load3dAnimation

Some files were not shown because too many files have changed in this diff Show More