Compare commits
152 Commits
manager/ui
...
v1.15.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
026f076b8a | ||
|
|
65f1561ec6 | ||
|
|
bb094cf0ae | ||
|
|
ec684ee6b8 | ||
|
|
3978613f14 | ||
|
|
0a40e07f7e | ||
|
|
577af51ff8 | ||
|
|
df7c7383e2 | ||
|
|
1279f30f5a | ||
|
|
9ab4b549c0 | ||
|
|
10de4e5445 | ||
|
|
30420f2c0a | ||
|
|
39c3a57c11 | ||
|
|
6d09b7165f | ||
|
|
8fc6840434 | ||
|
|
db575425fe | ||
|
|
ccb71bf1a3 | ||
|
|
733d71aaac | ||
|
|
e059b9b82f | ||
|
|
cfaf769a65 | ||
|
|
b80e0e1a3c | ||
|
|
7b7d9905a7 | ||
|
|
594fc5945c | ||
|
|
e5abf765bd | ||
|
|
712c127bb5 | ||
|
|
854501ef27 | ||
|
|
aea4493b4d | ||
|
|
df47226fd4 | ||
|
|
f26f5f25bb | ||
|
|
284902cabe | ||
|
|
58dec5ea42 | ||
|
|
7e76665a22 | ||
|
|
cb06d96930 | ||
|
|
b01ddb6aff | ||
|
|
10bed33383 | ||
|
|
a57e60d60a | ||
|
|
8c789bd05d | ||
|
|
28def833f9 | ||
|
|
fcc22f06ac | ||
|
|
3922a5882b | ||
|
|
4a40e83b98 | ||
|
|
21e0caa1b1 | ||
|
|
04af8cda4d | ||
|
|
504b717575 | ||
|
|
62fdcd4949 | ||
|
|
cb7adaef9b | ||
|
|
6aad5222ab | ||
|
|
690326c374 | ||
|
|
25ce267b2e | ||
|
|
78e3a20773 | ||
|
|
56dbcbbd22 | ||
|
|
4bfc8e9e33 | ||
|
|
6e72207927 | ||
|
|
71968ae133 | ||
|
|
4702cd18ce | ||
|
|
00d281c7fa | ||
|
|
3e25e08b10 | ||
|
|
1d66d6d7d3 | ||
|
|
cae5bbe86f | ||
|
|
4d35d937cf | ||
|
|
60afa5cf6c | ||
|
|
a1a33c8c9b | ||
|
|
9988fb8f1e | ||
|
|
0518b170d3 | ||
|
|
562cd7ea70 | ||
|
|
d3c64d404b | ||
|
|
24dcaa7f72 | ||
|
|
6c18781663 | ||
|
|
b6988e8d5c | ||
|
|
ae64721555 | ||
|
|
abe65e58a0 | ||
|
|
a1cfb68116 | ||
|
|
5bee36a73e | ||
|
|
a4b0f5ab5e | ||
|
|
f8a2c90138 | ||
|
|
27c252f74a | ||
|
|
7e26cffb26 | ||
|
|
cb8354bfce | ||
|
|
d5ebd7b7cb | ||
|
|
845d045991 | ||
|
|
c3154fe297 | ||
|
|
f90d61fad5 | ||
|
|
17834459a1 | ||
|
|
564c4d557f | ||
|
|
f852639758 | ||
|
|
eae538b08e | ||
|
|
d23108433e | ||
|
|
e6e7449ece | ||
|
|
22a1200bdf | ||
|
|
ee20b63bc1 | ||
|
|
0752e8b986 | ||
|
|
b234a68cf8 | ||
|
|
0863fda6a4 | ||
|
|
8530406c3e | ||
|
|
f7bfb6ec57 | ||
|
|
830933e78f | ||
|
|
65693ed2be | ||
|
|
ed153dccd9 | ||
|
|
fc7ed1bf09 | ||
|
|
4dad89369a | ||
|
|
5b730517a3 | ||
|
|
af0bf05883 | ||
|
|
d9e62ff860 | ||
|
|
d9ae6cb395 | ||
|
|
b162963593 | ||
|
|
c34cc301f1 | ||
|
|
afdb94f12f | ||
|
|
cc8dc3dbfb | ||
|
|
42d99fc37e | ||
|
|
1f03984d12 | ||
|
|
d49815fcb4 | ||
|
|
4899a1d8f6 | ||
|
|
71444d8c69 | ||
|
|
867ed4c1d7 | ||
|
|
0c6957bfd8 | ||
|
|
fffce30e91 | ||
|
|
c554138887 | ||
|
|
5bbceea76c | ||
|
|
0f0601100f | ||
|
|
ff59245a7f | ||
|
|
c5af11d1ea | ||
|
|
bc3e2e1597 | ||
|
|
e2a8456ff0 | ||
|
|
361c5ba930 | ||
|
|
bae47b80b3 | ||
|
|
a049e9ae2d | ||
|
|
44edec7ad2 | ||
|
|
db43f587a6 | ||
|
|
8997ff4b2a | ||
|
|
91a8591249 | ||
|
|
ef74d7cb01 | ||
|
|
8ab2334270 | ||
|
|
59dbcc5261 | ||
|
|
06488cc811 | ||
|
|
5a12bf33f3 | ||
|
|
96ff8a7785 | ||
|
|
a85a1bf794 | ||
|
|
52bad3d0d1 | ||
|
|
e8997a7653 | ||
|
|
0a6d3c0231 | ||
|
|
2db29fc2af | ||
|
|
329bdff677 | ||
|
|
906eb750ad | ||
|
|
1a120adaea | ||
|
|
26a7ebdd77 | ||
|
|
da415e9d80 | ||
|
|
384b3f3218 | ||
|
|
65cf157958 | ||
|
|
7af003fcab | ||
|
|
7e66e99c3a | ||
|
|
9e9459815d | ||
|
|
57b93a0007 |
@@ -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.
|
||||
|
||||
@@ -51,7 +51,10 @@
|
||||
0.85,
|
||||
false,
|
||||
false,
|
||||
""
|
||||
"",
|
||||
{
|
||||
"foo": "bar"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
104
browser_tests/assets/primitive/static_primitive_unconnected.json
Normal 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
|
||||
}
|
||||
BIN
browser_tests/assets/workflow.glb
Normal 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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,7 @@ test.describe('Keybindings', () => {
|
||||
})
|
||||
|
||||
await comfyPage.executeCommand('TestCommand')
|
||||
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
|
||||
expect(await comfyPage.getToastErrorCount()).toBe(1)
|
||||
})
|
||||
|
||||
test('Should handle async command errors', async ({ comfyPage }) => {
|
||||
@@ -45,6 +45,6 @@ test.describe('Keybindings', () => {
|
||||
})
|
||||
|
||||
await comfyPage.executeCommand('TestCommand')
|
||||
await expect(comfyPage.page.locator('.p-toast')).toBeVisible()
|
||||
expect(await comfyPage.getToastErrorCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 98 KiB |
@@ -309,3 +309,35 @@ 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('.comfy-error-report')
|
||||
await expect(errorDialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should display an error dialog when prompt execution fails', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const app = window['app']
|
||||
app.api.queuePrompt = () => {
|
||||
throw new Error('Error on queuePrompt!')
|
||||
}
|
||||
await app.queuePrompt(0)
|
||||
})
|
||||
const errorDialog = comfyPage.page.locator('.comfy-error-report')
|
||||
await expect(errorDialog).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
20
browser_tests/tests/execution.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Execution', () => {
|
||||
test('Report error on unconnected slot', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await comfyPage.clickEmptySpace()
|
||||
|
||||
await comfyPage.executeCommand('Comfy.QueuePrompt')
|
||||
await expect(comfyPage.page.locator('.comfy-error-report')).toBeVisible()
|
||||
await comfyPage.page.locator('.p-dialog-close-button').click()
|
||||
await comfyPage.page.locator('.comfy-error-report').waitFor({
|
||||
state: 'hidden'
|
||||
})
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'execution-error-unconnected-slot.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 97 KiB |
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -32,701 +32,6 @@ test.describe('Menu', () => {
|
||||
expect(newChildrenCount).toBe(initialChildrenCount + 1)
|
||||
})
|
||||
|
||||
test.describe('Node library sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.BookmarksCustomization', {})
|
||||
// Open the sidebar
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test('Node preview and drag to canvas', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
|
||||
// Hover over a node to display the preview
|
||||
const nodeSelector = '.p-tree-node-leaf'
|
||||
await comfyPage.page.hover(nodeSelector)
|
||||
|
||||
// Verify the preview is displayed
|
||||
const previewVisible = await comfyPage.page.isVisible(
|
||||
'.node-lib-node-preview'
|
||||
)
|
||||
expect(previewVisible).toBe(true)
|
||||
|
||||
const count = await comfyPage.getGraphNodesCount()
|
||||
// Drag the node onto the canvas
|
||||
const canvasSelector = '#graph-canvas'
|
||||
|
||||
// Get the bounding box of the canvas element
|
||||
const canvasBoundingBox = (await comfyPage.page
|
||||
.locator(canvasSelector)
|
||||
.boundingBox())!
|
||||
|
||||
// Calculate the center position of the canvas
|
||||
const targetPosition = {
|
||||
x: canvasBoundingBox.x + canvasBoundingBox.width / 2,
|
||||
y: canvasBoundingBox.y + canvasBoundingBox.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector, {
|
||||
targetPosition
|
||||
})
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(count + 1)
|
||||
})
|
||||
|
||||
test('Bookmark node', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
|
||||
// Bookmark the node
|
||||
await tab
|
||||
.getNode('KSampler (Advanced)')
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
|
||||
// Verify the bookmark is added to the bookmarks tab
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['KSamplerAdvanced'])
|
||||
// Verify the bookmark node with the same name is added to the tree.
|
||||
expect(await tab.getNode('KSampler (Advanced)').count()).toBe(2)
|
||||
|
||||
// Hover on the bookmark node to display the preview
|
||||
await comfyPage.page.hover('.node-lib-bookmark-tree-explorer .tree-leaf')
|
||||
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Ignores unrecognized node', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo'])
|
||||
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
expect(await tab.getFolder('sampling').count()).toBe(1)
|
||||
expect(await tab.getNode('foo').count()).toBe(0)
|
||||
})
|
||||
|
||||
test('Displays empty bookmarks folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
expect(await tab.getFolder('foo').count()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can add new bookmark folder', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.newFolderButton.click()
|
||||
const textInput = comfyPage.page.locator('.editable-text input')
|
||||
await textInput.waitFor({ state: 'visible' })
|
||||
await textInput.fill('New Folder')
|
||||
await textInput.press('Enter')
|
||||
expect(await tab.getFolder('New Folder').count()).toBe(1)
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['New Folder/'])
|
||||
})
|
||||
|
||||
test('Can add nested bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('New Folder').click()
|
||||
const textInput = comfyPage.page.locator('.editable-text input')
|
||||
await textInput.waitFor({ state: 'visible' })
|
||||
await textInput.fill('bar')
|
||||
await textInput.press('Enter')
|
||||
|
||||
expect(await tab.getFolder('bar').count()).toBe(1)
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['foo/', 'foo/bar/'])
|
||||
})
|
||||
|
||||
test('Can delete bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Delete').click()
|
||||
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('Can rename bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu-item-label:has-text("Rename")')
|
||||
.click()
|
||||
await comfyPage.page.keyboard.insertText('bar')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['bar/'])
|
||||
})
|
||||
|
||||
test('Can add bookmark by dragging node to bookmark folder', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
await comfyPage.page.dragAndDrop(
|
||||
tab.nodeSelector('KSampler (Advanced)'),
|
||||
tab.folderSelector('foo')
|
||||
)
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['foo/', 'foo/KSamplerAdvanced'])
|
||||
})
|
||||
|
||||
test('Can add bookmark by clicking bookmark button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
await tab
|
||||
.getNode('KSampler (Advanced)')
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['KSamplerAdvanced'])
|
||||
})
|
||||
|
||||
test('Can unbookmark node (Top level bookmark)', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSamplerAdvanced'
|
||||
])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab
|
||||
.getNode('KSampler (Advanced)')
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('Can unbookmark node (Library node bookmark)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSamplerAdvanced'
|
||||
])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
await comfyPage.page
|
||||
.locator(tab.nodeSelector('KSampler (Advanced)'))
|
||||
.nth(1)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
})
|
||||
test('Can customize icon', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Customize').click()
|
||||
await comfyPage.page
|
||||
.locator('.icon-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('.color-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page.getByLabel('Confirm').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({
|
||||
'foo/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
})
|
||||
// If color is left as default, it should not be saved
|
||||
test('Can customize icon (default field)', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Customize').click()
|
||||
await comfyPage.page
|
||||
.locator('.icon-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page.getByLabel('Confirm').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({
|
||||
'foo/': {
|
||||
icon: 'pi-folder'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Can customize bookmark color after interacting with color options', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open customization dialog
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Customize').click()
|
||||
|
||||
// Click a color option multiple times
|
||||
const customColorOption = comfyPage.page.locator(
|
||||
'.p-togglebutton-content > .pi-palette'
|
||||
)
|
||||
await customColorOption.click()
|
||||
await customColorOption.click()
|
||||
|
||||
// Use the color picker
|
||||
await comfyPage.page
|
||||
.getByLabel('Customize Folder')
|
||||
.getByRole('textbox')
|
||||
.click()
|
||||
await comfyPage.page.locator('.p-colorpicker-color-background').click()
|
||||
|
||||
// Finalize the customization
|
||||
await comfyPage.page
|
||||
.locator('.icon-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page.getByLabel('Confirm').click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the color selection is saved
|
||||
const setting = await comfyPage.getSetting(
|
||||
'Comfy.NodeLibrary.BookmarksCustomization'
|
||||
)
|
||||
await expect(setting).toHaveProperty(['foo/', 'color'])
|
||||
await expect(setting['foo/'].color).not.toBeNull()
|
||||
await expect(setting['foo/'].color).not.toBeUndefined()
|
||||
await expect(setting['foo/'].color).not.toBe('')
|
||||
})
|
||||
|
||||
test('Can rename customized bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.BookmarksCustomization', {
|
||||
'foo/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu-item-label:has-text("Rename")')
|
||||
.click()
|
||||
await comfyPage.page.keyboard.insertText('bar')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['bar/'])
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({
|
||||
'bar/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Can delete customized bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.BookmarksCustomization', {
|
||||
'foo/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Delete').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
test('Can filter nodes in both trees', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'foo/',
|
||||
'foo/KSamplerAdvanced',
|
||||
'KSampler'
|
||||
])
|
||||
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.nodeLibrarySearchBoxInput.fill('KSampler')
|
||||
// Node search box is debounced and may take some time to update.
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
expect(await tab.getNode('KSampler (Advanced)').count()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
|
||||
// Open the sidebar
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Can create new blank workflow', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'*Unsaved Workflow (2).json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can show top level saved workflows', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json',
|
||||
'workflow2.json': 'default.json'
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
|
||||
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
|
||||
)
|
||||
})
|
||||
|
||||
test('Can duplicate workflow', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
|
||||
|
||||
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
|
||||
expect.arrayContaining(['workflow1.json'])
|
||||
)
|
||||
|
||||
await comfyPage.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json'
|
||||
])
|
||||
|
||||
await comfyPage.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json',
|
||||
'*workflow1 (Copy) (2).json'
|
||||
])
|
||||
|
||||
await comfyPage.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json',
|
||||
'*workflow1 (Copy) (2).json',
|
||||
'*workflow1 (Copy) (3).json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can open workflow after insert', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'single_ksampler.json'
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.executeCommand('Comfy.LoadDefaultWorkflow')
|
||||
const originalNodeCount = (await comfyPage.getNodes()).length
|
||||
|
||||
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.getNodes()).length).toEqual(originalNodeCount + 1)
|
||||
|
||||
await tab.getPersistedItem('workflow1.json').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.getNodes()).length).toEqual(1)
|
||||
})
|
||||
|
||||
test('Can rename nested workflow from opened workflow item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
foo: {
|
||||
'bar.json': 'default.json'
|
||||
}
|
||||
})
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
// Switch to the parent folder
|
||||
await tab.getPersistedItem('foo').click()
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
// Switch to the nested workflow
|
||||
await tab.getPersistedItem('bar').click()
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
|
||||
const openedWorkflow = tab.getOpenedItem('foo/bar')
|
||||
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'foo/baz.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can save workflow as', async ({ comfyPage }) => {
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
|
||||
expect(
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
).toEqual(['*Unsaved Workflow.json', 'workflow3.json'])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
|
||||
expect(
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
).toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain localized slot names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const exportedWorkflow = await comfyPage.getExportedWorkflow({
|
||||
api: false
|
||||
})
|
||||
expect(exportedWorkflow).toBeDefined()
|
||||
for (const node of exportedWorkflow.nodes) {
|
||||
for (const slot of node.inputs) {
|
||||
expect(slot.localized_name).toBeUndefined()
|
||||
expect(slot.label).toBeUndefined()
|
||||
}
|
||||
for (const slot of node.outputs) {
|
||||
expect(slot.localized_name).toBeUndefined()
|
||||
expect(slot.label).toBeUndefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Can export same workflow with different locales', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
// Setup download listener before triggering the export
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await comfyPage.menu.topbar.exportWorkflow('exported_default.json')
|
||||
|
||||
// Wait for the download and get the file content
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('exported_default.json')
|
||||
|
||||
// Get the exported workflow content
|
||||
const downloadedContent = await comfyPage.getExportedWorkflow({
|
||||
api: false
|
||||
})
|
||||
|
||||
await comfyPage.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.setup()
|
||||
|
||||
const downloadedContentZh = await comfyPage.getExportedWorkflow({
|
||||
api: false
|
||||
})
|
||||
|
||||
// Compare the exported workflow with the original
|
||||
delete downloadedContent.id
|
||||
delete downloadedContentZh.id
|
||||
expect(downloadedContent).toBeDefined()
|
||||
expect(downloadedContent).toEqual(downloadedContentZh)
|
||||
})
|
||||
|
||||
test('Can save workflow as with same name', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
|
||||
expect(
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
).toEqual(['workflow5.json'])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
expect(
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
).toEqual(['workflow5.json'])
|
||||
})
|
||||
|
||||
test('Can save temporary workflow with unmodified name', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('Unsaved Workflow')
|
||||
// Should not trigger the overwrite dialog
|
||||
expect(
|
||||
await comfyPage.page.locator('.comfy-modal-content:visible').count()
|
||||
).toBe(0)
|
||||
|
||||
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
|
||||
})
|
||||
|
||||
test('Can overwrite other workflows with save as', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
await topbar.saveWorkflow('workflow1.json')
|
||||
await topbar.saveWorkflowAs('workflow2.json')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
).toEqual(['workflow1.json', 'workflow2.json'])
|
||||
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
|
||||
'workflow2.json'
|
||||
)
|
||||
|
||||
await topbar.saveWorkflowAs('workflow1.json')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
// The old workflow1.json should be deleted and the new one should be saved.
|
||||
expect(
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
).toEqual(['workflow2.json', 'workflow1.json'])
|
||||
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
|
||||
'workflow1.json'
|
||||
)
|
||||
})
|
||||
|
||||
test('Does not report warning when switching between opened workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('missing_nodes')
|
||||
await comfyPage.closeDialog()
|
||||
|
||||
// Load blank workflow
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
// Switch back to the missing_nodes workflow
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Can close saved-workflows from the open workflows section', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow(
|
||||
`tempWorkflow-${test.info().title}`
|
||||
)
|
||||
const closeButton = comfyPage.page.locator(
|
||||
'.comfyui-workflows-open .close-workflow-button'
|
||||
)
|
||||
await closeButton.click()
|
||||
expect(
|
||||
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
|
||||
).toEqual(['*Unsaved Workflow.json'])
|
||||
})
|
||||
|
||||
test('Can close saved workflow with command', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
|
||||
await comfyPage.executeCommand('Workspace.CloseWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Workflow.ConfirmDelete', false)
|
||||
|
||||
const { topbar, workflowsTab } = comfyPage.menu
|
||||
|
||||
const filename = 'workflow18.json'
|
||||
await topbar.saveWorkflow(filename)
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
|
||||
|
||||
await workflowsTab.getOpenedItem(filename).click({ button: 'right' })
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clickContextMenuItem('Delete')
|
||||
|
||||
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can delete workflows', async ({ comfyPage }) => {
|
||||
const { topbar, workflowsTab } = comfyPage.menu
|
||||
|
||||
const filename = 'workflow18.json'
|
||||
await topbar.saveWorkflow(filename)
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
|
||||
|
||||
await workflowsTab.getOpenedItem(filename).click({ button: 'right' })
|
||||
await comfyPage.clickContextMenuItem('Delete')
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json'
|
||||
})
|
||||
await comfyPage.setup()
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
|
||||
const nodeCount = await comfyPage.getGraphNodesCount()
|
||||
|
||||
// Get the bounding box of the canvas element
|
||||
const canvasBoundingBox = (await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.boundingBox())!
|
||||
|
||||
// Calculate the center position of the canvas
|
||||
const targetPosition = {
|
||||
x: canvasBoundingBox.x + canvasBoundingBox.width / 2,
|
||||
y: canvasBoundingBox.y + canvasBoundingBox.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.page.dragAndDrop(
|
||||
'.comfyui-workflows-browse .node-label:has-text("workflow1.json")',
|
||||
'#graph-canvas',
|
||||
{ targetPosition }
|
||||
)
|
||||
// Wait for the workflow to be inserted
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workflows topbar tabs', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting(
|
||||
@@ -822,204 +127,3 @@ test.describe('Menu', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe.skip('Queue sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('can display tasks', async ({ comfyPage }) => {
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
|
||||
test('can display tasks after closing then opening', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.close()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
|
||||
test.describe('Virtual scroll', () => {
|
||||
const layouts = [
|
||||
{ description: 'Five columns layout', width: 95, rows: 3, cols: 5 },
|
||||
{ description: 'Three columns layout', width: 55, rows: 3, cols: 3 },
|
||||
{ description: 'Two columns layout', width: 40, rows: 3, cols: 2 }
|
||||
]
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'])
|
||||
.repeat(50)
|
||||
.setupRoutes()
|
||||
})
|
||||
|
||||
layouts.forEach(({ description, width, rows, cols }) => {
|
||||
const preRenderedRows = 1
|
||||
const preRenderedTasks = preRenderedRows * cols * 2
|
||||
const visibleTasks = rows * cols
|
||||
const expectRenderLimit = visibleTasks + preRenderedTasks
|
||||
|
||||
test.describe(description, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.setTabWidth(width)
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
})
|
||||
|
||||
test('should not render items outside of view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
|
||||
test('should teardown items after scrolling away', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.queueTab.scrollTasks('down')
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
|
||||
test('should re-render items after scrolling away then back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.queueTab.scrollTasks('down')
|
||||
await comfyPage.menu.queueTab.scrollTasks('up')
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expand tasks', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// 2-item batch and 3-item batch -> 3 additional items when expanded
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp', 'example.webp', 'example.webp'])
|
||||
.withTask(['example.webp', 'example.webp'])
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
})
|
||||
|
||||
test('can expand tasks with multiple outputs', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
await comfyPage.menu.queueTab.expandTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
|
||||
initialCount + 3
|
||||
)
|
||||
})
|
||||
|
||||
test('can collapse flat tasks', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
await comfyPage.menu.queueTab.expandTasks()
|
||||
await comfyPage.menu.queueTab.collapseTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
|
||||
initialCount
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Clear tasks', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'])
|
||||
.repeat(6)
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
})
|
||||
|
||||
test('can clear all tasks', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.clearTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(0)
|
||||
expect(
|
||||
await comfyPage.menu.queueTab.noResultsPlaceholder.isVisible()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('can load new tasks after clearing all', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.clearTasks()
|
||||
await comfyPage.menu.queueTab.close()
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Gallery', () => {
|
||||
const firstImage = 'example.webp'
|
||||
const secondImage = 'image32x32.webp'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask([secondImage])
|
||||
.withTask([firstImage])
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
await comfyPage.menu.queueTab.openTaskPreview(0)
|
||||
})
|
||||
|
||||
test('displays gallery image after opening task preview', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nextFrame()
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
|
||||
})
|
||||
|
||||
test('maintains active gallery item when new tasks are added', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Add a new task while the gallery is still open
|
||||
const newImage = 'image64x64.webp'
|
||||
comfyPage.setupHistory().withTask([newImage])
|
||||
await comfyPage.menu.queueTab.triggerTasksUpdate()
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
|
||||
await newTask.waitFor({ state: 'visible' })
|
||||
// The active gallery item should still be the initial image
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Gallery navigation', () => {
|
||||
const paths: {
|
||||
description: string
|
||||
path: ('Right' | 'Left')[]
|
||||
end: string
|
||||
}[] = [
|
||||
{ description: 'Right', path: ['Right'], end: secondImage },
|
||||
{ description: 'Left', path: ['Right', 'Left'], end: firstImage },
|
||||
{ description: 'Left wrap', path: ['Left'], end: secondImage },
|
||||
{ description: 'Right wrap', path: ['Right', 'Right'], end: firstImage }
|
||||
]
|
||||
|
||||
paths.forEach(({ description, path, end }) => {
|
||||
test(`can navigate gallery ${description}`, async ({ comfyPage }) => {
|
||||
for (const direction of path)
|
||||
await comfyPage.page.keyboard.press(`Arrow${direction}`, {
|
||||
delay: 256
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 104 KiB |
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 61 KiB |
@@ -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)
|
||||
|
||||
@@ -39,6 +39,10 @@ test.describe('Reroute Node', () => {
|
||||
})
|
||||
|
||||
test.describe('LiteGraph Native Reroute Node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
|
||||
})
|
||||
|
||||
test('loads from workflow', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('reroute/native_reroute')
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('native_reroute.png')
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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'])
|
||||
@@ -206,5 +246,24 @@ test.describe('Selection Toolbox', () => {
|
||||
)
|
||||
await expect(colorPickerButton).toHaveCSS('color', BLUE_COLOR)
|
||||
})
|
||||
|
||||
test('colorization via color picker can be undone', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Select a node and color it
|
||||
await comfyPage.selectNodes(['KSampler'])
|
||||
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
|
||||
await comfyPage.page
|
||||
.locator('.color-picker-container i[data-testid="blue"]')
|
||||
.click()
|
||||
|
||||
// Undo the colorization
|
||||
await comfyPage.page.keyboard.press('Control+Z')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Node should be uncolored again
|
||||
const selectedNode = (await comfyPage.getNodeRefsByTitle('KSampler'))[0]
|
||||
expect(await selectedNode.getProperty('color')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
339
browser_tests/tests/sidebar/nodeLibrary.spec.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Node library sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.BookmarksCustomization', {})
|
||||
// Open the sidebar
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test('Node preview and drag to canvas', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
|
||||
// Hover over a node to display the preview
|
||||
const nodeSelector = '.p-tree-node-leaf'
|
||||
await comfyPage.page.hover(nodeSelector)
|
||||
|
||||
// Verify the preview is displayed
|
||||
const previewVisible = await comfyPage.page.isVisible(
|
||||
'.node-lib-node-preview'
|
||||
)
|
||||
expect(previewVisible).toBe(true)
|
||||
|
||||
const count = await comfyPage.getGraphNodesCount()
|
||||
// Drag the node onto the canvas
|
||||
const canvasSelector = '#graph-canvas'
|
||||
|
||||
// Get the bounding box of the canvas element
|
||||
const canvasBoundingBox = (await comfyPage.page
|
||||
.locator(canvasSelector)
|
||||
.boundingBox())!
|
||||
|
||||
// Calculate the center position of the canvas
|
||||
const targetPosition = {
|
||||
x: canvasBoundingBox.x + canvasBoundingBox.width / 2,
|
||||
y: canvasBoundingBox.y + canvasBoundingBox.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector, {
|
||||
targetPosition
|
||||
})
|
||||
|
||||
// Verify the node is added to the canvas
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(count + 1)
|
||||
})
|
||||
|
||||
test('Bookmark node', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
|
||||
// Bookmark the node
|
||||
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
|
||||
|
||||
// Verify the bookmark is added to the bookmarks tab
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['KSamplerAdvanced'])
|
||||
// Verify the bookmark node with the same name is added to the tree.
|
||||
expect(await tab.getNode('KSampler (Advanced)').count()).toBe(2)
|
||||
|
||||
// Hover on the bookmark node to display the preview
|
||||
await comfyPage.page.hover('.node-lib-bookmark-tree-explorer .tree-leaf')
|
||||
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(true)
|
||||
})
|
||||
|
||||
test('Ignores unrecognized node', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo'])
|
||||
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
expect(await tab.getFolder('sampling').count()).toBe(1)
|
||||
expect(await tab.getNode('foo').count()).toBe(0)
|
||||
})
|
||||
|
||||
test('Displays empty bookmarks folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
expect(await tab.getFolder('foo').count()).toBe(1)
|
||||
})
|
||||
|
||||
test('Can add new bookmark folder', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.newFolderButton.click()
|
||||
const textInput = comfyPage.page.locator('.editable-text input')
|
||||
await textInput.waitFor({ state: 'visible' })
|
||||
await textInput.fill('New Folder')
|
||||
await textInput.press('Enter')
|
||||
expect(await tab.getFolder('New Folder').count()).toBe(1)
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['New Folder/'])
|
||||
})
|
||||
|
||||
test('Can add nested bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('New Folder').click()
|
||||
const textInput = comfyPage.page.locator('.editable-text input')
|
||||
await textInput.waitFor({ state: 'visible' })
|
||||
await textInput.fill('bar')
|
||||
await textInput.press('Enter')
|
||||
|
||||
expect(await tab.getFolder('bar').count()).toBe(1)
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['foo/', 'foo/bar/'])
|
||||
})
|
||||
|
||||
test('Can delete bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Delete').click()
|
||||
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('Can rename bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu-item-label:has-text("Rename")')
|
||||
.click()
|
||||
await comfyPage.page.keyboard.insertText('bar')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['bar/'])
|
||||
})
|
||||
|
||||
test('Can add bookmark by dragging node to bookmark folder', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
await comfyPage.page.dragAndDrop(
|
||||
tab.nodeSelector('KSampler (Advanced)'),
|
||||
tab.folderSelector('foo')
|
||||
)
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['foo/', 'foo/KSamplerAdvanced'])
|
||||
})
|
||||
|
||||
test('Can add bookmark by clicking bookmark button', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['KSamplerAdvanced'])
|
||||
})
|
||||
|
||||
test('Can unbookmark node (Top level bookmark)', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSamplerAdvanced'
|
||||
])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getNode('KSampler (Advanced)').locator('.bookmark-button').click()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('Can unbookmark node (Library node bookmark)', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'KSamplerAdvanced'
|
||||
])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('sampling').click()
|
||||
await comfyPage.page
|
||||
.locator(tab.nodeSelector('KSampler (Advanced)'))
|
||||
.nth(1)
|
||||
.locator('.bookmark-button')
|
||||
.click()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
})
|
||||
test('Can customize icon', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Customize').click()
|
||||
await comfyPage.page
|
||||
.locator('.icon-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('.color-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page.getByLabel('Confirm').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({
|
||||
'foo/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
})
|
||||
// If color is left as default, it should not be saved
|
||||
test('Can customize icon (default field)', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Customize').click()
|
||||
await comfyPage.page
|
||||
.locator('.icon-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page.getByLabel('Confirm').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({
|
||||
'foo/': {
|
||||
icon: 'pi-folder'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Can customize bookmark color after interacting with color options', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open customization dialog
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Customize').click()
|
||||
|
||||
// Click a color option multiple times
|
||||
const customColorOption = comfyPage.page.locator(
|
||||
'.p-togglebutton-content > .pi-palette'
|
||||
)
|
||||
await customColorOption.click()
|
||||
await customColorOption.click()
|
||||
|
||||
// Use the color picker
|
||||
await comfyPage.page
|
||||
.getByLabel('Customize Folder')
|
||||
.getByRole('textbox')
|
||||
.click()
|
||||
await comfyPage.page.locator('.p-colorpicker-color-background').click()
|
||||
|
||||
// Finalize the customization
|
||||
await comfyPage.page
|
||||
.locator('.icon-field .p-selectbutton > *:nth-child(2)')
|
||||
.click()
|
||||
await comfyPage.page.getByLabel('Confirm').click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify the color selection is saved
|
||||
const setting = await comfyPage.getSetting(
|
||||
'Comfy.NodeLibrary.BookmarksCustomization'
|
||||
)
|
||||
await expect(setting).toHaveProperty(['foo/', 'color'])
|
||||
await expect(setting['foo/'].color).not.toBeNull()
|
||||
await expect(setting['foo/'].color).not.toBeUndefined()
|
||||
await expect(setting['foo/'].color).not.toBe('')
|
||||
})
|
||||
|
||||
test('Can rename customized bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.BookmarksCustomization', {
|
||||
'foo/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu-item-label:has-text("Rename")')
|
||||
.click()
|
||||
await comfyPage.page.keyboard.insertText('bar')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual(['bar/'])
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({
|
||||
'bar/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Can delete customized bookmark folder', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', ['foo/'])
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.BookmarksCustomization', {
|
||||
'foo/': {
|
||||
icon: 'pi-folder',
|
||||
color: '#007bff'
|
||||
}
|
||||
})
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.getFolder('foo').click({ button: 'right' })
|
||||
await comfyPage.page.getByLabel('Delete').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
|
||||
).toEqual([])
|
||||
expect(
|
||||
await comfyPage.getSetting('Comfy.NodeLibrary.BookmarksCustomization')
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
test('Can filter nodes in both trees', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
|
||||
'foo/',
|
||||
'foo/KSamplerAdvanced',
|
||||
'KSampler'
|
||||
])
|
||||
|
||||
const tab = comfyPage.menu.nodeLibraryTab
|
||||
await tab.nodeLibrarySearchBoxInput.fill('KSampler')
|
||||
// Node search box is debounced and may take some time to update.
|
||||
await comfyPage.page.waitForTimeout(1000)
|
||||
expect(await tab.getNode('KSampler (Advanced)').count()).toBe(2)
|
||||
})
|
||||
})
|
||||
204
browser_tests/tests/sidebar/queue.spec.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe.skip('Queue sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('can display tasks', async ({ comfyPage }) => {
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
|
||||
test('can display tasks after closing then opening', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.close()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
|
||||
test.describe('Virtual scroll', () => {
|
||||
const layouts = [
|
||||
{ description: 'Five columns layout', width: 95, rows: 3, cols: 5 },
|
||||
{ description: 'Three columns layout', width: 55, rows: 3, cols: 3 },
|
||||
{ description: 'Two columns layout', width: 40, rows: 3, cols: 2 }
|
||||
]
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'])
|
||||
.repeat(50)
|
||||
.setupRoutes()
|
||||
})
|
||||
|
||||
layouts.forEach(({ description, width, rows, cols }) => {
|
||||
const preRenderedRows = 1
|
||||
const preRenderedTasks = preRenderedRows * cols * 2
|
||||
const visibleTasks = rows * cols
|
||||
const expectRenderLimit = visibleTasks + preRenderedTasks
|
||||
|
||||
test.describe(description, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.setTabWidth(width)
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
})
|
||||
|
||||
test('should not render items outside of view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
|
||||
test('should teardown items after scrolling away', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.queueTab.scrollTasks('down')
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
|
||||
test('should re-render items after scrolling away then back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.queueTab.scrollTasks('down')
|
||||
await comfyPage.menu.queueTab.scrollTasks('up')
|
||||
const renderedCount =
|
||||
await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
expect(renderedCount).toBeLessThanOrEqual(expectRenderLimit)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Expand tasks', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// 2-item batch and 3-item batch -> 3 additional items when expanded
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp', 'example.webp', 'example.webp'])
|
||||
.withTask(['example.webp', 'example.webp'])
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
})
|
||||
|
||||
test('can expand tasks with multiple outputs', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
await comfyPage.menu.queueTab.expandTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
|
||||
initialCount + 3
|
||||
)
|
||||
})
|
||||
|
||||
test('can collapse flat tasks', async ({ comfyPage }) => {
|
||||
const initialCount = await comfyPage.menu.queueTab.visibleTasks.count()
|
||||
await comfyPage.menu.queueTab.expandTasks()
|
||||
await comfyPage.menu.queueTab.collapseTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(
|
||||
initialCount
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Clear tasks', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask(['example.webp'])
|
||||
.repeat(6)
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
})
|
||||
|
||||
test('can clear all tasks', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.clearTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(0)
|
||||
expect(
|
||||
await comfyPage.menu.queueTab.noResultsPlaceholder.isVisible()
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('can load new tasks after clearing all', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.queueTab.clearTasks()
|
||||
await comfyPage.menu.queueTab.close()
|
||||
await comfyPage.setupHistory().withTask(['example.webp']).setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
expect(await comfyPage.menu.queueTab.visibleTasks.count()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Gallery', () => {
|
||||
const firstImage = 'example.webp'
|
||||
const secondImage = 'image32x32.webp'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage
|
||||
.setupHistory()
|
||||
.withTask([secondImage])
|
||||
.withTask([firstImage])
|
||||
.setupRoutes()
|
||||
await comfyPage.menu.queueTab.open()
|
||||
await comfyPage.menu.queueTab.waitForTasks()
|
||||
await comfyPage.menu.queueTab.openTaskPreview(0)
|
||||
})
|
||||
|
||||
test('displays gallery image after opening task preview', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.nextFrame()
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
|
||||
})
|
||||
|
||||
test('maintains active gallery item when new tasks are added', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Add a new task while the gallery is still open
|
||||
const newImage = 'image64x64.webp'
|
||||
comfyPage.setupHistory().withTask([newImage])
|
||||
await comfyPage.menu.queueTab.triggerTasksUpdate()
|
||||
await comfyPage.page.waitForTimeout(500)
|
||||
const newTask = comfyPage.menu.queueTab.tasks.getByAltText(newImage)
|
||||
await newTask.waitFor({ state: 'visible' })
|
||||
// The active gallery item should still be the initial image
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(firstImage)).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe('Gallery navigation', () => {
|
||||
const paths: {
|
||||
description: string
|
||||
path: ('Right' | 'Left')[]
|
||||
end: string
|
||||
}[] = [
|
||||
{ description: 'Right', path: ['Right'], end: secondImage },
|
||||
{ description: 'Left', path: ['Right', 'Left'], end: firstImage },
|
||||
{ description: 'Left wrap', path: ['Left'], end: secondImage },
|
||||
{ description: 'Right wrap', path: ['Right', 'Right'], end: firstImage }
|
||||
]
|
||||
|
||||
paths.forEach(({ description, path, end }) => {
|
||||
test(`can navigate gallery ${description}`, async ({ comfyPage }) => {
|
||||
for (const direction of path)
|
||||
await comfyPage.page.keyboard.press(`Arrow${direction}`, {
|
||||
delay: 256
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
expect(comfyPage.menu.queueTab.getGalleryImage(end)).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
349
browser_tests/tests/sidebar/workflows.spec.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.setSetting('Comfy.Workflow.WorkflowTabsPosition', 'Sidebar')
|
||||
|
||||
// Open the sidebar
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Can create new blank workflow', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'*Unsaved Workflow (2).json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can show top level saved workflows', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json',
|
||||
'workflow2.json': 'default.json'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
|
||||
expect.arrayContaining(['workflow1.json', 'workflow2.json'])
|
||||
)
|
||||
})
|
||||
|
||||
test('Can duplicate workflow', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
|
||||
|
||||
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual(
|
||||
expect.arrayContaining(['workflow1.json'])
|
||||
)
|
||||
|
||||
await comfyPage.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json'
|
||||
])
|
||||
|
||||
await comfyPage.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json',
|
||||
'*workflow1 (Copy) (2).json'
|
||||
])
|
||||
|
||||
await comfyPage.executeCommand('Comfy.DuplicateWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'*workflow1 (Copy).json',
|
||||
'*workflow1 (Copy) (2).json',
|
||||
'*workflow1 (Copy) (3).json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can open workflow after insert', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'single_ksampler.json'
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await comfyPage.executeCommand('Comfy.LoadDefaultWorkflow')
|
||||
const originalNodeCount = (await comfyPage.getNodes()).length
|
||||
|
||||
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.getNodes()).length).toEqual(originalNodeCount + 1)
|
||||
|
||||
await tab.getPersistedItem('workflow1.json').click()
|
||||
await comfyPage.nextFrame()
|
||||
expect((await comfyPage.getNodes()).length).toEqual(1)
|
||||
})
|
||||
|
||||
test('Can rename nested workflow from opened workflow item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
foo: {
|
||||
'bar.json': 'default.json'
|
||||
}
|
||||
})
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
// Switch to the parent folder
|
||||
await tab.getPersistedItem('foo').click()
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
// Switch to the nested workflow
|
||||
await tab.getPersistedItem('bar').click()
|
||||
await comfyPage.page.waitForTimeout(300)
|
||||
|
||||
const openedWorkflow = tab.getOpenedItem('foo/bar')
|
||||
await tab.renameWorkflow(openedWorkflow, 'foo/baz')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'foo/baz.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can save workflow as', async ({ comfyPage }) => {
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow3.json')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'workflow3.json'
|
||||
])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow4.json')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json',
|
||||
'workflow3.json',
|
||||
'workflow4.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Exported workflow does not contain localized slot names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const exportedWorkflow = await comfyPage.getExportedWorkflow({
|
||||
api: false
|
||||
})
|
||||
expect(exportedWorkflow).toBeDefined()
|
||||
for (const node of exportedWorkflow.nodes) {
|
||||
for (const slot of node.inputs) {
|
||||
expect(slot.localized_name).toBeUndefined()
|
||||
expect(slot.label).toBeUndefined()
|
||||
}
|
||||
for (const slot of node.outputs) {
|
||||
expect(slot.localized_name).toBeUndefined()
|
||||
expect(slot.label).toBeUndefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('Can export same workflow with different locales', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
// Setup download listener before triggering the export
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await comfyPage.menu.topbar.exportWorkflow('exported_default.json')
|
||||
|
||||
// Wait for the download and get the file content
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('exported_default.json')
|
||||
|
||||
// Get the exported workflow content
|
||||
const downloadedContent = await comfyPage.getExportedWorkflow({
|
||||
api: false
|
||||
})
|
||||
|
||||
await comfyPage.setSetting('Comfy.Locale', 'zh')
|
||||
await comfyPage.setup()
|
||||
|
||||
const downloadedContentZh = await comfyPage.getExportedWorkflow({
|
||||
api: false
|
||||
})
|
||||
|
||||
// Compare the exported workflow with the original
|
||||
delete downloadedContent.id
|
||||
delete downloadedContentZh.id
|
||||
expect(downloadedContent).toBeDefined()
|
||||
expect(downloadedContent).toEqual(downloadedContentZh)
|
||||
})
|
||||
|
||||
test('Can save workflow as with same name', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow5.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can save temporary workflow with unmodified name', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
|
||||
|
||||
await comfyPage.menu.topbar.saveWorkflow('Unsaved Workflow')
|
||||
// Should not trigger the overwrite dialog
|
||||
expect(
|
||||
await comfyPage.page.locator('.comfy-modal-content:visible').count()
|
||||
).toBe(0)
|
||||
|
||||
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
|
||||
})
|
||||
|
||||
test('Can overwrite other workflows with save as', async ({ comfyPage }) => {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
await topbar.saveWorkflow('workflow1.json')
|
||||
await topbar.saveWorkflowAs('workflow2.json')
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'workflow1.json',
|
||||
'workflow2.json'
|
||||
])
|
||||
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
|
||||
'workflow2.json'
|
||||
)
|
||||
|
||||
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',
|
||||
'workflow1.json'
|
||||
])
|
||||
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
|
||||
'workflow1.json'
|
||||
)
|
||||
})
|
||||
|
||||
test('Does not report warning when switching between opened workflows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('missing_nodes')
|
||||
await comfyPage.closeDialog()
|
||||
|
||||
// Load blank workflow
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
// Switch back to the missing_nodes workflow
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('.comfy-missing-nodes')
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Can close saved-workflows from the open workflows section', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.menu.topbar.saveWorkflow(
|
||||
`tempWorkflow-${test.info().title}`
|
||||
)
|
||||
const closeButton = comfyPage.page.locator(
|
||||
'.comfyui-workflows-open .close-workflow-button'
|
||||
)
|
||||
await closeButton.click()
|
||||
expect(await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can close saved workflow with command', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow1.json')
|
||||
await comfyPage.executeCommand('Workspace.CloseWorkflow')
|
||||
expect(await tab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can delete workflows (confirm disabled)', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Workflow.ConfirmDelete', false)
|
||||
|
||||
const { topbar, workflowsTab } = comfyPage.menu
|
||||
|
||||
const filename = 'workflow18.json'
|
||||
await topbar.saveWorkflow(filename)
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
|
||||
|
||||
await workflowsTab.getOpenedItem(filename).click({ button: 'right' })
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clickContextMenuItem('Delete')
|
||||
|
||||
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can delete workflows', async ({ comfyPage }) => {
|
||||
const { topbar, workflowsTab } = comfyPage.menu
|
||||
|
||||
const filename = 'workflow18.json'
|
||||
await topbar.saveWorkflow(filename)
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([filename])
|
||||
|
||||
await workflowsTab.getOpenedItem(filename).click({ button: 'right' })
|
||||
await comfyPage.clickContextMenuItem('Delete')
|
||||
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
await expect(workflowsTab.getOpenedItem(filename)).not.toBeVisible()
|
||||
expect(await workflowsTab.getOpenedWorkflowNames()).toEqual([
|
||||
'*Unsaved Workflow.json'
|
||||
])
|
||||
})
|
||||
|
||||
test('Can drop workflow from workflows sidebar', async ({ comfyPage }) => {
|
||||
await comfyPage.setupWorkflowsDirectory({
|
||||
'workflow1.json': 'default.json'
|
||||
})
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
|
||||
const nodeCount = await comfyPage.getGraphNodesCount()
|
||||
|
||||
// Get the bounding box of the canvas element
|
||||
const canvasBoundingBox = (await comfyPage.page
|
||||
.locator('#graph-canvas')
|
||||
.boundingBox())!
|
||||
|
||||
// Calculate the center position of the canvas
|
||||
const targetPosition = {
|
||||
x: canvasBoundingBox.x + canvasBoundingBox.width / 2,
|
||||
y: canvasBoundingBox.y + canvasBoundingBox.height / 2
|
||||
}
|
||||
|
||||
await comfyPage.page.dragAndDrop(
|
||||
'.comfyui-workflows-browse .node-label:has-text("workflow1.json")',
|
||||
'#graph-canvas',
|
||||
{ targetPosition }
|
||||
)
|
||||
// Wait for the workflow to be inserted
|
||||
await comfyPage.page.waitForTimeout(200)
|
||||
expect(await comfyPage.getGraphNodesCount()).toBe(nodeCount * 2)
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
2
global.d.ts
vendored
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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
14
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.12",
|
||||
"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.16",
|
||||
"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.10",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -17,7 +17,9 @@ const TITLE_SUFFIX = ' - ComfyUI'
|
||||
|
||||
const executionStore = useExecutionStore()
|
||||
const executionText = computed(() =>
|
||||
executionStore.isIdle ? '' : `[${executionStore.executionProgress}%]`
|
||||
executionStore.isIdle
|
||||
? ''
|
||||
: `[${Math.round(executionStore.executionProgress * 100)}%]`
|
||||
)
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
@@ -41,7 +43,7 @@ const workflowNameText = computed(() => {
|
||||
|
||||
const nodeExecutionTitle = computed(() =>
|
||||
executionStore.executingNode && executionStore.executingNodeProgress
|
||||
? `${executionText.value}[${executionStore.executingNodeProgress}%] ${executionStore.executingNode.type}`
|
||||
? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}`
|
||||
: ''
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = (e: Event) => {
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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((mutation, { downloads }) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -32,18 +32,17 @@
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
import { VueSeverity } from '@/types/primeVueTypes'
|
||||
import { PrimeVueSeverity } from '@/types/primeVueTypes'
|
||||
|
||||
// Properties
|
||||
interface Props {
|
||||
outlined?: boolean
|
||||
const {
|
||||
disabled,
|
||||
outlined = true,
|
||||
severity = 'secondary'
|
||||
} = defineProps<{
|
||||
disabled?: boolean
|
||||
severity?: VueSeverity
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
outlined: true,
|
||||
severity: 'secondary'
|
||||
})
|
||||
outlined?: boolean
|
||||
severity?: PrimeVueSeverity
|
||||
}>()
|
||||
|
||||
// Model
|
||||
const active = defineModel<boolean>({ required: true })
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]')
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
159
src/components/dialog/content/ErrorDialogContent.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="comfy-error-report flex flex-col gap-4">
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="title"
|
||||
:message="error.exceptionMessage"
|
||||
/>
|
||||
<template v-if="error.extensionFile">
|
||||
<span>{{ t('errorDialog.extensionFileHint') }}:</span>
|
||||
<br />
|
||||
<span class="font-bold">{{ error.extensionFile }}</span>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-2 justify-center">
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('g.showReport')"
|
||||
@click="showReport"
|
||||
/>
|
||||
<Button
|
||||
v-show="!sendReportOpen"
|
||||
text
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showSendReport"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
<ScrollPanel class="w-full h-[400px] max-w-[80vw]">
|
||||
<pre class="whitespace-pre-wrap break-words">{{ reportContent }}</pre>
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<ReportIssuePanel
|
||||
v-if="sendReportOpen"
|
||||
:title="$t('issueReport.submitErrorReport')"
|
||||
:error-type="error.reportType ?? 'unknownError'"
|
||||
:extra-fields="[stackTraceField]"
|
||||
:tags="{
|
||||
exceptionMessage: error.exceptionMessage,
|
||||
nodeType: error.nodeType ?? 'UNKNOWN'
|
||||
}"
|
||||
/>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<FindIssueButton
|
||||
:errorMessage="error.exceptionMessage"
|
||||
:repoOwner="repoOwner"
|
||||
:repoName="repoName"
|
||||
/>
|
||||
<Button
|
||||
v-if="reportOpen"
|
||||
:label="$t('g.copyToClipboard')"
|
||||
icon="pi pi-copy"
|
||||
@click="copyReportToClipboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { ReportField } from '@/types/issueReportTypes'
|
||||
import {
|
||||
type ErrorReportData,
|
||||
generateErrorReport
|
||||
} from '@/utils/errorReportUtil'
|
||||
|
||||
import ReportIssuePanel from './error/ReportIssuePanel.vue'
|
||||
|
||||
const { error } = defineProps<{
|
||||
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
|
||||
/**
|
||||
* The type of error report to submit.
|
||||
* @default 'unknownError'
|
||||
*/
|
||||
reportType?: string
|
||||
/**
|
||||
* The file name of the extension that caused the error.
|
||||
*/
|
||||
extensionFile?: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const repoOwner = 'comfyanonymous'
|
||||
const repoName = 'ComfyUI'
|
||||
const reportContent = ref('')
|
||||
const reportOpen = ref(false)
|
||||
const showReport = () => {
|
||||
reportOpen.value = true
|
||||
}
|
||||
const sendReportOpen = ref(false)
|
||||
const showSendReport = () => {
|
||||
sendReportOpen.value = true
|
||||
}
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const title = computed<string>(
|
||||
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
|
||||
)
|
||||
|
||||
const stackTraceField = computed<ReportField>(() => {
|
||||
return {
|
||||
label: t('issueReport.stackTrace'),
|
||||
value: 'StackTrace',
|
||||
optIn: true,
|
||||
getData: () => error.traceback
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (!systemStatsStore.systemStats) {
|
||||
await systemStatsStore.fetchSystemStats()
|
||||
}
|
||||
|
||||
try {
|
||||
const [logs] = await Promise.all([api.getLogs()])
|
||||
|
||||
reportContent.value = generateErrorReport({
|
||||
systemStats: systemStatsStore.systemStats!,
|
||||
serverLogs: logs,
|
||||
workflow: app.graph.serialize(),
|
||||
exceptionType: error.exceptionType,
|
||||
exceptionMessage: error.exceptionMessage,
|
||||
traceback: error.traceback,
|
||||
nodeId: error.nodeId,
|
||||
nodeType: error.nodeType
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('g.error'),
|
||||
detail: t('toastMessages.failedToFetchLogs'),
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const copyReportToClipboard = async () => {
|
||||
await copyToClipboard(reportContent.value)
|
||||
}
|
||||
</script>
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
icon="pi pi-exclamation-circle"
|
||||
:title="props.error.node_type"
|
||||
:message="props.error.exception_message"
|
||||
/>
|
||||
<div class="comfy-error-report">
|
||||
<div class="flex gap-2 justify-center">
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
:label="$t('g.showReport')"
|
||||
@click="showReport"
|
||||
/>
|
||||
<Button
|
||||
v-show="!sendReportOpen"
|
||||
text
|
||||
:label="$t('issueReport.helpFix')"
|
||||
@click="showSendReport"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
<ScrollPanel style="width: 100%; height: 400px; max-width: 80vw">
|
||||
<pre class="wrapper-pre">{{ reportContent }}</pre>
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<ReportIssuePanel
|
||||
v-if="sendReportOpen"
|
||||
:title="$t('issueReport.submitErrorReport')"
|
||||
error-type="graphExecutionError"
|
||||
:extra-fields="[stackTraceField]"
|
||||
:tags="{
|
||||
exceptionMessage: props.error.exception_message,
|
||||
nodeType: props.error.node_type
|
||||
}"
|
||||
/>
|
||||
<div class="action-container">
|
||||
<FindIssueButton
|
||||
:errorMessage="props.error.exception_message"
|
||||
:repoOwner="repoOwner"
|
||||
:repoName="repoName"
|
||||
/>
|
||||
<Button
|
||||
v-if="reportOpen"
|
||||
:label="$t('g.copyToClipboard')"
|
||||
icon="pi pi-copy"
|
||||
@click="copyReportToClipboard"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import type { ExecutionErrorWsMessage, SystemStats } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ReportField } from '@/types/issueReportTypes'
|
||||
|
||||
import ReportIssuePanel from './error/ReportIssuePanel.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
error: ExecutionErrorWsMessage
|
||||
}>()
|
||||
|
||||
const repoOwner = 'comfyanonymous'
|
||||
const repoName = 'ComfyUI'
|
||||
const reportContent = ref('')
|
||||
const reportOpen = ref(false)
|
||||
const showReport = () => {
|
||||
reportOpen.value = true
|
||||
}
|
||||
const sendReportOpen = ref(false)
|
||||
const showSendReport = () => {
|
||||
sendReportOpen.value = true
|
||||
}
|
||||
const toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
const stackTraceField = computed<ReportField>(() => {
|
||||
return {
|
||||
label: t('issueReport.stackTrace'),
|
||||
value: 'StackTrace',
|
||||
optIn: true,
|
||||
getData: () => props.error.traceback?.join('\n')
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [systemStats, logs] = await Promise.all([
|
||||
api.getSystemStats(),
|
||||
api.getLogs()
|
||||
])
|
||||
generateReport(systemStats, logs)
|
||||
} catch (error) {
|
||||
console.error('Error fetching system stats or logs:', error)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Error',
|
||||
detail: 'Failed to fetch system information',
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const generateReport = (systemStats: SystemStats, logs: string) => {
|
||||
// The default JSON workflow has about 3000 characters.
|
||||
const MAX_JSON_LENGTH = 20000
|
||||
const workflowJSONString = JSON.stringify(app.graph.serialize())
|
||||
const workflowText =
|
||||
workflowJSONString.length > MAX_JSON_LENGTH
|
||||
? 'Workflow too large. Please manually upload the workflow from local file system.'
|
||||
: workflowJSONString
|
||||
|
||||
reportContent.value = `
|
||||
# ComfyUI Error Report
|
||||
## Error Details
|
||||
- **Node ID:** ${props.error.node_id}
|
||||
- **Node Type:** ${props.error.node_type}
|
||||
- **Exception Type:** ${props.error.exception_type}
|
||||
- **Exception Message:** ${props.error.exception_message}
|
||||
## Stack Trace
|
||||
\`\`\`
|
||||
${props.error.traceback.join('\n')}
|
||||
\`\`\`
|
||||
## System Information
|
||||
- **ComfyUI Version:** ${systemStats.system.comfyui_version}
|
||||
- **Arguments:** ${systemStats.system.argv.join(' ')}
|
||||
- **OS:** ${systemStats.system.os}
|
||||
- **Python Version:** ${systemStats.system.python_version}
|
||||
- **Embedded Python:** ${systemStats.system.embedded_python}
|
||||
- **PyTorch Version:** ${systemStats.system.pytorch_version}
|
||||
## Devices
|
||||
${systemStats.devices
|
||||
.map(
|
||||
(device) => `
|
||||
- **Name:** ${device.name}
|
||||
- **Type:** ${device.type}
|
||||
- **VRAM Total:** ${device.vram_total}
|
||||
- **VRAM Free:** ${device.vram_free}
|
||||
- **Torch VRAM Total:** ${device.torch_vram_total}
|
||||
- **Torch VRAM Free:** ${device.torch_vram_free}
|
||||
`
|
||||
)
|
||||
.join('\n')}
|
||||
## Logs
|
||||
\`\`\`
|
||||
${logs}
|
||||
\`\`\`
|
||||
## Attached Workflow
|
||||
Please make sure that workflow does not contain any sensitive information such as API keys or passwords.
|
||||
\`\`\`
|
||||
${workflowText}
|
||||
\`\`\`
|
||||
|
||||
## Additional Context
|
||||
(Please add any additional context or steps to reproduce the error here)
|
||||
`
|
||||
}
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const copyReportToClipboard = async () => {
|
||||
await copyToClipboard(reportContent.value)
|
||||
}
|
||||
|
||||
const openNewGithubIssue = async () => {
|
||||
await copyReportToClipboard()
|
||||
const issueTitle = encodeURIComponent(
|
||||
`[Bug]: ${props.error.exception_type} in ${props.error.node_type}`
|
||||
)
|
||||
const issueBody = encodeURIComponent(
|
||||
'The report has been copied to the clipboard. Please paste it here.'
|
||||
)
|
||||
const url = `https://github.com/${repoOwner}/${repoName}/issues/new?title=${issueTitle}&body=${issueBody}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-error-report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.wrapper-pre {
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -18,17 +18,15 @@
|
||||
v-model:selectedTab="selectedTab"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-auto"
|
||||
class="flex-1 overflow-auto pr-80"
|
||||
:class="{
|
||||
'transition-all duration-300': isSmallScreen,
|
||||
'pl-80': isSideNavOpen || !isSmallScreen,
|
||||
'pl-8': !isSideNavOpen && isSmallScreen,
|
||||
'pr-80': showInfoPanel
|
||||
'pl-8': !isSideNavOpen && isSmallScreen
|
||||
}"
|
||||
>
|
||||
<div class="px-6 pt-6 flex flex-col h-full">
|
||||
<RegistrySearchBar
|
||||
v-if="!hideSearchBar"
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:searchMode="searchMode"
|
||||
:searchResults="searchResults"
|
||||
@@ -36,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"
|
||||
@@ -57,29 +55,18 @@
|
||||
<div v-else class="h-full" @click="handleGridContainerClick">
|
||||
<VirtualGrid
|
||||
:items="resultsWithKeys"
|
||||
:defaultItemSize="DEFAULT_CARD_SIZE"
|
||||
class="p-0 m-0 max-w-full"
|
||||
:buffer-rows="2"
|
||||
:gridStyle="{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${DEFAULT_CARD_SIZE}px, 1fr))`,
|
||||
padding: '0.5rem',
|
||||
gap: '1.125rem 1.25rem',
|
||||
justifyContent: 'stretch'
|
||||
}"
|
||||
:buffer-rows="3"
|
||||
:gridStyle="GRID_STYLE"
|
||||
@approach-end="onApproachEnd"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div
|
||||
class="relative w-full aspect-square cursor-pointer"
|
||||
<PackCard
|
||||
@click.stop="(event) => selectNodePack(item, event)"
|
||||
>
|
||||
<PackCard
|
||||
:node-pack="item"
|
||||
:is-selected="
|
||||
selectedNodePacks.some((pack) => pack.id === item.id)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
:node-pack="item"
|
||||
:is-selected="
|
||||
selectedNodePacks.some((pack) => pack.id === item.id)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
@@ -87,13 +74,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showInfoPanel"
|
||||
class="w-80 border-l-0 border-surface-border absolute right-0 top-0 bottom-0 flex z-20"
|
||||
>
|
||||
<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" />
|
||||
@@ -104,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'
|
||||
@@ -117,76 +104,206 @@ 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'
|
||||
|
||||
const DEFAULT_CARD_SIZE = 349
|
||||
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,
|
||||
isOpen: isSideNavOpen,
|
||||
toggle: toggleSideNav
|
||||
} = useResponsiveCollapse()
|
||||
const hideSearchBar = computed(() => isSmallScreen.value && showInfoPanel.value)
|
||||
|
||||
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()
|
||||
|
||||
watchEffect(async () => {
|
||||
if (selectedTab.value.id === 'installed') {
|
||||
displayPacks.value = await getInstalledSearchResults()
|
||||
const {
|
||||
startFetchWorkflowPacks,
|
||||
filterWorkflowPack,
|
||||
workflowPacks,
|
||||
isLoading: isLoadingWorkflow
|
||||
} = useWorkflowPacks()
|
||||
|
||||
const filterMissingPacks = (packs: components['schemas']['Node'][]) =>
|
||||
packs.filter((pack) => !comfyManagerStore.isPackInstalled(pack.id))
|
||||
|
||||
const isInstalledTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Installed
|
||||
)
|
||||
const isMissingTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Missing
|
||||
)
|
||||
const isWorkflowTab = computed(
|
||||
() => selectedTab.value?.id === ManagerTab.Workflow
|
||||
)
|
||||
const isAllTab = computed(() => selectedTab.value?.id === ManagerTab.All)
|
||||
|
||||
watch([isInstalledTab, installedPacks], () => {
|
||||
if (!isInstalledTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = filterInstalledPack(searchResults.value)
|
||||
} else if (!installedPacks.value.length) {
|
||||
startFetchInstalled()
|
||||
} else {
|
||||
displayPacks.value = searchResults.value
|
||||
displayPacks.value = installedPacks.value
|
||||
}
|
||||
})
|
||||
|
||||
const resultsWithKeys = computed(() =>
|
||||
displayPacks.value.map((item) => ({
|
||||
...item,
|
||||
key: item.id || item.name
|
||||
}))
|
||||
watch([isMissingTab, isWorkflowTab, workflowPacks], () => {
|
||||
if (!isWorkflowTab.value && !isMissingTab.value) return
|
||||
|
||||
if (!isEmptySearch.value) {
|
||||
displayPacks.value = isMissingTab.value
|
||||
? filterMissingPacks(filterWorkflowPack(searchResults.value))
|
||||
: filterWorkflowPack(searchResults.value)
|
||||
} else if (!workflowPacks.value.length) {
|
||||
startFetchWorkflowPacks()
|
||||
} else {
|
||||
displayPacks.value = isMissingTab.value
|
||||
? filterMissingPacks(workflowPacks.value)
|
||||
: workflowPacks.value
|
||||
}
|
||||
})
|
||||
|
||||
watch([isAllTab, searchResults], () => {
|
||||
if (!isAllTab.value) return
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
})) 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
|
||||
@@ -220,6 +337,25 @@ const handleGridContainerClick = (event: MouseEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
const showInfoPanel = computed(() => selectedNodePacks.value.length > 0)
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:options="tabs"
|
||||
optionLabel="label"
|
||||
listStyle="max-height:unset"
|
||||
class="w-full border-0 bg-transparent"
|
||||
class="w-full border-0 bg-transparent shadow-none"
|
||||
:pt="{
|
||||
list: { class: 'p-5' },
|
||||
option: { class: 'px-8 py-3 text-lg rounded-xl' },
|
||||
|
||||
@@ -18,20 +18,18 @@
|
||||
<script setup lang="ts">
|
||||
import Message from 'primevue/message'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
import { VueSeverity } from '@/types/primeVueTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type PackVersionStatus = components['schemas']['NodeVersionStatus']
|
||||
type PackStatus = components['schemas']['NodeStatus']
|
||||
type Status = PackVersionStatus | PackStatus
|
||||
|
||||
type MessageProps = InstanceType<typeof Message>['$props']
|
||||
type MessageSeverity = MessageProps['severity']
|
||||
type StatusProps = {
|
||||
label: string
|
||||
severity: VueSeverity
|
||||
severity: MessageSeverity
|
||||
}
|
||||
|
||||
const { statusType } = defineProps<{
|
||||
@@ -49,7 +47,7 @@ const statusPropsMap: Record<Status, StatusProps> = {
|
||||
},
|
||||
NodeStatusBanned: {
|
||||
label: 'banned',
|
||||
severity: 'danger'
|
||||
severity: 'error'
|
||||
},
|
||||
NodeVersionStatusActive: {
|
||||
label: 'active',
|
||||
@@ -65,11 +63,11 @@ const statusPropsMap: Record<Status, StatusProps> = {
|
||||
},
|
||||
NodeVersionStatusFlagged: {
|
||||
label: 'flagged',
|
||||
severity: 'danger'
|
||||
severity: 'error'
|
||||
},
|
||||
NodeVersionStatusBanned: {
|
||||
label: 'banned',
|
||||
severity: 'danger'
|
||||
severity: 'error'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -1,43 +1,53 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full z-40 hidden-scrollbar w-80">
|
||||
<div class="p-6 flex-1 overflow-hidden text-sm">
|
||||
<InfoPanelHeader :node-packs="[nodePack]" />
|
||||
<div class="mb-6">
|
||||
<MetadataRow
|
||||
v-if="isPackInstalled(nodePack.id)"
|
||||
:label="t('manager.filter.enabled')"
|
||||
class="flex"
|
||||
style="align-items: center"
|
||||
>
|
||||
<PackEnableToggle :node-pack="nodePack" />
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
v-for="item in infoItems"
|
||||
v-show="item.value !== undefined && item.value !== null"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
<MetadataRow :label="t('g.status')">
|
||||
<PackStatusMessage
|
||||
:status-type="
|
||||
nodePack.status as components['schemas']['NodeVersionStatus']
|
||||
"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</MetadataRow>
|
||||
<template v-if="nodePack">
|
||||
<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="mb-6 overflow-hidden">
|
||||
<InfoTabs :node-pack="nodePack" />
|
||||
<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)"
|
||||
:label="t('manager.filter.enabled')"
|
||||
class="flex"
|
||||
style="align-items: center"
|
||||
>
|
||||
<PackEnableToggle :node-pack="nodePack" />
|
||||
</MetadataRow>
|
||||
<MetadataRow
|
||||
v-for="item in infoItems"
|
||||
v-show="item.value !== undefined && item.value !== null"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
<MetadataRow :label="t('g.status')">
|
||||
<PackStatusMessage
|
||||
:status-type="
|
||||
nodePack.status as components['schemas']['NodeVersionStatus']
|
||||
"
|
||||
/>
|
||||
</MetadataRow>
|
||||
<MetadataRow :label="t('manager.version')">
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</MetadataRow>
|
||||
</div>
|
||||
<div class="mb-6 overflow-hidden">
|
||||
<InfoTabs :node-pack="nodePack" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="mt-4 mx-8 flex-1 overflow-hidden text-sm">
|
||||
{{ $t('manager.infoPanelEmpty') }}
|
||||
</div>
|
||||
</template>
|
||||
</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'
|
||||
@@ -47,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 {
|
||||
@@ -59,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()
|
||||
@@ -87,9 +106,6 @@ const infoItems = computed<InfoItem[]>(() => [
|
||||
</script>
|
||||
<style scoped>
|
||||
.hidden-scrollbar {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<Card
|
||||
class="absolute inset-0 flex flex-col overflow-hidden rounded-2xl shadow-elevation-4 dark-theme:bg-dark-elevation-1 transition-all duration-200"
|
||||
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 h-full rounded-2xl gap-0' },
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl' },
|
||||
title: {
|
||||
class:
|
||||
'self-stretch px-4 py-3 inline-flex justify-start items-center gap-6'
|
||||
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
|
||||
},
|
||||
footer: { class: 'p-0 m-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
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
<span v-if="publisherName" class="max-w-40 truncate">
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
<span v-else-if="nodePack.latest_version">
|
||||
{{ nodePack.latest_version.version }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="nodePack.latest_version?.createdAt"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@rowDblclick="editKeybinding($event.data)"
|
||||
>
|
||||
<Column field="actions" header="">
|
||||
<template #body="slotProps">
|
||||
@@ -157,7 +158,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 +170,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 +205,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)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// @ts-strict-ignore
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -34,7 +33,7 @@ describe('SettingItem', () => {
|
||||
name: 'Node Input Conversion Submenus',
|
||||
type: 'combo',
|
||||
value: 'Top',
|
||||
options: (value: string) => ['Correctly Translated']
|
||||
options: () => ['Correctly Translated']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -101,6 +101,8 @@ const handleRestart = async () => {
|
||||
|
||||
const onReconnect = () => {
|
||||
useCommandStore().execute('Comfy.RefreshNodeDefinitions')
|
||||
comfyManagerStore.clearLogs()
|
||||
comfyManagerStore.setStale()
|
||||
}
|
||||
useEventListener(api, 'reconnected', onReconnect, { once: true })
|
||||
}
|
||||
|
||||
@@ -19,11 +19,15 @@
|
||||
<script setup lang="ts">
|
||||
import TabMenu from 'primevue/tabmenu'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useManagerProgressDialogStore } from '@/stores/comfyManagerStore'
|
||||
|
||||
const progressDialogContent = useManagerProgressDialogStore()
|
||||
const activeTabIndex = ref(0)
|
||||
|
||||
const tabs = [{ label: 'Installation Queue' }, { label: 'Failed (0)' }]
|
||||
const { t } = useI18n()
|
||||
const tabs = [
|
||||
{ label: t('manager.installationQueue') },
|
||||
{ label: t('manager.failed', { count: 0 }) }
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
@@ -61,13 +62,15 @@ import { usePaste } from '@/composables/usePaste'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n } from '@/i18n'
|
||||
import { api } from '@/scripts/api'
|
||||
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { IS_CONTROL_WIDGET, updateControlWidgetLabel } from '@/scripts/widgets'
|
||||
import { useColorPaletteService } from '@/services/colorPaletteService'
|
||||
import { useWorkflowService } from '@/services/workflowService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
@@ -80,6 +83,7 @@ const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
@@ -106,7 +110,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 +130,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) {
|
||||
@@ -155,6 +162,55 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Update the progress of the executing node
|
||||
watch(
|
||||
() =>
|
||||
[executionStore.executingNodeId, executionStore.executingNodeProgress] as [
|
||||
NodeId | null,
|
||||
number | null
|
||||
],
|
||||
([executingNodeId, executingNodeProgress]) => {
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
if (node.id == executingNodeId) {
|
||||
node.progress = executingNodeProgress ?? undefined
|
||||
} else {
|
||||
node.progress = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Update node slot errors
|
||||
watch(
|
||||
() => executionStore.lastNodeErrors,
|
||||
(lastNodeErrors) => {
|
||||
const removeSlotError = (node: LGraphNode) => {
|
||||
for (const slot of node.inputs) {
|
||||
delete slot.hasErrors
|
||||
}
|
||||
for (const slot of node.outputs) {
|
||||
delete slot.hasErrors
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of comfyApp.graph.nodes) {
|
||||
removeSlotError(node)
|
||||
const nodeErrors = lastNodeErrors?.[node.id]
|
||||
if (!nodeErrors) continue
|
||||
for (const error of nodeErrors.errors) {
|
||||
if (error.extra_info && error.extra_info.input_name) {
|
||||
const inputIndex = node.findInputSlot(error.extra_info.input_name)
|
||||
if (inputIndex !== -1) {
|
||||
node.inputs[inputIndex].hasErrors = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comfyApp.canvas.draw(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
const loadCustomNodesI18n = async () => {
|
||||
try {
|
||||
const i18nData = await api.getCustomNodesI18n()
|
||||
@@ -168,6 +224,7 @@ const loadCustomNodesI18n = async () => {
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
// @ts-expect-error fixme ts strict error
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
|
||||
@@ -184,16 +241,32 @@ onMounted(async () => {
|
||||
// some listeners of litegraph canvas.
|
||||
ChangeTracker.init(comfyApp)
|
||||
await loadCustomNodesI18n()
|
||||
await settingStore.loadSettingValues()
|
||||
try {
|
||||
await settingStore.loadSettingValues()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
console.log(
|
||||
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
|
||||
)
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||