Compare commits
3 Commits
v1.9.3
...
report-iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7cdb27d48 | ||
|
|
a437fe90fc | ||
|
|
d6c1036c68 |
3
.github/workflows/release.yaml
vendored
@@ -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
@@ -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
|
||||
|
||||
16
.vscode/launch.json.default
vendored
@@ -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,
|
||||
}
|
||||
]
|
||||
}
|
||||
5
.vscode/settings.json.default
vendored
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"css.customData": [
|
||||
".vscode/tailwind.json"
|
||||
]
|
||||
}
|
||||
55
.vscode/tailwind.json
vendored
@@ -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 you’d 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 106 KiB |
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 97 KiB |
12
global.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
16
package.json
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
src/components/common/CheckboxGroup.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
:label="$t('g.download') + ' (' + fileSize + ')'"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!!props.error"
|
||||
:disabled="props.error"
|
||||
:title="props.url"
|
||||
@click="download.triggerBrowserDownload"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
66
src/components/common/LogTerminal.vue
Normal 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>
|
||||
@@ -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"> </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>
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
v-for="device in props.stats.devices"
|
||||
:key="device.index"
|
||||
:header="device.name"
|
||||
:value="device.index"
|
||||
>
|
||||
<DeviceInfo :device="device" />
|
||||
</TabPanel>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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']]
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||