Compare commits

...

91 Commits

Author SHA1 Message Date
Comfy Org PR Bot
4702cd18ce 1.15.3 (#3246)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-26 16:33:13 -04:00
Chenlei Hu
00d281c7fa [Test] Add playwright tests (#3245)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-26 16:32:42 -04:00
Chenlei Hu
3e25e08b10 [Bug] Fix broken output link id during reroute migration (#3244) 2025-03-26 16:00:54 -04:00
Comfy Org PR Bot
1d66d6d7d3 [chore] Update litegraph to 0.11.3 (#3242)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-26 15:25:17 -04:00
dependabot[bot]
cae5bbe86f Bump vite from 5.4.14 to 5.4.15 (#3243)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-26 15:25:08 -04:00
Christian Byrne
4d35d937cf [Manager] Fix build env vars (#3238) 2025-03-26 11:03:03 -04:00
Comfy Org PR Bot
60afa5cf6c [chore] Update Comfy Registry API types from comfy-api@d7be0da (#3239)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-03-26 02:48:38 -07:00
Comfy Org PR Bot
a1a33c8c9b 1.15.2 (#3237)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-25 22:07:08 -04:00
Chenlei Hu
9988fb8f1e [Bug] Fix selection toolbox select+drag (#3235)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-25 15:27:25 -04:00
Chenlei Hu
0518b170d3 [i18n] Add spanish translation (#3233)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-25 14:34:44 -04:00
Chenlei Hu
562cd7ea70 [TS] Fix type errors in app.ts (#3232) 2025-03-25 13:02:55 -04:00
Chenlei Hu
d3c64d404b [Bug] Fix selection toolbox position on pasted node (#3231) 2025-03-25 10:51:04 -04:00
Chenlei Hu
24dcaa7f72 [i18n] Translate toast messages (#3228)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 22:22:17 -04:00
Comfy Org PR Bot
6c18781663 1.15.1 (#3227)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-24 22:01:15 -04:00
Chenlei Hu
b6988e8d5c Report file load error via toast (#3226)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 22:01:07 -04:00
Chenlei Hu
ae64721555 Implement load workflow error dialog in Vue (#3225)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 21:00:50 -04:00
Terry Jia
abe65e58a0 [3d] add support to upload texture (#3224)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 16:58:45 -04:00
Comfy Org PR Bot
a1cfb68116 [chore] Update litegraph to 0.11.2 (#3223)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-03-24 16:09:40 -04:00
MohammadAboulEla
5bee36a73e Enable/Disable the drawing of image size (#3200) 2025-03-24 13:47:55 -04:00
Chenlei Hu
a4b0f5ab5e [Bug] Fix widget placeholder rendering (#3215)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-23 23:02:35 -04:00
filtered
f8a2c90138 Fix drag new link from reroute to widget (#3214) 2025-03-23 22:29:51 -04:00
Chenlei Hu
27c252f74a Render placeholder rect for DOM widgets when zoomed out (#3213)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-23 21:35:58 -04:00
Comfy Org PR Bot
7e26cffb26 [chore] Update litegraph to 0.11.1 (#3212)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-23 21:02:31 -04:00
niboshi
cb8354bfce Show error message on drop error (#3189) 2025-03-23 20:52:55 -04:00
Comfy Org PR Bot
d5ebd7b7cb [chore] Update litegraph to 0.11.0 (#3211)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-24 11:36:24 +11:00
Comfy Org PR Bot
845d045991 1.15.0 (#3210)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-23 19:13:09 -04:00
Chenlei Hu
c3154fe297 [Reroute] Clean floating reroutes in reroute migration (#3209) 2025-03-23 18:40:39 -04:00
Christian Byrne
f90d61fad5 [Style] Remove box shadow on template dialog sidenav (#3208) 2025-03-23 16:14:34 -04:00
Christian Byrne
17834459a1 Fix parsing workflows from 3d outputs on Windows (#3196) 2025-03-23 11:42:57 -07:00
Chenlei Hu
564c4d557f Migrate legacy reroute to litegraph native reroute (#3151)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-22 18:56:35 -04:00
filtered
f852639758 Fix slot types added multiple times (#3194) 2025-03-23 09:01:12 +11:00
Comfy Org PR Bot
eae538b08e [chore] Update litegraph to 0.11.0-2 (#3193)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-03-23 08:01:25 +11:00
Dr.Lt.Data
d23108433e refine locales/ko (#3185) 2025-03-22 15:06:10 -04:00
Comfy Org PR Bot
e6e7449ece [chore] Update litegraph to 0.11.0-1 (#3190)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-03-23 03:49:44 +11:00
Comfy Org PR Bot
22a1200bdf [chore] Update litegraph to 0.11.0-0 (#3183)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-03-22 10:08:41 +11:00
Chenlei Hu
ee20b63bc1 Remove unused plugin vite-plugin-static-copy (#3182) 2025-03-21 14:11:01 -04:00
Comfy Org PR Bot
0752e8b986 1.14.5 (#3181)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-21 11:07:21 -04:00
Christian Byrne
b234a68cf8 [Manager] Get node pack info on select (#3177) 2025-03-21 11:05:09 -04:00
Terry Jia
0863fda6a4 [3d] add support to export different formats (#3176)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-21 11:04:39 -04:00
Christian Byrne
8530406c3e [Manager] Set max size on custom node pack icons (#3178) 2025-03-21 11:04:23 -04:00
Christian Byrne
f7bfb6ec57 Fix Workflow Validation error when node pack 'unknown' version (#3179) 2025-03-21 11:04:06 -04:00
Comfy Org PR Bot
830933e78f Update locales for node definitions (#3174)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-20 21:44:46 -04:00
Comfy Org PR Bot
65693ed2be 1.14.4 (#3173)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-20 21:36:22 -04:00
Chenlei Hu
ed153dccd9 [Test] Fix flaky playwright tests (#3170) 2025-03-20 21:33:05 -04:00
Christian Byrne
fc7ed1bf09 Add Hunyuan3d template workflow titles i18 fields (#3171)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-03-20 21:31:42 -04:00
Christian Byrne
4dad89369a Load workflows from GLTF files (#3169)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-20 20:55:51 -04:00
Chenlei Hu
5b730517a3 [Test] Sync workflow instead of full page reload (#3168) 2025-03-20 20:42:12 -04:00
Chenlei Hu
af0bf05883 [TS] Add null checks to TreeExplorer.vue (#3166) 2025-03-20 13:08:22 -04:00
Chenlei Hu
d9e62ff860 [Vue] Use Vue 3.5 syntax for prop default value (#3165) 2025-03-20 12:40:49 -04:00
Chenlei Hu
d9ae6cb395 [TS] Use custom TreeNode type (#3164) 2025-03-20 12:03:47 -04:00
Chenlei Hu
b162963593 [TS] Fix TreeExplorerNode types (#3163) 2025-03-20 11:47:04 -04:00
Christian Byrne
c34cc301f1 [Manager] Allow cancelling registry requests by route (#3158) 2025-03-20 10:30:00 -04:00
Terry Jia
afdb94f12f [3d] refactor legacy code by using new vue style (#3161) 2025-03-20 10:28:35 -04:00
Dr.Lt.Data
cc8dc3dbfb refine locales/ko (#3162) 2025-03-20 10:27:55 -04:00
filtered
42d99fc37e Track floating link changes in undo/redo history (#3160) 2025-03-20 22:19:31 +11:00
Comfy Org PR Bot
1f03984d12 1.14.3 (#3155)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-19 22:01:08 -04:00
Terry Jia
d49815fcb4 [3d] add preview 3d for saveGlb (#3156) 2025-03-19 22:00:59 -04:00
Christian Byrne
4899a1d8f6 [Manager] Keep node previews inside info panel bounds (#3140) 2025-03-19 21:22:16 -04:00
Christian Byrne
71444d8c69 [Manager] Allow searching while in 'Missing' node packs tab (#3153) 2025-03-19 20:58:55 -04:00
Chenlei Hu
867ed4c1d7 [Schema] Update zod schema on zVector2 (#3152) 2025-03-19 20:58:13 -04:00
Christian Byrne
0c6957bfd8 [Manager] Use skeleton placeholder when loading (#3150) 2025-03-19 20:18:44 -04:00
Christian Byrne
fffce30e91 [Manager] Allow scrolling info panel while keeping the icon pinned at top (#3139) 2025-03-19 20:05:49 -04:00
Chenlei Hu
c554138887 [Develop] Remove duplicated run of tsc typecheck (#3149) 2025-03-19 14:26:22 -04:00
Chenlei Hu
5bbceea76c Downgrade vitest to 2.0.0 (#3148) 2025-03-19 14:22:44 -04:00
Christian Byrne
0f0601100f [Manager] Re-evaluate search results in installed tab when node packs change (#3144) 2025-03-19 10:34:24 -04:00
Christian Byrne
ff59245a7f [Manager] Use inject for installing button state (#3143) 2025-03-19 10:33:47 -04:00
Christian Byrne
c5af11d1ea [Manager] Remove title hover (#3142) 2025-03-19 10:30:42 -04:00
Christian Byrne
bc3e2e1597 [Manager] Auto scroll logs terminal to bottom (#3141)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-19 10:30:11 -04:00
filtered
e2a8456ff0 Remove yarn packageManager claim from package.json (#3145) 2025-03-19 19:26:35 +11:00
Christian Byrne
361c5ba930 [Manager] Fallback text on info panel (#3138)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-03-19 00:49:04 -07:00
Comfy Org PR Bot
bae47b80b3 1.14.2 (#3137)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-18 22:57:26 -04:00
Chenlei Hu
a049e9ae2d [TS] Enable strict mode (#3136) 2025-03-18 22:57:17 -04:00
Terry Jia
44edec7ad2 [TS] ts-strict for 3D components (#3135) 2025-03-18 22:40:13 -04:00
Chenlei Hu
db43f587a6 [TS] Fix ts-strict errors in Vue components (Part 4) (#3134) 2025-03-18 20:42:32 -04:00
Christian Byrne
8997ff4b2a [Manager] Add 'Missing' and 'In Workflow' tabs (#3133)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-18 20:21:03 -04:00
Chenlei Hu
91a8591249 Add support for webm video from SaveWEBM node (#3132) 2025-03-18 17:46:18 -04:00
Christian Byrne
ef74d7cb01 [Manager] Show node pack's dependencies in info panel (#3130)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-18 16:39:43 -04:00
Christian Byrne
8ab2334270 [Manager] Reset Manager state on reboot (#3129) 2025-03-18 16:38:17 -04:00
Chenlei Hu
59dbcc5261 [TS] Type component ref for 3D components (#3127) 2025-03-18 16:38:04 -04:00
Terry Jia
06488cc811 [3d] performance improve (#3131) 2025-03-18 16:23:32 -04:00
filtered
5a12bf33f3 Add graph ID and revision to schema (#3096) 2025-03-19 07:00:25 +11:00
Chenlei Hu
96ff8a7785 [TS] Fix ts-strict errors in Vue components (Part 3) (#3126) 2025-03-18 11:38:43 -04:00
Christian Byrne
a85a1bf794 [Manager] Add infinite scroll to search results (#3124) 2025-03-18 10:52:32 -04:00
Terry Jia
52bad3d0d1 [3d] support output normal and lineart at once (#3122) 2025-03-18 10:51:53 -04:00
Chenlei Hu
e8997a7653 [TS] Fix ts-strict errors in Vue components (Part 2) (#3123)
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-03-18 10:51:23 -04:00
Comfy Org PR Bot
0a6d3c0231 [chore] Update Comfy Registry API types from comfy-api@e40500f (#3121)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-03-17 19:07:10 -07:00
Chenlei Hu
2db29fc2af [TS] Fix ts-strict errors in Vue components (Part 1) (#3119) 2025-03-17 21:15:00 -04:00
Robin Huang
329bdff677 [Desktop] Add install path validation error messages (#3059)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-17 20:37:11 -04:00
Comfy Org PR Bot
906eb750ad 1.14.1 (#3118)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-03-17 20:35:26 -04:00
Christian Byrne
1a120adaea [Manager] Adjust node pack card style according to installing or disabled state (#3103) 2025-03-17 17:17:36 -07:00
Christian Byrne
26a7ebdd77 Fix uploaded image not forcing re-render (#3115)
Co-authored-by: github-actions <github-actions@github.com>
2025-03-17 20:13:05 -04:00
255 changed files with 14877 additions and 2573 deletions

View File

@@ -9,7 +9,7 @@ module.exports = defineConfig({
entry: 'src/locales/en',
entryLocale: 'en',
output: 'src/locales',
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr'],
outputLocales: ['zh', 'ru', 'ja', 'ko', 'fr', 'es'],
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, controlnet, lora.
'latent' is the short form of 'latent space'.
'mask' is in the context of image processing.

View File

@@ -0,0 +1,104 @@
{
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "KSampler",
"pos": {
"0": 304.3653259277344,
"1": 42.15586471557617
},
"size": [
315,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
},
{
"id": 1,
"type": "PrimitiveInt",
"pos": {
"0": 14,
"1": 43
},
"size": [
203.1999969482422,
40.368401303242536
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "value",
"type": "INT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "Int"
},
"widgets_values": [10]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

Binary file not shown.

View File

@@ -214,6 +214,10 @@ export class ComfyPage {
`Failed to setup workflows directory: ${await resp.text()}`
)
}
await this.page.evaluate(async () => {
await window['app'].extensionManager.workflow.syncWorkflows()
})
}
async setupUser(username: string) {
@@ -459,7 +463,14 @@ export class ComfyPage {
await this.nextFrame()
}
async dragAndDropFile(fileName: string) {
async dragAndDropFile(
fileName: string,
options: {
dropPosition?: Position
} = {}
) {
const { dropPosition = { x: 100, y: 100 } } = options
const filePath = this.assetPath(fileName)
// Read the file content
@@ -471,38 +482,63 @@ export class ComfyPage {
if (fileName.endsWith('.webp')) return 'image/webp'
if (fileName.endsWith('.webm')) return 'video/webm'
if (fileName.endsWith('.json')) return 'application/json'
if (fileName.endsWith('.glb')) return 'model/gltf-binary'
return 'application/octet-stream'
}
const fileType = getFileType(fileName)
await this.page.evaluate(
async ({ buffer, fileName, fileType }) => {
async ({ buffer, fileName, fileType, dropPosition }) => {
const file = new File([new Uint8Array(buffer)], fileName, {
type: fileType
})
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
const dropEvent = new DragEvent('drop', {
const targetElement = document.elementFromPoint(
dropPosition.x,
dropPosition.y
)
if (!targetElement) {
console.error('No element found at drop position:', dropPosition)
return { success: false, error: 'No element at position' }
}
const eventOptions = {
bubbles: true,
cancelable: true,
dataTransfer
})
dataTransfer,
clientX: dropPosition.x,
clientY: dropPosition.y
}
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false
})
Object.defineProperty(dropEvent, 'stopPropagation', {
value: () => {},
writable: false
})
document.dispatchEvent(dropEvent)
targetElement.dispatchEvent(dragOverEvent)
targetElement.dispatchEvent(dropEvent)
return {
success: true,
targetInfo: {
tagName: targetElement.tagName,
id: targetElement.id,
classList: Array.from(targetElement.classList)
}
}
},
{ buffer: [...new Uint8Array(buffer)], fileName, fileType }
{ buffer: [...new Uint8Array(buffer)], fileName, fileType, dropPosition }
)
await this.nextFrame()
@@ -553,11 +589,20 @@ export class ComfyPage {
await this.dragAndDrop(this.clipTextEncodeNode1InputSlot, this.emptySpace)
}
async connectEdge() {
await this.dragAndDrop(
this.loadCheckpointNodeClipOutputSlot,
this.clipTextEncodeNode1InputSlot
)
async connectEdge(
options: {
reverse?: boolean
} = {}
) {
const { reverse = false } = options
const start = reverse
? this.clipTextEncodeNode1InputSlot
: this.loadCheckpointNodeClipOutputSlot
const end = reverse
? this.loadCheckpointNodeClipOutputSlot
: this.clipTextEncodeNode1InputSlot
await this.dragAndDrop(start, end)
}
async adjustWidgetValue() {

View File

@@ -115,8 +115,20 @@ export class NodeWidgetReference {
}
)
}
}
async getValue() {
return await this.node.comfyPage.page.evaluate(
([id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
const widget = node.widgets[index]
if (!widget) throw new Error(`Widget ${index} not found.`)
return widget.value
},
[this.node.id, this.index] as const
)
}
}
export class NodeReference {
constructor(
readonly id: NodeId,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -309,3 +309,21 @@ test.describe('Feedback dialog', () => {
await expect(feedbackHeader).not.toBeVisible()
})
})
test.describe('Error dialog', () => {
test('Should display an error dialog when graph configure fails', async ({
comfyPage
}) => {
await comfyPage.page.evaluate(() => {
const graph = window['graph']
graph.configure = () => {
throw new Error('Error on configure!')
}
})
await comfyPage.loadWorkflow('default')
const errorDialog = comfyPage.page.locator('.error-dialog-content')
await expect(errorDialog).toBeVisible()
})
})

View File

@@ -5,8 +5,8 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('DOM Widget', () => {
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('collapsed_multiline')
expect(comfyPage.page.locator('.comfy-multiline-input')).not.toBeVisible()
const textareaWidget = comfyPage.page.locator('.comfy-multiline-input')
await expect(textareaWidget).not.toBeVisible()
})
test('Multiline textarea correctly collapses', async ({ comfyPage }) => {

View File

@@ -91,15 +91,20 @@ test.describe('Node Interaction', () => {
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge()
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
// Test both directions of edge connection.
;[{ reverse: false }, { reverse: true }].forEach(({ reverse }) => {
test(`Can disconnect/connect edge ${reverse ? 'reverse' : 'normal'}`, async ({
comfyPage
}) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge({ reverse })
// Move mouse to empty area to avoid slot highlight.
await comfyPage.moveMouseToEmptyArea()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -8,7 +8,8 @@ test.describe('Load Workflow in Media', () => {
'edited_workflow.webp',
'no_workflow.webp',
'large_workflow.webp',
'workflow.webm'
'workflow.webm',
'workflow.glb'
].forEach(async (fileName) => {
test(`Load workflow in ${fileName}`, async ({ comfyPage }) => {
await comfyPage.dragAndDropFile(fileName)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -5,14 +5,14 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Primitive Node', () => {
test('Can load with correct size', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node')
await comfyPage.loadWorkflow('primitive/primitive_node')
await expect(comfyPage.canvas).toHaveScreenshot('primitive_node.png')
})
// When link is dropped on widget, it should automatically convert the widget
// to input.
test('Can connect to widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node_unconnected')
await comfyPage.loadWorkflow('primitive/primitive_node_unconnected')
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const ksamplerNode: NodeReference = await comfyPage.getNodeRefById(2)
// Connect the output of the primitive node to the input of first widget of the ksampler node
@@ -23,7 +23,9 @@ test.describe('Primitive Node', () => {
})
test('Can connect to dom widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive_node_unconnected_dom_widget')
await comfyPage.loadWorkflow(
'primitive/primitive_node_unconnected_dom_widget'
)
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const clipEncoderNode: NodeReference = await comfyPage.getNodeRefById(2)
await primitiveNode.connectWidget(0, clipEncoderNode, 0)
@@ -31,4 +33,14 @@ test.describe('Primitive Node', () => {
'primitive_node_connected_dom_widget.png'
)
})
test('Can connect to static primitive node', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('primitive/static_primitive_unconnected')
const primitiveNode: NodeReference = await comfyPage.getNodeRefById(1)
const ksamplerNode: NodeReference = await comfyPage.getNodeRefById(2)
await primitiveNode.connectWidget(0, ksamplerNode, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'static_primitive_connected.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@@ -321,6 +321,7 @@ test.describe('Remote COMBO Widget', () => {
// Click refresh button
await clickRefreshButton(comfyPage, nodeName)
await comfyPage.page.waitForTimeout(200)
// Verify the selected value of the widget is the first option in the refreshed list
const refreshedValue = await getWidgetValue(comfyPage, nodeName)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -30,6 +30,46 @@ test.describe('Selection Toolbox', () => {
).toBeVisible()
})
test('shows at correct position when node is pasted', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('single_ksampler')
await comfyPage.selectNodes(['KSampler'])
await comfyPage.ctrlC()
await comfyPage.page.mouse.move(100, 100)
await comfyPage.ctrlV()
const overlayContainer = comfyPage.page.locator(
'.selection-overlay-container'
)
await expect(overlayContainer).toBeVisible()
// Verify the absolute position
const boundingBox = await overlayContainer.boundingBox()
expect(boundingBox).not.toBeNull()
// 10px offset for the pasted node
expect(Math.round(boundingBox!.x)).toBeCloseTo(90, -1) // Allow ~10px tolerance
// 30px offset of node title height
expect(Math.round(boundingBox!.y)).toBeCloseTo(60, -1)
})
test('hide when select and drag happen at the same time', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('single_ksampler')
const node = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
const nodePos = await node.getPosition()
// Drag on the title of the node
await comfyPage.page.mouse.move(nodePos.x + 100, nodePos.y - 15)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(nodePos.x + 200, nodePos.y + 200)
await comfyPage.nextFrame()
await expect(
comfyPage.page.locator('.selection-overlay-container')
).not.toBeVisible()
})
test('shows border only with multiple selections', async ({ comfyPage }) => {
// Select single node
await comfyPage.selectNodes(['KSampler'])

View File

@@ -34,7 +34,6 @@ test.describe('Workflows sidebar', () => {
'workflow1.json': 'default.json',
'workflow2.json': 'default.json'
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -77,7 +76,6 @@ test.describe('Workflows sidebar', () => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'single_ksampler.json'
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -101,7 +99,6 @@ test.describe('Workflows sidebar', () => {
'bar.json': 'default.json'
}
})
await comfyPage.setup()
const tab = comfyPage.menu.workflowsTab
await tab.open()
@@ -230,6 +227,7 @@ test.describe('Workflows sidebar', () => {
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog.click('overwrite')
await comfyPage.page.waitForTimeout(200)
// The old workflow1.json should be deleted and the new one should be saved.
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
'workflow2.json',
@@ -323,7 +321,7 @@ test.describe('Workflows sidebar', () => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json'
})
await comfyPage.setup()
await comfyPage.menu.workflowsTab.open()
const nodeCount = await comfyPage.getGraphNodesCount()

View File

@@ -128,11 +128,62 @@ test.describe('Dynamic widget manipulation', () => {
})
})
test.describe('Load image widget', () => {
test.describe('Image widget', () => {
test('Can load image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
await expect(comfyPage.canvas).toHaveScreenshot('load_image_widget.png')
})
test('Can drag and drop image', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
// Get position of the load image node
const nodes = await comfyPage.getNodeRefsByType('LoadImage')
const loadImageNode = nodes[0]
const { x, y } = await loadImageNode.getPosition()
// Drag and drop image file onto the load image node
await comfyPage.dragAndDropFile('image32x32.webp', {
dropPosition: { x, y }
})
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_drag_and_dropped.png'
)
// Expect the filename combo value to be updated
const fileComboWidget = await loadImageNode.getWidget(0)
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
})
test('Can change image by changing the filename combo value', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('widgets/load_image_widget')
const nodes = await comfyPage.getNodeRefsByType('LoadImage')
const loadImageNode = nodes[0]
// Click the combo widget used to select the image filename
const fileComboWidget = await loadImageNode.getWidget(0)
await fileComboWidget.click()
// Select a new image filename value from the combo context menu
const comboEntry = comfyPage.page.getByRole('menuitem', {
name: 'image32x32.webp'
})
await comboEntry.click({ noWaitAfter: true })
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_changed_by_combo_value.png'
)
// Expect the filename combo value to be updated
const filename = await fileComboWidget.getValue()
expect(filename).toBe('image32x32.webp')
})
})
test.describe('Load audio widget', () => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

2
global.d.ts vendored
View File

@@ -1,6 +1,8 @@
declare const __COMFYUI_FRONTEND_VERSION__: string
declare const __SENTRY_ENABLED__: boolean
declare const __SENTRY_DSN__: string
declare const __ALGOLIA_APP_ID__: string
declare const __ALGOLIA_API_KEY__: string
interface Navigator {
/**

View File

@@ -3,9 +3,7 @@ export default {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles) => [
...formatAndEslint(stagedFiles),
'vue-tsc --noEmit',
'tsc --noEmit',
'tsc-strict'
'vue-tsc --noEmit'
]
}

1274
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.14.0",
"version": "1.15.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -13,7 +13,7 @@
"build": "npm run typecheck && vite build",
"build:types": "vite build --config vite.types.config.mts && node scripts/prepare-types.js",
"zipdist": "node scripts/zipdist.js",
"typecheck": "vue-tsc --noEmit && tsc --noEmit && tsc-strict",
"typecheck": "vue-tsc --noEmit",
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}'",
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
"test:browser": "npx playwright test",
@@ -58,13 +58,11 @@
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
"typescript-strict-plugin": "^2.4.4",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.4.14",
"vite": "^5.4.15",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.1.9",
"vitest": "^2.0.0",
"vue-tsc": "^2.1.10",
"zip-dir": "^2.0.0",
"zod-to-json-schema": "^3.24.1"
@@ -72,8 +70,8 @@
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.20",
"@comfyorg/litegraph": "^0.10.9",
"@comfyorg/comfyui-electron-types": "^0.4.31",
"@comfyorg/litegraph": "^0.11.3",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",

View File

@@ -28,7 +28,7 @@ const handleKey = (e: KeyboardEvent) => {
useEventListener(window, 'keydown', handleKey)
useEventListener(window, 'keyup', handleKey)
const showContextMenu = (event: PointerEvent) => {
const showContextMenu = (event: MouseEvent) => {
const { target } = event
switch (true) {
case target instanceof HTMLTextAreaElement:
@@ -40,6 +40,7 @@ const showContextMenu = (event: PointerEvent) => {
}
onMounted(() => {
// @ts-expect-error fixme ts strict error
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
console.log('ComfyUI Front-end version:', config.app_version)

View File

@@ -2,8 +2,8 @@
<Splitter
class="splitter-overlay-root splitter-overlay"
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
:key="activeSidebarTabId"
:stateKey="activeSidebarTabId"
:key="activeSidebarTabId ?? undefined"
:stateKey="activeSidebarTabId ?? undefined"
stateStorage="local"
>
<SplitterPanel

View File

@@ -1,7 +1,6 @@
<template>
<div
class="batch-count"
:class="props.class"
v-tooltip.bottom="{
value: $t('menu.batchCount'),
showDelay: 600
@@ -41,14 +40,6 @@ import { computed } from 'vue'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
interface Props {
class?: string
}
const props = withDefaults(defineProps<Props>(), {
class: ''
})
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1

View File

@@ -135,8 +135,11 @@ const hasPendingTasks = computed(
)
const commandStore = useCommandStore()
const queuePrompt = (e: MouseEvent) => {
const commandId = e.shiftKey ? 'Comfy.QueuePromptFront' : 'Comfy.QueuePrompt'
const queuePrompt = (e: Event) => {
const commandId =
'shiftKey' in e && e.shiftKey
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
commandStore.execute(commandId)
}
</script>

View File

@@ -12,11 +12,11 @@ import { Ref, onUnmounted, ref } from 'vue'
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
const emit = defineEmits<{
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement>]
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement | undefined>]
unmounted: []
}>()
const terminalEl = ref<HTMLElement>()
const rootEl = ref<HTMLElement>()
const terminalEl = ref<HTMLElement | undefined>()
const rootEl = ref<HTMLElement | undefined>()
emit('created', useTerminal(terminalEl), rootEl)
onUnmounted(() => emit('unmounted'))

View File

@@ -13,7 +13,7 @@ import BaseTerminal from './BaseTerminal.vue'
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
root: Ref<HTMLElement | undefined>
) => {
const terminalApi = electronAPI().Terminal

View File

@@ -27,7 +27,7 @@ const loading = ref(true)
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
root: Ref<HTMLElement | undefined>
) => {
// `autoCols` is false because we don't want the progress bar in the terminal
// to render incorrectly as the progress bar is rendered based on the

View File

@@ -18,7 +18,7 @@
:src="src"
@error="handleImageError"
class="comfy-image-main"
:class="[...classArray]"
:class="classProp"
:alt="alt"
/>
</span>
@@ -29,36 +29,24 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ref } from 'vue'
const props = withDefaults(
defineProps<{
src: string
class?: string | string[] | object
contain: boolean
alt?: string
}>(),
{
contain: false,
alt: 'Image content'
}
)
const {
src,
class: classProp,
contain = false,
alt = 'Image content'
} = defineProps<{
src: string
class?: any
contain?: boolean
alt?: string
}>()
const imageBroken = ref(false)
const handleImageError = () => {
imageBroken.value = true
}
const classArray = computed(() => {
if (Array.isArray(props.class)) {
return props.class
} else if (typeof props.class === 'string') {
return props.class.split(' ')
} else if (typeof props.class === 'object') {
return Object.keys(props.class).filter((key) => props.class[key])
}
return []
})
</script>
<style scoped>

View File

@@ -98,12 +98,14 @@ const defaultIcon = iconOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
)
// @ts-expect-error fixme ts strict error
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const resetCustomization = () => {
// @ts-expect-error fixme ts strict error
selectedIcon.value =
iconOptions.find((option) => option.value === props.initialIcon) ||
defaultIcon

View File

@@ -2,7 +2,9 @@
<div class="grid grid-cols-2 gap-2">
<template v-for="col in deviceColumns" :key="col.field">
<div class="font-medium">{{ col.header }}</div>
<div>{{ formatValue(props.device[col.field], col.field) }}</div>
<div>
{{ formatValue(props.device[col.field], col.field) }}
</div>
</template>
</div>
</template>
@@ -15,7 +17,7 @@ const props = defineProps<{
device: DeviceStats
}>()
const deviceColumns = [
const deviceColumns: { field: keyof DeviceStats; header: string }[] = [
{ field: 'name', header: 'Name' },
{ field: 'type', header: 'Type' },
{ field: 'vram_total', header: 'VRAM Total' },

View File

@@ -1,6 +1,6 @@
<template>
<div class="editable-text">
<span v-if="!props.isEditing">
<span v-if="!isEditing">
{{ modelValue }}
</span>
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
@@ -27,30 +27,27 @@
import InputText from 'primevue/inputtext'
import { nextTick, ref, watch } from 'vue'
interface EditableTextProps {
const { modelValue, isEditing = false } = defineProps<{
modelValue: string
isEditing?: boolean
}
const props = withDefaults(defineProps<EditableTextProps>(), {
isEditing: false
})
}>()
const emit = defineEmits(['update:modelValue', 'edit'])
const inputValue = ref<string>(props.modelValue)
const inputRef = ref(null)
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const blurInputElement = () => {
// @ts-expect-error - $el is an internal property of the InputText component
inputRef.value?.$el.blur()
}
const finishEditing = () => {
emit('edit', inputValue.value)
}
watch(
() => props.isEditing,
() => isEditing,
(newVal) => {
if (newVal) {
inputValue.value = props.modelValue
inputValue.value = modelValue
nextTick(() => {
if (!inputRef.value) return
const fileName = inputValue.value.includes('.')
@@ -58,6 +55,7 @@ watch(
: inputValue.value
const start = 0
const end = fileName.length
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange?.(start, end)
})

View File

@@ -101,13 +101,16 @@ const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const electronDownloadStore = useElectronDownloadStore()
// @ts-expect-error fixme ts strict error
const [savePath, filename] = props.label.split('/')
electronDownloadStore.$subscribe((_, { downloads }) => {
const download = downloads.find((download) => props.url === download.url)
if (download) {
// @ts-expect-error fixme ts strict error
downloadProgress.value = Number((download.progress * 100).toFixed(1))
// @ts-expect-error fixme ts strict error
status.value = download.status
}
})

View File

@@ -72,7 +72,7 @@ function getFormAttrs(item: FormItem) {
item.options(formValue.value)
: item.options
if (typeof item.options[0] !== 'string') {
if (typeof item.options?.[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}

View File

@@ -76,7 +76,7 @@ const updateValue = (newValue: number | null) => {
const displayValue = (value: number): string => {
updateValue(value)
const stepString = props.step.toString()
const stepString = (props.step ?? 1).toString()
const resolution = stepString.includes('.')
? stepString.split('.')[1].length
: 0

View File

@@ -2,7 +2,7 @@
<div class="input-slider flex flex-row items-center gap-2">
<Slider
:modelValue="modelValue"
@update:modelValue="updateValue"
@update:modelValue="(value) => updateValue(value as number)"
class="slider-part"
:class="sliderClass"
:min="min"

View File

@@ -13,9 +13,9 @@
<template>
<Button
class="relative p-button-icon-only"
:outlined="props.outlined"
:severity="props.severity"
:disabled="active || props.disabled"
:outlined="outlined"
:severity="severity"
:disabled="active || disabled"
@click="(event) => $emit('refresh', event)"
>
<span
@@ -34,16 +34,15 @@ import ProgressSpinner from 'primevue/progressspinner'
import { VueSeverity } from '@/types/primeVueTypes'
// Properties
interface Props {
outlined?: boolean
const {
disabled,
outlined = true,
severity = 'secondary'
} = defineProps<{
disabled?: boolean
outlined?: boolean
severity?: VueSeverity
}
const props = withDefaults(defineProps<Props>(), {
outlined: true,
severity: 'secondary'
})
}>()
// Model
const active = defineModel<boolean>({ required: true })

View File

@@ -48,15 +48,16 @@ const systemInfo = computed(() => ({
argv: props.stats.system.argv.join(' ')
}))
const systemColumns = [
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total' },
{ field: 'ram_free', header: 'RAM Free' }
]
const systemColumns: { field: keyof SystemStats['system']; header: string }[] =
[
{ field: 'os', header: 'OS' },
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total' },
{ field: 'ram_free', header: 'RAM Free' }
]
const formatValue = (value: any, field: string) => {
if (['ram_total', 'ram_free'].includes(field)) {

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center" :class="props.class">
<div class="flex items-center">
<span v-if="position === 'left'" class="mr-2 shrink-0">{{ text }}</span>
<Divider :align="align" :type="type" :layout="layout" class="flex-grow" />
<span v-if="position === 'right'" class="ml-2 shrink-0">{{ text }}</span>
@@ -9,19 +9,17 @@
<script setup lang="ts">
import Divider from 'primevue/divider'
interface Props {
const {
text,
position = 'left',
align = 'center',
type = 'solid',
layout = 'horizontal'
} = defineProps<{
text: string
class?: string
position?: 'left' | 'right'
align?: 'left' | 'center' | 'right' | 'top' | 'bottom'
type?: 'solid' | 'dashed' | 'dotted'
layout?: 'horizontal' | 'vertical'
}
const props = withDefaults(defineProps<Props>(), {
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
})
}>()
</script>

View File

@@ -53,7 +53,9 @@ import {
} from '@/types/treeExplorerTypes'
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys')
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys', {
required: true
})
provide(InjectKeyExpandedKeys, expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
// Tracks whether the caller has set the selectionKeys model.
@@ -103,7 +105,7 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
return isExpanded ? 'pi pi-folder-open' : 'pi pi-folder'
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const children = node.children?.map(fillNodeInfo)
const children = node.children?.map(fillNodeInfo) ?? []
const totalLeaves = node.leaf
? 1
: children.reduce((acc, child) => acc + child.totalLeaves, 0)
@@ -113,7 +115,7 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves,
badgeText: node.getBadgeText ? node.getBadgeText() : null,
badgeText: node.getBadgeText ? node.getBadgeText() : undefined,
isEditingLabel: node.key === renameEditingNode.value?.key
}
}
@@ -129,7 +131,7 @@ const onNodeContentClick = async (
}
emit('nodeClick', node, e)
}
const menu = ref(null)
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
@@ -149,7 +151,7 @@ const handleNodeLabelEdit = async (
if (node.key === newFolderNode.value?.key) {
await handleFolderCreation(newName)
} else {
await node.handleRename(newName)
await node.handleRename?.(newName)
}
},
node.handleError,
@@ -174,22 +176,32 @@ const menuItems = computed<MenuItem[]>(() =>
{
label: t('g.rename'),
icon: 'pi pi-file-edit',
command: () => renameCommand(menuTargetNode.value),
command: () => {
if (menuTargetNode.value) {
renameCommand(menuTargetNode.value)
}
},
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
command: () => {
if (menuTargetNode.value) {
deleteCommand(menuTargetNode.value)
}
},
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
].map((menuItem) => ({
].map((menuItem: MenuItem) => ({
...menuItem,
command: wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
command: menuItem.command
? wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
: undefined
}))
)
@@ -217,14 +229,15 @@ const wrapCommandWithErrorHandler = (
}
defineExpose({
renameCommand,
deleteCommand,
/**
* The command to add a folder to a node via the context menu
* @param targetNodeKey - The key of the node where the folder will be added under
*/
addFolderCommand: (targetNodeKey: string) => {
addFolderCommand(findNodeByKey(renderedRoot.value, targetNodeKey))
const targetNode = findNodeByKey(renderedRoot.value, targetNodeKey)
if (targetNode) {
addFolderCommand(targetNode)
}
}
})
</script>

View File

@@ -76,10 +76,10 @@ const nodeBadgeText = computed<string>(() => {
})
const showNodeBadgeText = computed<boolean>(() => nodeBadgeText.value !== '')
const isEditing = computed<boolean>(() => props.node.isEditingLabel)
const isEditing = computed<boolean>(() => props.node.isEditingLabel ?? false)
const handleEditLabel = inject(InjectKeyHandleEditLabelFunction)
const handleRename = (newName: string) => {
handleEditLabel(props.node, newName)
handleEditLabel?.(props.node, newName)
}
const container = ref<HTMLElement | null>(null)
@@ -102,7 +102,7 @@ if (props.node.draggable) {
? ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
render: ({ container }) => {
return props.node.renderDragPreview(container)
return props.node.renderDragPreview?.(container)
},
nativeSetDragImage
})

View File

@@ -68,9 +68,9 @@ onMounted(async () => {
await validateUrl(props.modelValue)
})
const handleInput = (value: string) => {
const handleInput = (value: string | undefined) => {
// Update internal value without emitting
internalValue.value = cleanInput(value)
internalValue.value = cleanInput(value ?? '')
// Reset validation state when user types
validationState.value = ValidationState.IDLE
}

View File

@@ -15,10 +15,16 @@
</template>
<script setup lang="ts" generic="T">
import { useElementSize, useScroll } from '@vueuse/core'
import { useElementSize, useScroll, whenever } from '@vueuse/core'
import { clamp, debounce } from 'lodash'
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
type GridState = {
start: number
end: number
isNearEnd: boolean
}
const {
items,
bufferRows = 1,
@@ -36,6 +42,13 @@ const {
defaultItemWidth?: number
}>()
const emit = defineEmits<{
/**
* Emitted when `bufferRows` (or fewer) rows remaining between scrollY and grid bottom.
*/
'approach-end': []
}>()
const itemHeight = ref(defaultItemHeight)
const itemWidth = ref(defaultItemWidth)
const container = ref<HTMLElement | null>(null)
@@ -50,22 +63,31 @@ const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
const state = computed<{ start: number; end: number }>(() => {
const state = computed<GridState>(() => {
const fromRow = offsetRows.value - bufferRows
const toRow = offsetRows.value + bufferRows + viewRows.value
const fromCol = fromRow * cols.value
const toCol = toRow * cols.value
const remainingCol = items.length - toCol
return {
start: clamp(fromCol, 0, items?.length),
end: clamp(toCol, fromCol, items?.length)
end: clamp(toCol, fromCol, items?.length),
isNearEnd: remainingCol <= cols.value * bufferRows
}
})
const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
whenever(
() => state.value.isNearEnd,
() => {
emit('approach-end')
}
)
const updateItemSize = () => {
if (container.value) {
const firstItem = container.value.querySelector('[data-virtual-grid-item]')

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
@@ -14,6 +13,7 @@ describe('EditableText', () => {
app.use(PrimeVue)
})
// @ts-expect-error fixme ts strict error
const mountComponent = (props, options = {}) => {
return mount(EditableText, {
global: {
@@ -65,6 +65,7 @@ describe('EditableText', () => {
})
await wrapper.findComponent(InputText).trigger('blur')
expect(wrapper.emitted('edit')).toBeTruthy()
// @ts-expect-error fixme ts strict error
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
})
})

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import Badge from 'primevue/badge'
@@ -59,6 +58,7 @@ describe('TreeExplorerTreeNode', () => {
expect(wrapper.findComponent(EditableText).props('modelValue')).toBe(
'Test Node'
)
// @ts-expect-error fixme ts strict error
expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3')
})

View File

@@ -0,0 +1,80 @@
<template>
<div class="error-dialog-content flex flex-col gap-4">
<NoResultsPlaceholder
class="pb-0"
icon="pi pi-exclamation-circle"
:title="title"
:message="errorMessage"
/>
<pre
class="stack-trace p-5 text-neutral-400 text-xs max-h-[50vh] overflow-auto bg-black/20"
>
{{ stackTrace }}
</pre>
<template v-if="extensionFile">
<span>{{ t('errorDialog.extensionFileHint') }}:</span>
<br />
<span class="font-bold">{{ extensionFile }}</span>
</template>
<Button
v-show="!sendReportOpen"
text
fluid
:label="$t('issueReport.helpFix')"
@click="showSendReport"
/>
<ReportIssuePanel
v-if="sendReportOpen"
:error-type="errorType"
:extra-fields="[
{
label: t('issueReport.stackTrace'),
value: 'StackTrace',
optIn: true,
getData: () => stackTrace
}
]"
:tags="{
exceptionMessage: errorMessage,
extensionFile: extensionFile ?? 'UNKNOWN'
}"
:title="t('issueReport.submitErrorReport')"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import ReportIssuePanel from './error/ReportIssuePanel.vue'
const { t } = useI18n()
const {
title: _title,
errorMessage,
stackTrace: _stackTrace,
extensionFile,
errorType = 'frontendError'
} = defineProps<{
title?: string
errorMessage: string
stackTrace?: string
extensionFile?: string
errorType?: string
}>()
const title = computed(() => _title ?? t('errorDialog.defaultTitle'))
const stackTrace = computed(() => _stackTrace ?? t('errorDialog.noStackTrace'))
const sendReportOpen = ref(false)
function showSendReport() {
sendReportOpen.value = true
}
</script>

View File

@@ -20,7 +20,7 @@
>
<div v-for="(panel, index) in taskPanels" :key="index">
<Panel
:expanded="expandedPanels[index] || false"
:expanded="collapsedPanels[index] || false"
toggleable
class="shadow-elevation-1 rounded-lg mt-2 dark-theme:bg-black dark-theme:border-black"
>
@@ -28,11 +28,11 @@
<div class="flex items-center justify-between w-full py-2">
<div class="flex flex-col text-sm font-medium leading-normal">
<span>{{ panel.taskName }}</span>
<span v-show="expandedPanels[index]" class="text-muted">
<span class="text-muted">
{{
index === taskPanels.length - 1
? 'In progress'
: 'Completed '
isInProgress(index)
? $t('g.inProgress')
: $t('g.completed') + ' ✓'
}}
</span>
</div>
@@ -41,9 +41,9 @@
<template #toggleicon>
<Button
:icon="
expandedPanels[index]
? 'pi pi-chevron-down'
: 'pi pi-chevron-right'
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
text
class="text-neutral-300"
@@ -51,11 +51,17 @@
/>
</template>
<div
:ref="
index === taskPanels.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="overflow-y-auto h-64 rounded-lg bg-black"
:class="{
'h-64': index !== taskPanels.length - 1,
'flex-grow': index === taskPanels.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
@@ -86,26 +92,68 @@ import {
const { taskLogs } = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const managerStore = useComfyManagerStore()
const isInProgress = (index: number) =>
index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const expandedPanels = ref<Record<number, boolean>>({})
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
expandedPanels.value[index] = !expandedPanels.value[index]
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: {
passive: true
}
})
const scrollToBottom = () => {
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => taskPanels.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
const scrollLastPanelToBottom = () => {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
const scrollContentToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
whenever(() => isExpanded.value, scrollToBottom)
const resetUserScrolling = () => {
isUserScrolling.value = false
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
const onLogsAdded = () => {
// If user is scrolling manually, don't automatically scroll to bottom
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(isCollapsed, resetUserScrolling)
onMounted(() => {
expandedPanels.value = {}
scrollToBottom()
scrollContentToBottom()
})
onBeforeUnmount(() => {

View File

@@ -35,9 +35,10 @@ const onConfirm = () => {
useDialogStore().closeDialog()
}
const inputRef = ref(null)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const selectAllText = () => {
if (!inputRef.value) return
// @ts-expect-error - $el is an internal property of the InputText component
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(0, inputElement.value.length)
}

View File

@@ -15,7 +15,7 @@
scrollHeight="100%"
:optionDisabled="
(option: SettingTreeNode) =>
!queryIsEmpty && !searchResultsCategories.has(option.label)
!queryIsEmpty && !searchResultsCategories.has(option.label ?? '')
"
class="border-none w-full"
/>
@@ -31,7 +31,7 @@
<PanelTemplate
v-for="category in settingCategories"
:key="category.key"
:value="category.label"
:value="category.label ?? ''"
>
<template #header>
<CurrentUserMessage v-if="tabValue === 'Comfy'" />
@@ -266,8 +266,8 @@ const handleSearch = (query: string) => {
const queryIsEmpty = computed(() => searchQuery.value.length === 0)
const inSearch = computed(() => !queryIsEmpty.value && !searchInProgress.value)
const tabValue = computed(() =>
inSearch.value ? 'Search Results' : activeCategory.value?.label
const tabValue = computed<string>(() =>
inSearch.value ? 'Search Results' : activeCategory.value?.label ?? ''
)
// Don't allow null category to be set outside of search.
// In search mode, the active category can be null to show all search results.

View File

@@ -0,0 +1,176 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import ManagerProgressDialogContent from '../ManagerProgressDialogContent.vue'
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
lastPanelRef: HTMLElement | null
onLogsAdded: () => void
handleScroll: (e: { target: HTMLElement }) => void
isUserScrolling: boolean
resetUserScrolling: () => void
collapsedPanels: Record<number, boolean>
togglePanel: (index: number) => void
}
const mockCollapse = vi.fn()
const defaultMockTaskLogs = [
{ taskName: 'Task 1', logs: ['Log 1', 'Log 2'] },
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
]
vi.mock('@/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs]
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
collapse: mockCollapse
}))
}))
describe('ManagerProgressDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollapse.mockReset()
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(ManagerProgressDialogContent, {
props: {
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Panel,
Button
}
}
}) as VueWrapper<ComponentInstance>
}
it('renders the correct number of panels', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findAllComponents(Panel).length).toBe(2)
})
it('expands the last panel by default', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.vm.collapsedPanels[1]).toBeFalsy()
})
it('toggles panel expansion when toggle method is called', async () => {
const wrapper = mountComponent()
await nextTick()
// Initial state - first panel should be collapsed
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
wrapper.vm.togglePanel(0)
await nextTick()
// After toggle - first panel should be expanded
expect(wrapper.vm.collapsedPanels[0]).toBe(true)
wrapper.vm.togglePanel(0)
await nextTick()
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
})
it('displays the correct status for each panel', async () => {
const wrapper = mountComponent()
await nextTick()
// Expand all panels to see status text
const panels = wrapper.findAllComponents(Panel)
for (let i = 0; i < panels.length; i++) {
if (!wrapper.vm.collapsedPanels[i]) {
wrapper.vm.togglePanel(i)
await nextTick()
}
}
const panelsText = wrapper
.findAllComponents(Panel)
.map((panel) => panel.text())
expect(panelsText[0]).toContain('Completed ✓')
expect(panelsText[1]).toContain('Completed ✓')
})
it('auto-scrolls to bottom when new logs are added', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 0,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.onLogsAdded()
await nextTick()
// Check if scrollTop is set to scrollHeight (scrolled to bottom)
expect(mockScrollElement.scrollTop).toBe(200)
})
it('does not auto-scroll when user is manually scrolling', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 50,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.handleScroll({ target: mockScrollElement })
await nextTick()
expect(wrapper.vm.isUserScrolling).toBe(true)
// Now trigger the log update
wrapper.vm.onLogsAdded()
await nextTick()
// Check that scrollTop is not changed (should still be 50)
expect(mockScrollElement.scrollTop).toBe(50)
})
it('calls collapse method when component is unmounted', async () => {
const wrapper = mountComponent()
await nextTick()
wrapper.unmount()
expect(mockCollapse).toHaveBeenCalled()
})
})

View File

@@ -4,7 +4,7 @@
@submit="submit"
:resolver="zodResolver(issueReportSchema)"
>
<Panel :pt="$attrs.pt">
<Panel :pt="$attrs.pt as any">
<template #header>
<div class="flex items-center gap-2">
<span class="font-bold">{{ title }}</span>
@@ -187,7 +187,7 @@ const createUser = (formData: IssueReportFormData): User => ({
})
const createExtraData = async (formData: IssueReportFormData) => {
const result = {}
const result: Record<string, unknown> = {}
const isChecked = (fieldValue: string) => formData[fieldValue]
await Promise.all(
@@ -243,7 +243,7 @@ const submit = async (event: FormSubmitEvent) => {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.message,
detail: error instanceof Error ? error.message : String(error),
life: 3000
})
}

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import Checkbox from 'primevue/checkbox'
@@ -95,12 +94,15 @@ vi.mock('@primevue/forms', () => ({
},
methods: {
onSubmit() {
// @ts-expect-error fixme ts strict error
this.$emit('submit', {
valid: true,
// @ts-expect-error fixme ts strict error
values: this.formValues
})
},
updateFieldValue(name: string, value: any) {
// @ts-expect-error fixme ts strict error
this.formValues[name] = value
}
}
@@ -116,13 +118,17 @@ vi.mock('@primevue/forms', () => ({
}
},
methods: {
// @ts-expect-error fixme ts strict error
updateValue(value) {
// @ts-expect-error fixme ts strict error
this.modelValue = value
// @ts-expect-error fixme ts strict error
let parent = this.$parent
while (parent && parent.$options.name !== 'Form') {
parent = parent.$parent
}
if (parent) {
// @ts-expect-error fixme ts strict error
parent.updateFieldValue(this.name, value)
}
}
@@ -163,6 +169,7 @@ describe('ReportIssuePanel', () => {
for (const field of DEFAULT_FIELDS) {
const checkbox = checkboxes.find(
// @ts-expect-error fixme ts strict error
(checkbox) => checkbox.props('value') === field
)
expect(checkbox).toBeDefined()
@@ -218,12 +225,11 @@ describe('ReportIssuePanel', () => {
})
// Filter out the contact preferences checkboxes
const fieldCheckboxes = wrapper
.findAllComponents(Checkbox)
.filter(
(checkbox) =>
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
)
const fieldCheckboxes = wrapper.findAllComponents(Checkbox).filter(
// @ts-expect-error fixme ts strict error
(checkbox) =>
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
)
expect(fieldCheckboxes.length).toBe(1)
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
})
@@ -235,6 +241,7 @@ describe('ReportIssuePanel', () => {
})
const customCheckbox = wrapper
.findAllComponents(Checkbox)
// @ts-expect-error fixme ts strict error
.find((checkbox) => checkbox.props('value') === 'CustomField')
expect(customCheckbox).toBeDefined()
})

View File

@@ -34,10 +34,10 @@
/>
<div class="flex-1 overflow-auto">
<div
v-if="isLoading || isInitialLoad"
class="flex justify-center items-center h-full"
v-if="isLoading"
class="w-full h-full overflow-auto scrollbar-hide"
>
<ProgressSpinner />
<GridSkeleton :grid-style="GRID_STYLE" :skeleton-card-count />
</div>
<NoResultsPlaceholder
v-else-if="searchResults.length === 0"
@@ -55,13 +55,9 @@
<div v-else class="h-full" @click="handleGridContainerClick">
<VirtualGrid
:items="resultsWithKeys"
:buffer-rows="5"
:gridStyle="{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(22rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
}"
:buffer-rows="3"
:gridStyle="GRID_STYLE"
@approach-end="onApproachEnd"
>
<template #item="{ item }">
<PackCard
@@ -83,7 +79,7 @@
<ContentDivider orientation="vertical" :width="0.2" />
<div class="flex-1 flex flex-col isolate">
<InfoPanel
v-if="!hasMultipleSelections"
v-if="!hasMultipleSelections && selectedNodePack"
:node-pack="selectedNodePack"
/>
<InfoPanelMultiItem v-else :node-packs="selectedNodePacks" />
@@ -94,9 +90,10 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { merge } from 'lodash'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watchEffect } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
@@ -107,15 +104,33 @@ import InfoPanel from '@/components/dialog/content/manager/infoPanel/InfoPanel.v
import InfoPanelMultiItem from '@/components/dialog/content/manager/infoPanel/InfoPanelMultiItem.vue'
import PackCard from '@/components/dialog/content/manager/packCard/PackCard.vue'
import RegistrySearchBar from '@/components/dialog/content/manager/registrySearchBar/RegistrySearchBar.vue'
import GridSkeleton from '@/components/dialog/content/manager/skeleton/GridSkeleton.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useInstalledPacks } from '@/composables/useInstalledPacks'
import { useInstalledPacks } from '@/composables/nodePack/useInstalledPacks'
import { useWorkflowPacks } from '@/composables/nodePack/useWorkflowPacks'
import { useRegistrySearch } from '@/composables/useRegistrySearch'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
import type { TabItem } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
enum ManagerTab {
All = 'all',
Installed = 'installed',
Workflow = 'workflow',
Missing = 'missing'
}
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const { getPackById } = useComfyRegistryStore()
const GRID_STYLE = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
} as const
const {
isSmallScreen,
@@ -124,56 +139,179 @@ const {
} = useResponsiveCollapse()
const tabs = ref<TabItem[]>([
{ id: 'all', label: t('g.all'), icon: 'pi-list' },
{ id: 'installed', label: t('g.installed'), icon: 'pi-box' }
{ id: ManagerTab.All, label: t('g.all'), icon: 'pi-list' },
{ id: ManagerTab.Installed, label: t('g.installed'), icon: 'pi-box' },
{
id: ManagerTab.Workflow,
label: t('manager.inWorkflow'),
icon: 'pi-folder'
},
{
id: ManagerTab.Missing,
label: t('g.missing'),
icon: 'pi-exclamation-circle'
}
])
const selectedTab = ref<TabItem>(tabs.value[0])
const {
searchQuery,
pageNumber,
isLoading,
isLoading: isSearchLoading,
searchResults,
searchMode,
suggestions
} = useRegistrySearch()
pageNumber.value = 0
const onApproachEnd = () => {
pageNumber.value++
}
const isInitialLoad = computed(
() => searchResults.value.length === 0 && searchQuery.value === ''
)
const { getInstalledPacks } = useInstalledPacks()
const displayPacks = ref<components['schemas']['Node'][]>([])
const isEmptySearch = computed(() => searchQuery.value === '')
const displayPacks = ref<components['schemas']['Node'][]>([])
const getInstalledSearchResults = async () => {
if (isEmptySearch.value) return getInstalledPacks()
return searchResults.value.filter((pack) =>
comfyManagerStore.installedPacksIds.has(pack.name)
)
const {
startFetchInstalled,
filterInstalledPack,
installedPacks,
isLoading: isLoadingInstalled
} = useInstalledPacks()
const {
startFetchWorkflowPacks,
filterWorkflowPack,
workflowPacks,
isLoading: isLoadingWorkflow
} = useWorkflowPacks()
const getInstalledResults = () => {
if (isEmptySearch.value) {
startFetchInstalled()
return installedPacks.value
} else {
return filterInstalledPack(searchResults.value)
}
}
watchEffect(async () => {
if (selectedTab.value.id === 'installed') {
displayPacks.value = await getInstalledSearchResults()
const getInWorkflowResults = () => {
if (isEmptySearch.value) {
startFetchWorkflowPacks()
return workflowPacks.value
} else {
displayPacks.value = searchResults.value
return filterWorkflowPack(searchResults.value)
}
}
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
const setMissingPacks = () => {
displayPacks.value = filterMissingPacks(workflowPacks.value)
}
const getMissingPacks = () => {
if (isEmptySearch.value) {
startFetchWorkflowPacks()
whenever(() => workflowPacks.value.length, setMissingPacks, {
immediate: true,
once: true
})
return filterMissingPacks(workflowPacks.value)
} else {
return filterMissingPacks(filterWorkflowPack(searchResults.value))
}
}
const onTabChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
displayPacks.value = getInstalledResults()
break
case ManagerTab.Workflow:
displayPacks.value = getInWorkflowResults()
break
case ManagerTab.Missing:
displayPacks.value = getMissingPacks()
break
default:
displayPacks.value = searchResults.value
}
}
const onResultsChange = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
displayPacks.value = filterInstalledPack(searchResults.value)
break
case ManagerTab.Workflow:
displayPacks.value = filterWorkflowPack(searchResults.value)
break
case ManagerTab.Missing:
displayPacks.value = filterMissingPacks(
filterWorkflowPack(searchResults.value)
)
break
default:
displayPacks.value = searchResults.value
}
}
whenever(selectedTab, onTabChange)
watch(searchResults, onResultsChange, { flush: 'pre' })
watch(() => comfyManagerStore.installedPacksIds, onResultsChange)
const isLoading = computed(() => {
if (isSearchLoading.value) return searchResults.value.length === 0
if (selectedTab.value?.id === ManagerTab.Installed) {
return isLoadingInstalled.value
}
if (
selectedTab.value?.id === ManagerTab.Workflow ||
selectedTab.value?.id === ManagerTab.Missing
) {
return isLoadingWorkflow.value
}
return isInitialLoad.value
})
const resultsWithKeys = computed(() =>
displayPacks.value.map((item) => ({
...item,
key: item.id || item.name
}))
const resultsWithKeys = computed(
() =>
displayPacks.value.map((item) => ({
...item,
key: item.id || item.name
})) as (components['schemas']['Node'] & { key: string })[]
)
const selectedNodePacks = ref<components['schemas']['Node'][]>([])
const selectedNodePack = computed(() =>
const selectedNodePack = computed<components['schemas']['Node'] | null>(() =>
selectedNodePacks.value.length === 1 ? selectedNodePacks.value[0] : null
)
const getLoadingCount = () => {
switch (selectedTab.value?.id) {
case ManagerTab.Installed:
return comfyManagerStore.installedPacksIds?.size
case ManagerTab.Workflow:
return workflowPacks.value?.length
case ManagerTab.Missing:
return workflowPacks.value?.filter?.(
(pack) => !comfyManagerStore.isPackInstalled(pack.id)
)?.length
default:
return searchResults.value.length
}
}
const skeletonCardCount = computed(() => {
const loadingCount = getLoadingCount()
if (loadingCount) return loadingCount
return isSmallScreen.value ? 12 : 16
})
const selectNodePack = (
nodePack: components['schemas']['Node'],
event: MouseEvent
@@ -208,4 +346,24 @@ const handleGridContainerClick = (event: MouseEvent) => {
}
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
if (!selectedNodePack.value?.id) return
// If only a single node pack is selected, fetch full node pack info from registry
if (hasMultipleSelections.value) return
const data = await getPackById.call(selectedNodePack.value.id)
if (data?.id === selectedNodePack.value?.id) {
// If selected node hasn't changed since request, merge registry & Algolia data
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
}
})
onUnmounted(() => {
getPackById.cancel()
})
</script>

View File

@@ -6,12 +6,12 @@
'w-full': fullWidth,
'w-min-content': !fullWidth
}"
:disabled="isExecuted"
:disabled="isInstalling"
v-bind="$attrs"
@click="onClick"
>
<span class="py-2.5 px-3">
<template v-if="isExecuted">
<template v-if="isInstalling">
{{ loadingMessage ?? $t('g.loading') }}
</template>
<template v-else>
@@ -23,7 +23,9 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { ref } from 'vue'
import { inject, ref } from 'vue'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
const {
label,
@@ -43,10 +45,10 @@ defineOptions({
inheritAttrs: false
})
const isExecuted = ref(false)
const isInstalling = inject(IsInstallingKey, ref(false))
const onClick = (): void => {
isExecuted.value = true
isInstalling.value = true
emit('action')
}
</script>

View File

@@ -11,9 +11,12 @@
</template>
<script setup lang="ts">
import { inject, ref } from 'vue'
import PackActionButton from '@/components/dialog/content/manager/button/PackActionButton.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import {
IsInstallingKey,
ManagerChannel,
ManagerDatabaseSource,
SelectedVersion
@@ -26,6 +29,8 @@ const { nodePacks } = defineProps<{
nodePacks: NodePack[]
}>()
const isInstalling = inject(IsInstallingKey, ref(false))
const managerStore = useComfyManagerStore()
const createPayload = (installItem: NodePack) => {
@@ -50,6 +55,8 @@ const installPack = (item: NodePack) =>
const installAllPacks = async () => {
if (!nodePacks?.length) return
isInstalling.value = true
const uninstalledPacks = nodePacks.filter(
(pack) => !managerStore.isPackInstalled(pack.id)
)

View File

@@ -1,8 +1,10 @@
<template>
<template v-if="nodePack">
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
<div class="p-6 flex-1 overflow-hidden text-sm">
<div class="flex flex-col h-full z-40 w-80 overflow-hidden relative">
<div class="top-0 z-10 px-6 pt-6 w-full">
<InfoPanelHeader :node-packs="[nodePack]" />
</div>
<div class="p-6 pt-2 overflow-y-auto flex-1 text-sm hidden-scrollbar">
<div class="mb-6">
<MetadataRow
v-if="isPackInstalled(nodePack.id)"
@@ -44,7 +46,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { whenever } from '@vueuse/core'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PackStatusMessage from '@/components/dialog/content/manager/PackStatusMessage.vue'
@@ -54,6 +57,7 @@ import InfoPanelHeader from '@/components/dialog/content/manager/infoPanel/InfoP
import InfoTabs from '@/components/dialog/content/manager/infoPanel/InfoTabs.vue'
import MetadataRow from '@/components/dialog/content/manager/infoPanel/MetadataRow.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import { components } from '@/types/comfyRegistryTypes'
interface InfoItem {
@@ -66,6 +70,14 @@ const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const managerStore = useComfyManagerStore()
const isInstalled = computed(() => managerStore.isPackInstalled(nodePack.id))
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
whenever(isInstalled, () => {
isInstalling.value = false
})
const { isPackInstalled } = useComfyManagerStore()
const { t, d, n } = useI18n()
@@ -94,9 +106,6 @@ const infoItems = computed<InfoItem[]>(() => [
</script>
<style scoped>
.hidden-scrollbar {
height: 100%;
overflow-y: auto;
/* Firefox */
scrollbar-width: none;

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col h-full">
<div v-if="nodePacks?.length" class="flex flex-col h-full">
<div class="p-6 flex-1 overflow-auto">
<InfoPanelHeader :node-packs="nodePacks">
<InfoPanelHeader :node-packs>
<template #thumbnail>
<PackIconStacked :node-packs="nodePacks" />
</template>
@@ -24,6 +24,9 @@
</div>
</div>
</div>
<div v-else class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
{{ $t('manager.infoPanelEmpty') }}
</div>
</template>
<script setup lang="ts">
@@ -48,7 +51,7 @@ const getPackNodes = async (pack: components['schemas']['Node']) => {
if (!comfyRegistryService.packNodesAvailable(pack)) return []
return comfyRegistryService.getNodeDefs({
packId: pack.id,
versionId: pack.latest_version.id
versionId: pack.latest_version?.id
})
}

View File

@@ -1,12 +1,24 @@
<template>
<div class="mt-4 overflow-hidden">
<InfoTextSection
v-if="nodePack.description"
v-if="nodePack?.description"
:sections="descriptionSections"
/>
<p v-else class="text-muted italic text-sm">
{{ $t('manager.noDescription') }}
</p>
<div v-if="nodePack?.latest_version?.dependencies?.length">
<p class="mb-1">
{{ $t('manager.dependencies') }}
</p>
<div
v-for="(dep, index) in nodePack.latest_version.dependencies"
:key="index"
class="text-muted break-words"
>
{{ dep }}
</div>
</div>
</div>
</template>

View File

@@ -9,13 +9,17 @@
:key="i"
class="border border-surface-border rounded-lg p-4"
>
<NodePreview :node-def="placeholderNodeDef" />
<NodePreview
:node-def="placeholderNodeDef"
class="!text-[.625rem] !min-w-full"
/>
</div>
</div>
</template>
<script setup lang="ts">
import NodePreview from '@/components/node/NodePreview.vue'
import { ComfyNodeDef } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { components } from '@/types/comfyRegistryTypes'
defineProps<{
@@ -24,7 +28,8 @@ defineProps<{
}>()
// TODO: when registry returns node defs, use them here
const placeholderNodeDef = {
const placeholderNodeDef: ComfyNodeDef = {
name: 'Sample Node',
display_name: 'Sample Node',
description: 'This is a sample node for preview purposes',
inputs: {
@@ -32,8 +37,11 @@ const placeholderNodeDef = {
input2: { name: 'Input 2', type: 'CONDITIONING' }
},
outputs: [
{ name: 'Output 1', type: 'IMAGE', index: 0 },
{ name: 'Output 2', type: 'MASK', index: 1 }
]
{ name: 'Output 1', type: 'IMAGE', index: 0, is_list: false },
{ name: 'Output 2', type: 'MASK', index: 1, is_list: false }
],
category: 'Utility',
output_node: false,
python_module: 'nodes'
}
</script>

View File

@@ -2,7 +2,8 @@
<Card
class="w-full h-full inline-flex flex-col justify-between items-start overflow-hidden rounded-2xl shadow-elevation-3 dark-theme:bg-dark-elevation-2 transition-all duration-200"
:class="{
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected
'outline outline-[6px] outline-[var(--p-primary-color)]': isSelected,
'opacity-60': isDisabled
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
@@ -19,53 +20,65 @@
</template>
<template #content>
<ContentDivider />
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<template v-if="isInstalling">
<div
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
>
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
:title="nodePack.name"
>
{{ nodePack.name }}
</span>
<ProgressSpinner />
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
class="self-stretch text-center justify-start text-sm font-medium leading-none"
>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
:title="nodePack.description"
>
{{ nodePack.description }}
</p>
{{ $t('g.installing') }}...
</div>
</div>
</template>
<template v-else>
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<div
class="self-stretch inline-flex justify-start items-center gap-2"
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
{{ nodePack.name }}
</span>
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
{{ nodePack.description }}
</p>
</div>
<div
class="self-stretch inline-flex justify-start items-center gap-2"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
</div>
</template>
</template>
<template #footer>
<ContentDivider :width="0.1" />
@@ -75,14 +88,17 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Card from 'primevue/card'
import { computed } from 'vue'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
@@ -91,16 +107,26 @@ const { nodePack, isSelected = false } = defineProps<{
isSelected?: boolean
}>()
const { isPackInstalled, getInstalledPackVersion } = useComfyManagerStore()
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
const { isPackInstalled, isPackEnabled, getInstalledPackVersion } =
useComfyManagerStore()
const isInstalled = computed(() => isPackInstalled(nodePack?.id))
const isDisabled = computed(
() => isInstalled.value && !isPackEnabled(nodePack?.id)
)
whenever(isInstalled, () => (isInstalling.value = false))
const isUpdateAvailable = computed(() => {
if (!isInstalled.value) return false
const latestVersion = nodePack.latest_version?.version
if (!latestVersion) return false
const installedVersion = getInstalledPackVersion(nodePack.id)
const installedVersion = getInstalledPackVersion(nodePack.id ?? '')
// Don't attempt to show update available for nightly GitHub packs
if (installedVersion && !isSemVer(installedVersion)) return false

View File

@@ -2,7 +2,7 @@
<img
:src="isImageError ? DEFAULT_ICON : imgSrc"
:alt="nodePack.name + ' icon'"
class="object-contain rounded-lg"
class="object-contain rounded-lg max-h-72 max-w-72"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>

View File

@@ -0,0 +1,19 @@
<template>
<div :style="gridStyle">
<PackCardSkeleton v-for="n in skeletonCardCount" :key="n" />
</div>
</template>
<script setup lang="ts">
import PackCardSkeleton from '@/components/dialog/content/manager/skeleton/PackCardSkeleton.vue'
const { skeletonCardCount = 12, gridStyle } = defineProps<{
skeletonCardCount?: number
gridStyle: {
display: string
gridTemplateColumns: string
padding: string
gap: string
}
}>()
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div
class="rounded-lg border shadow-sm h-full overflow-hidden flex flex-col"
data-virtual-grid-item
>
<!-- Card header - flush with top, approximately 15% of height -->
<div class="w-full px-4 py-3 flex justify-between items-center border-b">
<div class="flex items-center">
<div class="w-6 h-6 flex items-center justify-center">
<Skeleton shape="circle" width="1.5rem" height="1.5rem"></Skeleton>
</div>
<Skeleton width="5rem" height="1rem" class="ml-2"></Skeleton>
</div>
<Skeleton width="4rem" height="1.75rem" borderRadius="0.75rem"></Skeleton>
</div>
<!-- Card content with icon on left and text on right -->
<div class="flex-1 p-4 flex">
<!-- Left icon - 64x64 -->
<div class="flex-shrink-0 mr-4">
<Skeleton width="4rem" height="4rem" borderRadius="0.5rem"></Skeleton>
</div>
<!-- Right content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Title -->
<Skeleton width="80%" height="1rem" class="mb-2"></Skeleton>
<!-- Description -->
<div class="mb-3">
<Skeleton width="100%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="95%" height="0.75rem" class="mb-1"></Skeleton>
<Skeleton width="90%" height="0.75rem"></Skeleton>
</div>
<!-- Tags/Badges -->
<div class="flex gap-2">
<Skeleton
width="4rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
<Skeleton
width="5rem"
height="1.5rem"
borderRadius="0.75rem"
></Skeleton>
</div>
</div>
</div>
<!-- Card footer - similar to header -->
<div class="w-full px-5 py-4 flex justify-between items-center border-t">
<Skeleton width="4rem" height="0.8rem"></Skeleton>
<Skeleton width="6rem" height="0.8rem"></Skeleton>
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -0,0 +1,79 @@
import { VueWrapper, mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import GridSkeleton from '../GridSkeleton.vue'
import PackCardSkeleton from '../PackCardSkeleton.vue'
describe('GridSkeleton', () => {
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(GridSkeleton, {
props: {
gridStyle: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(19rem, 1fr))',
padding: '0.5rem',
gap: '1.5rem'
},
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
stubs: {
PackCardSkeleton: true
}
}
})
}
it('renders with default props', () => {
const wrapper = mountComponent()
expect(wrapper.exists()).toBe(true)
})
it('applies the provided grid style', () => {
const customGridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
padding: '1rem',
gap: '1rem'
}
const wrapper = mountComponent({
props: { gridStyle: customGridStyle }
})
const gridElement = wrapper.element
expect(gridElement.style.display).toBe('grid')
expect(gridElement.style.gridTemplateColumns).toBe(
'repeat(auto-fill, minmax(15rem, 1fr))'
)
expect(gridElement.style.padding).toBe('1rem')
expect(gridElement.style.gap).toBe('1rem')
})
it('renders the specified number of skeleton cards', async () => {
const cardCount = 5
const wrapper = mountComponent({
props: { skeletonCardCount: cardCount }
})
await nextTick()
const skeletonCards = wrapper.findAllComponents(PackCardSkeleton)
expect(skeletonCards.length).toBe(5)
})
})

View File

@@ -55,7 +55,7 @@
icon="pi pi-ellipsis-h"
text
severity="secondary"
@click="menu.show($event)"
@click="menu?.show($event)"
/>
<ContextMenu ref="menu" :model="contextMenuItems" />
</template>

View File

@@ -157,7 +157,10 @@ interface ICommandData {
const commandsData = computed<ICommandData[]>(() => {
return Object.values(commandStore.commands).map((command) => ({
id: command.id,
label: t(`commands.${normalizeI18nKey(command.id)}.label`, command.label),
label: t(
`commands.${normalizeI18nKey(command.id)}.label`,
command.label ?? ''
),
keybinding: keybindingStore.getKeybindingByCommandId(command.id)
}))
})
@@ -166,7 +169,7 @@ const selectedCommandData = ref<ICommandData | null>(null)
const editDialogVisible = ref(false)
const newBindingKeyCombo = ref<KeyComboImpl | null>(null)
const currentEditingCommand = ref<ICommandData | null>(null)
const keybindingInput = ref(null)
const keybindingInput = ref<InstanceType<typeof InputText> | null>(null)
const existingKeybindingOnCombo = computed<KeybindingImpl | null>(() => {
if (!currentEditingCommand.value) {
@@ -201,6 +204,7 @@ watchEffect(() => {
if (editDialogVisible.value) {
// nextTick doesn't work here, so we use a timeout instead
setTimeout(() => {
// @ts-expect-error - $el is an internal property of the InputText component
keybindingInput.value?.$el?.focus()
}, 300)
}

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'

View File

@@ -15,15 +15,10 @@ import { computed } from 'vue'
import { KeyComboImpl } from '@/stores/keybindingStore'
const props = withDefaults(
defineProps<{
keyCombo: KeyComboImpl
isModified: boolean
}>(),
{
isModified: false
}
)
const { keyCombo, isModified = false } = defineProps<{
keyCombo: KeyComboImpl
isModified?: boolean
}>()
const keySequences = computed(() => props.keyCombo.getKeySequences())
const keySequences = computed(() => keyCombo.getKeySequences())
</script>

View File

@@ -101,6 +101,8 @@ const handleRestart = async () => {
const onReconnect = () => {
useCommandStore().execute('Comfy.RefreshNodeDefinitions')
comfyManagerStore.clearLogs()
comfyManagerStore.setStale()
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
}

View File

@@ -5,7 +5,7 @@
v-for="widget in widgets"
:key="widget.id"
:widget="widget"
:widget-state="domWidgetStore.widgetStates.get(widget.id)"
:widget-state="domWidgetStore.widgetStates.get(widget.id)!"
@update:widget-value="widget.value = $event"
/>
</div>
@@ -30,7 +30,6 @@ const widgets = computed(() =>
)
)
const DEFAULT_MARGIN = 10
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
@@ -49,14 +48,14 @@ const updateWidgets = () => {
widgetState.visible = visible
if (visible) {
const margin = widget.options.margin ?? DEFAULT_MARGIN
const margin = widget.margin
widgetState.pos = [node.pos[0] + margin, node.pos[1] + margin + widget.y]
widgetState.size = [
(widget.width ?? node.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
]
// TODO: optimize this logic as it's O(n), where n is the number of nodes
widgetState.zIndex = lgCanvas.graph.nodes.indexOf(node)
widgetState.zIndex = lgCanvas.graph?.nodes.indexOf(node) ?? -1
widgetState.readonly = lgCanvas.read_only
}
}

View File

@@ -106,7 +106,9 @@ watchEffect(() => {
watchEffect(() => {
const spellcheckEnabled = settingStore.get('Comfy.TextareaWidget.Spellcheck')
const textareas = document.querySelectorAll('textarea.comfy-multiline-input')
const textareas = document.querySelectorAll<HTMLTextAreaElement>(
'textarea.comfy-multiline-input'
)
textareas.forEach((textarea: HTMLTextAreaElement) => {
textarea.spellcheck = spellcheckEnabled
@@ -124,6 +126,7 @@ watch(
for (const n of comfyApp.graph.nodes) {
if (!n.widgets) continue
for (const w of n.widgets) {
// @ts-expect-error fixme ts strict error
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
@@ -168,6 +171,7 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
@@ -188,12 +192,15 @@ onMounted(async () => {
CORE_SETTINGS.forEach((setting) => {
settingStore.addSetting(setting)
})
// @ts-expect-error fixme ts strict error
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
// @ts-expect-error fixme ts strict error
window['app'] = comfyApp
// @ts-expect-error fixme ts strict error
window['graph'] = comfyApp.graph
comfyAppReady.value = true

View File

@@ -28,12 +28,12 @@ import { normalizeI18nKey } from '@/utils/formatUtil'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const settingStore = useSettingStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipRef = ref<HTMLDivElement | undefined>()
const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
const hideTooltip = () => (tooltipText.value = null)
const hideTooltip = () => (tooltipText.value = '')
const showTooltip = async (tooltip: string | null | undefined) => {
if (!tooltip) return
@@ -44,7 +44,9 @@ const showTooltip = async (tooltip: string | null | undefined) => {
await nextTick()
const rect = tooltipRef.value.getBoundingClientRect()
const rect = tooltipRef.value?.getBoundingClientRect()
if (!rect) return
if (rect.right > window.innerWidth) {
left.value = comfyApp.canvas.mouse[0] - rect.width + 'px'
}
@@ -60,7 +62,7 @@ const onIdle = () => {
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const nodeDef = nodeDefStore.nodeDefsByName[node.type ?? '']
if (
ctor.title_mode !== LiteGraph.NO_TITLE &&
@@ -80,8 +82,8 @@ const onIdle = () => {
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef.inputs[inputName]?.tooltip
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
nodeDef.inputs[inputName]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -94,8 +96,8 @@ const onIdle = () => {
)
if (outputSlot !== -1) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.outputs.${outputSlot}.tooltip`,
nodeDef.outputs[outputSlot]?.tooltip
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
nodeDef.outputs[outputSlot]?.tooltip ?? ''
)
return showTooltip(translatedTooltip)
}
@@ -104,8 +106,8 @@ const onIdle = () => {
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !isDOMWidget(widget)) {
const translatedTooltip = st(
`nodeDefs.${normalizeI18nKey(node.type)}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef.inputs[widget.name]?.tooltip
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
nodeDef.inputs[widget.name]?.tooltip ?? ''
)
// Widget tooltip can be set dynamically, current translation collection does not support this.
return showTooltip(widget.tooltip ?? translatedTooltip)

View File

@@ -48,12 +48,15 @@ const positionSelectionOverlay = (canvas: LGraphCanvas) => {
// Register listener on canvas creation.
watch(
() => canvasStore.canvas,
() => canvasStore.canvas as LGraphCanvas | null,
(canvas: LGraphCanvas | null) => {
if (!canvas) return
canvas.onSelectionChange = useChainCallback(canvas.onSelectionChange, () =>
positionSelectionOverlay(canvas)
canvas.onSelectionChange = useChainCallback(
canvas.onSelectionChange,
// Wait for next frame as sometimes the selected items haven't been
// rendered yet, so the boundingRect is not available on them.
() => requestAnimationFrame(() => positionSelectionOverlay(canvas))
)
},
{ immediate: true }
@@ -88,7 +91,12 @@ watch(
positionSelectionOverlay(canvasStore.canvas as LGraphCanvas)
}, 100)
} else {
visible.value = false
// Selection change update to visible state is delayed by a frame. Here
// we also delay a frame so that the order of events is correct when
// the initial selection and dragging happens at the same time.
requestAnimationFrame(() => {
visible.value = false
})
}
}
)

View File

@@ -7,7 +7,7 @@
>
<template #icon>
<div class="flex items-center gap-1">
<i class="pi pi-circle-fill" :style="{ color: currentColor }" />
<i class="pi pi-circle-fill" :style="{ color: currentColor ?? '' }" />
<i class="pi pi-chevron-down" :style="{ fontSize: '0.5rem' }" />
</div>
</template>

View File

@@ -41,7 +41,7 @@ const emit = defineEmits<{
(e: 'update:widgetValue', value: string | object): void
}>()
const widgetElement = ref<HTMLElement>()
const widgetElement = ref<HTMLElement | undefined>()
const { style: positionStyle, updatePositionWithTransform } =
useAbsolutePosition()
@@ -61,6 +61,8 @@ const enableDomClipping = computed(() =>
const updateDomClipping = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas || !widgetElement.value) return
const selectedNode = Object.values(
lgCanvas.selected_nodes ?? {}
)[0] as LGraphNode
@@ -130,7 +132,7 @@ const inputSpec = widget.node.constructor.nodeData
const tooltip = inputSpec?.inputs?.[widget.name]?.tooltip
onMounted(() => {
if (isDOMWidget(widget)) {
if (isDOMWidget(widget) && widgetElement.value) {
widgetElement.value.appendChild(widget.element)
}
})

View File

@@ -146,7 +146,7 @@ const cpuMode = computed({
selected.value = value ? 'cpu' : null
}
})
const selected = defineModel<TorchDeviceType>('device', {
const selected = defineModel<TorchDeviceType | null>('device', {
required: true
})

View File

@@ -33,6 +33,9 @@
<Message v-if="pathExists" severity="warn">
{{ $t('install.pathExists') }}
</Message>
<Message v-if="nonDefaultDrive" severity="warn">
{{ $t('install.nonDefaultDrive') }}
</Message>
</div>
<!-- System Paths Info -->
@@ -80,6 +83,7 @@ const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true })
const pathExists = ref(false)
const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false)
@@ -96,11 +100,12 @@ onMounted(async () => {
await validatePath(paths.defaultInstallPath)
})
const validatePath = async (path: string) => {
const validatePath = async (path: string | undefined) => {
try {
pathError.value = ''
pathExists.value = false
const validation = await electron.validateInstallPath(path)
nonDefaultDrive.value = false
const validation = await electron.validateInstallPath(path ?? '')
// Create a pre-formatted list of errors
if (!validation.isValid) {
@@ -111,12 +116,14 @@ const validatePath = async (path: string) => {
errors.push(`${t('install.insufficientFreeSpace')}: ${requiredGB} GB`)
}
if (validation.parentMissing) errors.push(t('install.parentMissing'))
if (validation.isOneDrive) errors.push(t('install.isOneDrive'))
if (validation.error)
errors.push(`${t('install.unhandledError')}: ${validation.error}`)
pathError.value = errors.join('\n')
}
// Display the path exists warning
if (validation.isNonDefaultDrive) nonDefaultDrive.value = true
if (validation.exists) pathExists.value = true
} catch (error) {
pathError.value = t('install.pathValidationFailed')

View File

@@ -99,7 +99,7 @@ const isValidSource = computed(
() => sourcePath.value !== '' && pathError.value === ''
)
const validateSource = async (sourcePath: string) => {
const validateSource = async (sourcePath: string | undefined) => {
if (!sourcePath) {
pathError.value = ''
return
@@ -109,7 +109,7 @@ const validateSource = async (sourcePath: string) => {
pathError.value = ''
const validation = await electron.validateComfyUISource(sourcePath)
if (!validation.isValid) pathError.value = validation.error
if (!validation.isValid) pathError.value = validation.error ?? 'ERROR'
} catch (error) {
console.error(error)
pathError.value = t('install.pathValidationFailed')

View File

@@ -50,7 +50,7 @@ import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType }>()
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
@@ -95,7 +95,7 @@ const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device), torchMirror]
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,

View File

@@ -1,8 +1,13 @@
<template>
<div class="relative w-full h-full">
<div
class="relative w-full h-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<Load3DScene
ref="load3DSceneRef"
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -25,6 +30,7 @@
@edgeThresholdChange="listenEdgeThresholdChange"
/>
<Load3DControls
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:showPreview="showPreview"
@@ -37,7 +43,6 @@
:hasBackgroundImage="hasBackgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="false"
:edgeThreshold="edgeThreshold"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@@ -49,12 +54,15 @@
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
@uploadTexture="handleUploadTexture"
@exportModel="handleExportModel"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Load3DControls from '@/components/load3d/Load3DControls.vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
@@ -67,7 +75,9 @@ import {
} from '@/extensions/core/load3d/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComponentWidget } from '@/scripts/domWidget'
import { useToastStore } from '@/stores/toastStore'
const { t } = useI18n()
const { widget } = defineProps<{
widget: ComponentWidget<string[]>
}>()
@@ -90,11 +100,24 @@ const backgroundImage = ref('')
const upDirection = ref<UpDirection>('original')
const materialMode = ref<MaterialMode>('original')
const edgeThreshold = ref(85)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const handleMouseEnter = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.updateStatusMouseOnScene(false)
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'
@@ -135,6 +158,23 @@ const handleBackgroundImageUpdate = async (file: File | null) => {
node.properties['Background Image'] = backgroundImage.value
}
const handleUploadTexture = async (file: File) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dScene'))
return
}
try {
const texturePath = await Load3dUtils.uploadFile(file)
await load3DSceneRef.value.load3d.applyTexture(texturePath)
node.properties['Texture'] = texturePath
} catch (error) {
console.error('Error applying texture:', error)
useToastStore().addAlert(t('toastMessages.failedToApplyTexture'))
}
}
const handleUpdateFOV = (value: number) => {
fov.value = value
@@ -165,6 +205,24 @@ const handleUpdateMaterialMode = (value: MaterialMode) => {
node.properties['Material Mode'] = value
}
const handleExportModel = async (format: string) => {
if (!load3DSceneRef.value?.load3d) {
useToastStore().addAlert(t('toastMessages.no3dSceneToExport'))
return
}
try {
await load3DSceneRef.value.load3d.exportModel(format)
} catch (error) {
console.error('Error exporting model:', error)
useToastStore().addAlert(
t('toastMessages.failedToExportModel', {
format: format.toUpperCase()
})
)
}
}
const listenMaterialModeChange = (mode: MaterialMode) => {
materialMode.value = mode

View File

@@ -1,8 +1,13 @@
<template>
<div class="relative w-full h-full">
<div
class="relative w-full h-full"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<Load3DAnimationScene
ref="load3DAnimationSceneRef"
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -30,6 +35,7 @@
/>
<div class="absolute top-0 left-0 w-full h-full pointer-events-none">
<Load3DControls
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:showPreview="showPreview"
@@ -42,7 +48,6 @@
:hasBackgroundImage="hasBackgroundImage"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="true"
@updateBackgroundImage="handleBackgroundImageUpdate"
@switchCamera="switchCamera"
@toggleGrid="toggleGrid"
@@ -110,6 +115,24 @@ const showPreviewButton = computed(() => {
return !type.includes('Preview')
})
const load3DAnimationSceneRef = ref<InstanceType<
typeof Load3DAnimationScene
> | null>(null)
const handleMouseEnter = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(true)
}
}
const handleMouseLeave = () => {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
sceneRef.load3d.updateStatusMouseOnScene(false)
}
}
const switchCamera = () => {
cameraType.value =
cameraType.value === 'perspective' ? 'orthographic' : 'perspective'

View File

@@ -1,7 +1,7 @@
<template>
<Load3DScene
:node="node"
:type="type"
:inputSpec="inputSpec"
:backgroundColor="backgroundColor"
:showGrid="showGrid"
:lightIntensity="lightIntensity"
@@ -26,16 +26,17 @@
import { ref, watch } from 'vue'
import Load3DScene from '@/components/load3d/Load3DScene.vue'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import {
CameraType,
Load3DAnimationNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const props = defineProps<{
node: any
type: Load3DAnimationNodeType
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
@@ -63,7 +64,7 @@ const upDirection = ref(props.upDirection)
const materialMode = ref(props.materialMode)
const showFOVButton = ref(props.showFOVButton)
const showLightIntensityButton = ref(props.showLightIntensityButton)
const load3DSceneRef = ref(null)
const load3DSceneRef = ref<InstanceType<typeof Load3DScene> | null>(null)
watch(
() => props.cameraType,
@@ -124,21 +125,24 @@ watch(
watch(
() => props.playing,
(newValue) => {
load3DSceneRef.value.load3d.toggleAnimation(newValue)
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.toggleAnimation(newValue)
}
)
watch(
() => props.selectedSpeed,
(newValue) => {
load3DSceneRef.value.load3d.setAnimationSpeed(newValue)
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.setAnimationSpeed(newValue)
}
)
watch(
() => props.selectedAnimation,
(newValue) => {
load3DSceneRef.value.load3d.updateSelectedAnimation(newValue)
const load3d = load3DSceneRef.value?.load3d as Load3dAnimation | null
load3d?.updateSelectedAnimation(newValue)
}
)
@@ -183,4 +187,8 @@ const animationListeners = {
emit('animationListChange', newValue)
}
}
defineExpose({
load3DSceneRef
})
</script>

View File

@@ -16,14 +16,14 @@
>
<div class="flex flex-col">
<Button
v-for="(label, category) in categories"
v-for="category in availableCategories"
:key="category"
class="p-button-text w-full flex items-center justify-start"
:class="{ 'bg-gray-600': activeCategory === category }"
@click="selectCategory(category)"
>
<i :class="getCategoryIcon(category)"></i>
<span class="text-white">{{ t(label) }}</span>
<span class="text-white">{{ t(categoryLabels[category]) }}</span>
</Button>
</div>
</div>
@@ -43,13 +43,14 @@
<ModelControls
v-if="activeCategory === 'model'"
:inputSpec="inputSpec"
:upDirection="upDirection"
:materialMode="materialMode"
:isAnimation="isAnimation"
:edgeThreshold="edgeThreshold"
@updateUpDirection="handleUpdateUpDirection"
@updateMaterialMode="handleUpdateMaterialMode"
@updateEdgeThreshold="handleUpdateEdgeThreshold"
@uploadTexture="handleUploadTexture"
ref="modelControlsRef"
/>
@@ -70,6 +71,12 @@
@updateLightIntensity="handleUpdateLightIntensity"
ref="lightControlsRef"
/>
<ExportControls
v-if="activeCategory === 'export'"
@exportModel="handleExportModel"
ref="exportControlsRef"
/>
</div>
<div v-if="showPreviewButton">
<Button class="p-button-rounded p-button-text" @click="togglePreview">
@@ -89,9 +96,10 @@
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import CameraControls from '@/components/load3d/controls/CameraControls.vue'
import ExportControls from '@/components/load3d/controls/ExportControls.vue'
import LightControls from '@/components/load3d/controls/LightControls.vue'
import ModelControls from '@/components/load3d/controls/ModelControls.vue'
import SceneControls from '@/components/load3d/controls/SceneControls.vue'
@@ -101,10 +109,12 @@ import {
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
showPreview: boolean
@@ -117,24 +127,34 @@ const props = defineProps<{
hasBackgroundImage?: boolean
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
const isMenuOpen = ref(false)
const activeCategory = ref<'scene' | 'model' | 'camera' | 'light'>('scene')
const categories = {
const activeCategory = ref<string>('scene')
const categoryLabels: Record<string, string> = {
scene: 'load3d.scene',
model: 'load3d.model',
camera: 'load3d.camera',
light: 'load3d.light'
light: 'load3d.light',
export: 'load3d.export'
}
const availableCategories = computed(() => {
const baseCategories = ['scene', 'model', 'camera', 'light']
if (!props.inputSpec.isAnimation) {
return [...baseCategories, 'export']
}
return baseCategories
})
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
const selectCategory = (category: 'scene' | 'model' | 'camera' | 'light') => {
const selectCategory = (category: string) => {
activeCategory.value = category
isMenuOpen.value = false
}
@@ -144,8 +164,10 @@ const getCategoryIcon = (category: string) => {
scene: 'pi pi-image',
model: 'pi pi-box',
camera: 'pi pi-camera',
light: 'pi pi-sun'
light: 'pi pi-sun',
export: 'pi pi-download'
}
// @ts-expect-error fixme ts strict error
return `${icons[category]} text-white text-lg`
}
@@ -160,6 +182,8 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'exportModel', format: string): void
(e: 'uploadTexture', file: File): void
}>()
const backgroundColor = ref(props.backgroundColor)
@@ -208,6 +232,10 @@ const handleUpdateEdgeThreshold = (value: number) => {
emit('updateEdgeThreshold', value)
}
const handleUploadTexture = (file: File) => {
emit('uploadTexture', file)
}
const handleUpdateLightIntensity = (value: number) => {
emit('updateLightIntensity', value)
}
@@ -216,6 +244,10 @@ const handleUpdateFOV = (value: number) => {
emit('updateFOV', value)
}
const handleExportModel = (format: string) => {
emit('exportModel', format)
}
const closeSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -13,17 +13,16 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import {
CameraType,
Load3DAnimationNodeType,
Load3DNodeType,
MaterialMode,
UpDirection
} from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useLoad3dService } from '@/services/load3dService'
const props = defineProps<{
node: LGraphNode
type: Load3DNodeType | Load3DAnimationNodeType
inputSpec: CustomInputSpec
backgroundColor: string
showGrid: boolean
lightIntensity: number
@@ -60,7 +59,16 @@ const eventConfig = {
modelLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
materialLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.switchingMaterialMode')),
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading()
materialLoadingEnd: () => loadingOverlayRef.value?.endLoading(),
exportLoadingStart: (message: string) => {
loadingOverlayRef.value?.startLoading(message || t('load3d.exportingModel'))
},
exportLoadingEnd: () => {
loadingOverlayRef.value?.endLoading()
},
textureLoadingStart: () =>
loadingOverlayRef.value?.startLoading(t('load3d.applyingTexture')),
textureLoadingEnd: () => loadingOverlayRef.value?.endLoading()
} as const
watchEffect(() => {
@@ -95,6 +103,7 @@ watch(
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value)
// @ts-expect-error fixme ts strict error
rawLoad3d.setEdgeThreshold(newValue)
}
}
@@ -132,8 +141,9 @@ const handleEvents = (action: 'add' | 'remove') => {
onMounted(() => {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
// @ts-expect-error fixme ts strict error
container.value,
props.type
props.inputSpec
)
handleEvents('add')
})

View File

@@ -15,19 +15,22 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
import { t } from '@/i18n'
const modelLoading = ref(false)
const loadingMessage = ref('')
const startLoading = (message?: string) => {
modelLoading.value = true
const startLoading = async (message?: string) => {
loadingMessage.value = message || t('load3d.loadingModel')
modelLoading.value = true
await nextTick()
}
const endLoading = () => {
const endLoading = async () => {
await new Promise((resolve) => setTimeout(resolve, 100))
modelLoading.value = false
}

View File

@@ -0,0 +1,81 @@
<template>
<div class="flex flex-col">
<div class="relative show-export-formats">
<Button
class="p-button-rounded p-button-text"
@click="toggleExportFormats"
>
<i
class="pi pi-download text-white text-lg"
v-tooltip.right="{
value: t('load3d.exportModel'),
showDelay: 300
}"
></i>
</Button>
<div
v-show="showExportFormats"
class="absolute left-12 top-0 bg-black bg-opacity-50 rounded-lg shadow-lg"
>
<div class="flex flex-col">
<Button
v-for="format in exportFormats"
:key="format.value"
class="p-button-text text-white"
@click="exportModel(format.value)"
>
{{ format.label }}
</Button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Tooltip } from 'primevue'
import Button from 'primevue/button'
import { onMounted, onUnmounted, ref } from 'vue'
import { t } from '@/i18n'
const vTooltip = Tooltip
const emit = defineEmits<{
(e: 'exportModel', format: string): void
}>()
const showExportFormats = ref(false)
const exportFormats = [
{ label: 'GLB', value: 'glb' },
{ label: 'OBJ', value: 'obj' },
{ label: 'STL', value: 'stl' }
]
const toggleExportFormats = () => {
showExportFormats.value = !showExportFormats.value
}
const exportModel = (format: string) => {
emit('exportModel', format)
showExportFormats.value = false
}
const closeExportFormatsList = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.show-export-formats')) {
showExportFormats.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeExportFormatsList)
})
onUnmounted(() => {
document.removeEventListener('click', closeExportFormatsList)
})
</script>

View File

@@ -58,6 +58,33 @@
</div>
</div>
</div>
<div
v-if="
materialMode === 'original' &&
!props.inputSpec.isAnimation &&
!props.inputSpec.isPreview
"
class="relative show-texture-upload"
>
<Button class="p-button-rounded p-button-text" @click="openTextureUpload">
<i
class="pi pi-image text-white text-lg"
v-tooltip.right="{
value: t('load3d.uploadTexture'),
showDelay: 300
}"
></i>
<input
type="file"
ref="texturePickerRef"
accept="image/*"
@change="uploadTexture"
class="absolute opacity-0 w-0 h-0 p-0 m-0 pointer-events-none"
/>
</Button>
</div>
<div v-if="materialMode === 'lineart'" class="relative show-edge-threshold">
<Button
class="p-button-rounded p-button-text"
@@ -100,13 +127,14 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { MaterialMode, UpDirection } from '@/extensions/core/load3d/interfaces'
import { t } from '@/i18n'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
const vTooltip = Tooltip
const props = defineProps<{
inputSpec: CustomInputSpec
upDirection: UpDirection
materialMode: MaterialMode
isAnimation: boolean
edgeThreshold?: number
}>()
@@ -114,6 +142,7 @@ const emit = defineEmits<{
(e: 'updateUpDirection', direction: UpDirection): void
(e: 'updateMaterialMode', mode: MaterialMode): void
(e: 'updateEdgeThreshold', value: number): void
(e: 'uploadTexture', file: File): void
}>()
const upDirection = ref(props.upDirection || 'original')
@@ -122,6 +151,7 @@ const edgeThreshold = ref(props.edgeThreshold || 85)
const showUpDirection = ref(false)
const showMaterialMode = ref(false)
const showEdgeThreshold = ref(false)
const texturePickerRef = ref<HTMLInputElement | null>(null)
const upDirections: UpDirection[] = [
'original',
@@ -141,7 +171,7 @@ const materialModes = computed(() => {
//'depth' disable for now
]
if (!props.isAnimation) {
if (!props.inputSpec.isAnimation && !props.inputSpec.isPreview) {
modes.push('lineart')
}
@@ -169,6 +199,7 @@ watch(
watch(
() => props.edgeThreshold,
(newValue) => {
// @ts-expect-error fixme ts strict error
edgeThreshold.value = newValue
}
)
@@ -216,6 +247,18 @@ const updateEdgeThreshold = () => {
emit('updateEdgeThreshold', edgeThreshold.value)
}
const openTextureUpload = () => {
texturePickerRef.value?.click()
}
const uploadTexture = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
emit('uploadTexture', input.files[0])
}
}
const closeSceneSlider = (e: MouseEvent) => {
const target = e.target as HTMLElement

View File

@@ -74,8 +74,8 @@ const description = computed(() =>
)
// 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 reactiveLoading = computed(() => !!runner.value.refreshing)
const reactiveExecuting = computed(() => !!runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)

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