mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-27 17:52:16 +00:00
Compare commits
2 Commits
bl/pr-1030
...
rizumu/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c26b22ec | ||
|
|
83ee120f3d |
1
.github/workflows/release-version-bump.yaml
vendored
1
.github/workflows/release-version-bump.yaml
vendored
@@ -30,7 +30,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
1
.github/workflows/weekly-docs-check.yaml
vendored
1
.github/workflows/weekly-docs-check.yaml
vendored
@@ -18,7 +18,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
docs-check:
|
||||
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
|
||||
@@ -51,9 +51,6 @@
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
|
||||
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
|
||||
|
||||
/** State of a maintenance task, managed by the maintenance task store. */
|
||||
class MaintenanceTaskRunner {
|
||||
export class MaintenanceTaskRunner {
|
||||
constructor(readonly task: MaintenanceTask) {}
|
||||
|
||||
private _state?: MaintenanceTaskState
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
{
|
||||
"id": "f1a2b3c4-d5e6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"pos": [400, 300],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 120,
|
||||
"lastLinkId": 276,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Slot Drift Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [0, 300, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 300, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 120,
|
||||
"type": "ComfySwitchNode",
|
||||
"title": "Switch (CFG)",
|
||||
"pos": [100, 100],
|
||||
"size": [200, 80],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "value", "type": "FLOAT", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "FLOAT",
|
||||
"type": "FLOAT",
|
||||
"links": [257, 271, 276]
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "ComfySwitchNode" },
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 50],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"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 },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 276 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"type": "KSamplerAdvanced",
|
||||
"pos": [400, 350],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"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 },
|
||||
{ "name": "steps", "type": "INT", "link": null },
|
||||
{ "name": "cfg", "type": "FLOAT", "link": 271 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": { "Node name for S&R": "KSamplerAdvanced" },
|
||||
"widgets_values": [
|
||||
false,
|
||||
0,
|
||||
"randomize",
|
||||
20,
|
||||
8,
|
||||
"euler",
|
||||
"normal",
|
||||
0,
|
||||
10000,
|
||||
false
|
||||
]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 257,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 271,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 86,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
},
|
||||
{
|
||||
"id": 276,
|
||||
"origin_id": 120,
|
||||
"origin_slot": 0,
|
||||
"target_id": 85,
|
||||
"target_slot": 5,
|
||||
"type": "FLOAT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": { "scale": 1, "offset": [0, 0] },
|
||||
"frontendVersion": "1.43.2"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
{
|
||||
"id": "0cc04f4c-d744-462d-8638-4e5f5e3947e7",
|
||||
"revision": 0,
|
||||
"last_node_id": 19,
|
||||
"last_link_id": 24,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 14,
|
||||
"type": "CLIPLoader",
|
||||
"pos": [143.16716182216328, 290.16372862874033],
|
||||
"size": [270, 117.3125],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [21]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPLoader"
|
||||
},
|
||||
"widgets_values": [null, "stable_diffusion", "default"]
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"type": "PreviewImage",
|
||||
"pos": [1305.1455526601603, 472.17095792625025],
|
||||
"size": [225, 48],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 24
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"type": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"pos": [794.198171390827, 452.45433419677147],
|
||||
"size": [225, 172],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "renamed_clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 21
|
||||
},
|
||||
{
|
||||
"label": "renamed_seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 22
|
||||
},
|
||||
{
|
||||
"label": "renamed_vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 23
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [24]
|
||||
}
|
||||
],
|
||||
"title": "Input Test Subgraph",
|
||||
"properties": {
|
||||
"proxyWidgets": [
|
||||
["12", "seed"],
|
||||
["15", "text"]
|
||||
]
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [155.04048166054417, 773.3816055422594],
|
||||
"size": [270, 82],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [22]
|
||||
}
|
||||
],
|
||||
"title": "Seed Int",
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [0, "randomize"]
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"type": "VAELoader",
|
||||
"pos": [163.6043676075426, 543.9624492717659],
|
||||
"size": [270, 82.65625],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [23]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAELoader"
|
||||
},
|
||||
"widgets_values": ["pixel_space"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[21, 14, 0, 19, 0, "CLIP"],
|
||||
[22, 13, 0, 19, 1, "INT"],
|
||||
[23, 17, 0, 19, 2, "VAE"],
|
||||
[24, 19, 0, 18, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "314bbb9f-f1cc-456c-b14f-2ba92bd4a597",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 19,
|
||||
"lastLinkId": 24,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Input Test Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [
|
||||
358.8694807105848, 439.23932667242485, 123.14453125,
|
||||
99.99999999999994
|
||||
]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1408.5510580294986, 463.2512895126797, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "cfaad2dc-7758-412c-a4ac-dc2e6d37b28c",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [16],
|
||||
"localized_name": "clip",
|
||||
"label": "renamed_clip",
|
||||
"pos": [462.0140119605848, 459.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "2e4600ea-e1b1-42ca-b43a-e066fd080774",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"linkIds": [15],
|
||||
"localized_name": "seed",
|
||||
"label": "renamed_seed",
|
||||
"pos": [462.0140119605848, 479.23932667242485]
|
||||
},
|
||||
{
|
||||
"id": "86ed2da7-db02-454a-9362-70a3fa3e91bf",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"linkIds": [19],
|
||||
"localized_name": "vae",
|
||||
"label": "renamed_vae",
|
||||
"pos": [462.0140119605848, 499.23932667242485]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "8670d1a7-0d44-4688-b7dd-d4b423f1aee0",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [20],
|
||||
"localized_name": "IMAGE",
|
||||
"pos": [1428.5510580294986, 483.2512895126797]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 12,
|
||||
"type": "KSampler",
|
||||
"pos": [769.2424728654022, 512.726159169824],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 17
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "seed",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [18]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"type": "VAEDecode",
|
||||
"pos": [1208.5510580294986, 469.21581253470083],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "samples",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 18
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 19
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "IMAGE",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [20]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [681.4596332342014, 243.17567172890932],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 16
|
||||
},
|
||||
{
|
||||
"label": "renamed_from_sidepanel",
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [17]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 17,
|
||||
"origin_id": 15,
|
||||
"origin_slot": 0,
|
||||
"target_id": 12,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"origin_id": 12,
|
||||
"origin_slot": 0,
|
||||
"target_id": 16,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 15,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 12,
|
||||
"target_slot": 4,
|
||||
"type": "INT"
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 16,
|
||||
"target_slot": 1,
|
||||
"type": "VAE"
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"origin_id": 16,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "IMAGE"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6727925600199565,
|
||||
"offset": [446.69747171876463, 99.95078257277316]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -68,41 +68,6 @@ export class AppModeHelper {
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject linearData into the current graph and enter app mode.
|
||||
*
|
||||
* Serializes the graph, injects linearData with the given inputs and
|
||||
* auto-detected output node IDs, then reloads so the appModeStore
|
||||
* picks up the data via its activeWorkflow watcher.
|
||||
*
|
||||
* @param inputs - Widget selections as [nodeId, widgetName] tuples
|
||||
*/
|
||||
async enterAppModeWithInputs(inputs: [string, string][]) {
|
||||
await this.page.evaluate(async (inputTuples) => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: inputTuples, outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
}, inputs)
|
||||
await this.comfyPage.nextFrame()
|
||||
await this.toggleAppMode()
|
||||
}
|
||||
|
||||
/** The linear-mode widget list container (visible in app mode). */
|
||||
get linearWidgets(): Locator {
|
||||
return this.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -160,42 +125,4 @@ export class AppModeHelper {
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import type { Page, Route } from '@playwright/test'
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
private jobsRouteHandler: ((route: Route) => void) | null = null
|
||||
private queueJobs: Array<Record<string, unknown>> = []
|
||||
private historyJobs: Array<Record<string, unknown>> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -16,26 +13,6 @@ export class QueueHelper {
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
const baseTime = Date.now()
|
||||
this.queueJobs = [
|
||||
...Array.from({ length: running }, (_, i) => ({
|
||||
id: `running-${i}`,
|
||||
status: 'in_progress',
|
||||
create_time: baseTime - i * 1000,
|
||||
execution_start_time: baseTime - 5000 - i * 1000,
|
||||
execution_end_time: null,
|
||||
priority: i + 1
|
||||
})),
|
||||
...Array.from({ length: pending }, (_, i) => ({
|
||||
id: `pending-${i}`,
|
||||
status: 'pending',
|
||||
create_time: baseTime - (running + i) * 1000,
|
||||
execution_start_time: null,
|
||||
execution_end_time: null,
|
||||
priority: running + i + 1
|
||||
}))
|
||||
]
|
||||
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -58,7 +35,6 @@ export class QueueHelper {
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
await this.installJobsRoute()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,30 +43,6 @@ export class QueueHelper {
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const baseTime = Date.now()
|
||||
this.historyJobs = jobs.map((job, index) => {
|
||||
const completed = job.status === 'success'
|
||||
|
||||
return {
|
||||
id: job.promptId,
|
||||
status: completed ? 'completed' : 'failed',
|
||||
create_time: baseTime - index * 1000,
|
||||
execution_start_time: baseTime - 5000 - index * 1000,
|
||||
execution_end_time: baseTime - index * 1000,
|
||||
outputs_count: completed ? 1 : 0,
|
||||
workflow_id: `workflow-${job.promptId}`,
|
||||
preview_output: completed
|
||||
? {
|
||||
filename: `${job.promptId}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
@@ -109,44 +61,6 @@ export class QueueHelper {
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
await this.installJobsRoute()
|
||||
}
|
||||
|
||||
private async installJobsRoute() {
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses =
|
||||
url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.filter((status) => status.length > 0) ?? []
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0)
|
||||
const limit = Number(url.searchParams.get('limit') ?? 200)
|
||||
const jobs = [...this.queueJobs, ...this.historyJobs].filter(
|
||||
(job) => statuses.length === 0 || statuses.includes(String(job.status))
|
||||
)
|
||||
const paginatedJobs = jobs.slice(offset, offset + limit)
|
||||
|
||||
void route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: paginatedJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: jobs.length,
|
||||
has_more: offset + paginatedJobs.length < jobs.length
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route('**/api/jobs**', this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,9 +75,5 @@ export class QueueHelper {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute('**/api/jobs**', this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ export const TestIds = {
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
|
||||
@@ -281,14 +281,6 @@ export class NodeReference {
|
||||
getType(): Promise<string> {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async centerOnNode(): Promise<void> {
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found`)
|
||||
window.app!.canvas.centerOnNode(node)
|
||||
}, this.id)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { LGraph, Subgraph } from '../../src/lib/litegraph/src/litegraph'
|
||||
import { isSubgraph } from '../../src/utils/typeGuardUtil'
|
||||
|
||||
@@ -16,30 +14,3 @@ export function assertSubgraph(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the widget-input slot Y position and the node title height
|
||||
* for the promoted "text" input on the SubgraphNode.
|
||||
*
|
||||
* The slot Y should be at the widget row, not the header. A value near
|
||||
* zero or negative indicates the slot is positioned at the header (the bug).
|
||||
*/
|
||||
export function getTextSlotPosition(page: Page, nodeId: string) {
|
||||
return page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
if (!node) return null
|
||||
|
||||
const titleHeight = window.LiteGraph!.NODE_TITLE_HEIGHT
|
||||
|
||||
for (const input of node.inputs) {
|
||||
if (!input.widget || input.type !== 'STRING') continue
|
||||
return {
|
||||
hasPos: !!input.pos,
|
||||
posY: input.pos?.[1] ?? null,
|
||||
widgetName: input.widget.name,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
/**
|
||||
* Default workflow widget inputs as [nodeId, widgetName] tuples.
|
||||
* All widgets from the default graph are selected so the panel scrolls,
|
||||
* pushing the last widget's dropdown to the clipping boundary.
|
||||
*/
|
||||
const DEFAULT_INPUTS: [string, string][] = [
|
||||
['4', 'ckpt_name'],
|
||||
['6', 'text'],
|
||||
['7', 'text'],
|
||||
['5', 'width'],
|
||||
['5', 'height'],
|
||||
['5', 'batch_size'],
|
||||
['3', 'seed'],
|
||||
['3', 'steps'],
|
||||
['3', 'cfg'],
|
||||
['3', 'sampler_name'],
|
||||
['3', 'scheduler'],
|
||||
['3', 'denoise'],
|
||||
['9', 'filename_prefix']
|
||||
]
|
||||
|
||||
function isClippedByAnyAncestor(el: Element): boolean {
|
||||
const child = el.getBoundingClientRect()
|
||||
let parent = el.parentElement
|
||||
|
||||
while (parent) {
|
||||
const overflow = getComputedStyle(parent).overflow
|
||||
if (overflow !== 'visible') {
|
||||
const p = parent.getBoundingClientRect()
|
||||
if (
|
||||
child.top < p.top ||
|
||||
child.bottom > p.bottom ||
|
||||
child.left < p.left ||
|
||||
child.right > p.right
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
parent = parent.parentElement
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Add a node to the graph by type and return its ID. */
|
||||
async function addNode(page: Page, nodeType: string): Promise<string> {
|
||||
return page.evaluate((type) => {
|
||||
const node = window.app!.graph.add(
|
||||
window.LiteGraph!.createNode(type, undefined, {})
|
||||
)
|
||||
return String(node!.id)
|
||||
}, nodeType)
|
||||
}
|
||||
|
||||
test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Select dropdown is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const saveVideoId = await addNode(comfyPage.page, 'SaveVideo')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[saveVideoId, 'codec']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the codec widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the codec select (combobox role with aria-label from WidgetSelectDefault)
|
||||
const codecSelect = widgetList.getByRole('combobox', { name: 'codec' })
|
||||
await codecSelect.click()
|
||||
|
||||
const overlay = comfyPage.page.locator('.p-select-overlay').first()
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await overlay.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await overlay.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
|
||||
test('FormDropdown popup is not clipped in app mode panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const loadImageId = await addNode(comfyPage.page, 'LoadImage')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const inputs: [string, string][] = [
|
||||
...DEFAULT_INPUTS,
|
||||
[loadImageId, 'image']
|
||||
]
|
||||
await comfyPage.appMode.enterAppModeWithInputs(inputs)
|
||||
|
||||
await expect(comfyPage.appMode.linearWidgets).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// Scroll to bottom so the image widget is at the clipping edge
|
||||
const widgetList = comfyPage.appMode.linearWidgets
|
||||
await widgetList.evaluate((el) =>
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'instant' })
|
||||
)
|
||||
|
||||
// Click the FormDropdown trigger button for the image widget.
|
||||
// The button emits 'select-click' which toggles the Popover.
|
||||
const imageRow = widgetList.locator(
|
||||
'div:has(> div > span:text-is("image"))'
|
||||
)
|
||||
const dropdownButton = imageRow.locator('button:has(> span)').first()
|
||||
await dropdownButton.click()
|
||||
|
||||
// The unstyled PrimeVue Popover renders with role="dialog".
|
||||
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
|
||||
const popover = comfyPage.page
|
||||
.getByRole('dialog')
|
||||
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
|
||||
.first()
|
||||
await expect(popover).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const isInViewport = await popover.evaluate((el) => {
|
||||
const rect = el.getBoundingClientRect()
|
||||
return (
|
||||
rect.top >= 0 &&
|
||||
rect.left >= 0 &&
|
||||
rect.bottom <= window.innerHeight &&
|
||||
rect.right <= window.innerWidth
|
||||
)
|
||||
})
|
||||
expect(isInViewport).toBe(true)
|
||||
|
||||
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
|
||||
expect(isClipped).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -30,18 +30,10 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await page.mouse.click(seedPos.x, seedPos.y)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
@@ -56,15 +48,9 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
const saveImagePos = await saveImageRef.getPosition()
|
||||
// Click left edge — the right side is hidden by the builder panel
|
||||
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
@@ -94,15 +80,9 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -111,11 +91,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
await appMode.renameWidget(menu, 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
const workflowName = `${new Date().getTime()} builder-input`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
@@ -124,24 +104,6 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar via double-click', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
await appMode.goToInputs()
|
||||
|
||||
await appMode.renameBuilderInput('seed', 'Dblclick Seed')
|
||||
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input-dblclick`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
await expect(appMode.linearWidgets.getByText('Dblclick Seed')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Rename from builder preview sidebar', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -23,85 +23,4 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
|
||||
await comfyPage.workflow.loadWorkflow('links/bad_link')
|
||||
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(2)
|
||||
})
|
||||
|
||||
// Regression: duplicate links with shifted target_slot (widget-to-input
|
||||
// conversion) caused the wrong link to survive during deduplication.
|
||||
// Switch(CFG) node 120 connects to both KSamplerAdvanced 85 and 86 (2 links).
|
||||
// Links 257 and 276 shared the same tuple (origin=120 → target=85 slot=5).
|
||||
// Node 85's input.link was 276 (valid), but the bug kept 257 (stale) and
|
||||
// removed 276, breaking the cfg connection on KSamplerAdvanced 85.
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/10291
|
||||
test('Deduplicates links without breaking connections on slot-drift workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('links/duplicate_links_slot_drift')
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value
|
||||
if (!subgraph) return { error: 'No subgraph found' }
|
||||
|
||||
// Node 120 = Switch (CFG), connects to both KSamplerAdvanced 85 and 86
|
||||
const switchCfg = subgraph.getNodeById(120)
|
||||
const ksampler85 = subgraph.getNodeById(85)
|
||||
const ksampler86 = subgraph.getNodeById(86)
|
||||
if (!switchCfg || !ksampler85 || !ksampler86)
|
||||
return { error: 'Required nodes not found' }
|
||||
|
||||
// Find cfg inputs by name (slot indices shift due to widget-to-input)
|
||||
const cfgInput85 = ksampler85.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfgInput86 = ksampler86.inputs.find(
|
||||
(i: { name: string }) => i.name === 'cfg'
|
||||
)
|
||||
const cfg85Linked = cfgInput85?.link != null
|
||||
const cfg86Linked = cfgInput86?.link != null
|
||||
|
||||
// Verify the surviving links exist in the subgraph link map
|
||||
const cfg85LinkValid =
|
||||
cfg85Linked && subgraph.links.has(cfgInput85!.link!)
|
||||
const cfg86LinkValid =
|
||||
cfg86Linked && subgraph.links.has(cfgInput86!.link!)
|
||||
|
||||
// Switch(CFG) output should have exactly 2 links (one to each KSampler)
|
||||
const switchOutputLinkCount = switchCfg.outputs[0]?.links?.length ?? 0
|
||||
|
||||
// Count links from Switch(CFG) to node 85 cfg (should be 1, not 2)
|
||||
let cfgLinkToNode85Count = 0
|
||||
for (const link of subgraph.links.values()) {
|
||||
if (link.origin_id === 120 && link.target_id === 85)
|
||||
cfgLinkToNode85Count++
|
||||
}
|
||||
|
||||
return {
|
||||
cfg85Linked,
|
||||
cfg86Linked,
|
||||
cfg85LinkValid,
|
||||
cfg86LinkValid,
|
||||
cfg85LinkId: cfgInput85?.link ?? null,
|
||||
cfg86LinkId: cfgInput86?.link ?? null,
|
||||
switchOutputLinkIds: [...(switchCfg.outputs[0]?.links ?? [])],
|
||||
switchOutputLinkCount,
|
||||
cfgLinkToNode85Count
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Both KSamplerAdvanced nodes must have their cfg input connected
|
||||
expect(result.cfg85Linked).toBe(true)
|
||||
expect(result.cfg86Linked).toBe(true)
|
||||
// Links must exist in the subgraph link map
|
||||
expect(result.cfg85LinkValid).toBe(true)
|
||||
expect(result.cfg86LinkValid).toBe(true)
|
||||
// Switch(CFG) output has exactly 2 links (one per KSamplerAdvanced)
|
||||
expect(result.switchOutputLinkCount).toBe(2)
|
||||
// Only 1 link from Switch(CFG) to node 85 (duplicate removed)
|
||||
expect(result.cfgLinkToNode85Count).toBe(1)
|
||||
// Output link IDs must match the input link IDs (source/target integrity)
|
||||
expect(result.switchOutputLinkIds).toEqual(
|
||||
expect.arrayContaining([result.cfg85LinkId, result.cfg86LinkId])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
@@ -9,10 +11,58 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
async function enterAppMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
// LinearControls requires hasOutputs to be true. Serialize the current
|
||||
// graph, inject linearData with output node IDs, then reload so the
|
||||
// appModeStore picks up the outputs via its activeWorkflow watcher.
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
const graph = window.app!.graph
|
||||
if (!graph) return
|
||||
|
||||
const outputNodeIds = graph.nodes
|
||||
.filter(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)
|
||||
.map((n: { id: number | string }) => String(n.id))
|
||||
|
||||
// Serialize, inject linearData, and reload to sync stores
|
||||
const workflow = graph.serialize() as unknown as Record<string, unknown>
|
||||
const extra = (workflow.extra ?? {}) as Record<string, unknown>
|
||||
extra.linearData = { inputs: [], outputs: outputNodeIds }
|
||||
workflow.extra = extra
|
||||
await window.app!.loadGraphData(
|
||||
workflow as unknown as Parameters<
|
||||
NonNullable<typeof window.app>['loadGraphData']
|
||||
>[0]
|
||||
)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Toggle to app mode via the command which sets canvasStore.linearMode
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async function enterGraphMode(comfyPage: {
|
||||
page: Page
|
||||
nextFrame: () => Promise<void>
|
||||
}) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.extensionManager.command.execute('Comfy.ToggleLinear')
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
test('Displays linear controls when app mode active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -20,7 +70,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
@@ -28,7 +78,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
@@ -36,13 +86,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
await enterGraphMode(comfyPage)
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
@@ -51,7 +101,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Canvas not visible in app mode', async ({ comfyPage }) => {
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
await enterAppMode(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Assets Sidebar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.queue.mockHistory([
|
||||
{ promptId: 'history-asset-1', status: 'success' }
|
||||
])
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: /^Assets/ }).click()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('right-click menu can inspect an asset', async ({ comfyPage }) => {
|
||||
const assetCard = comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
|
||||
await assetCard.click({ button: 'right' })
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByLabel('Close')).toBeVisible()
|
||||
})
|
||||
|
||||
test('actions menu closes on scroll', async ({ comfyPage }) => {
|
||||
const assetCard = comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
|
||||
await assetCard.hover()
|
||||
await assetCard.getByRole('button', { name: /more options/i }).click()
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
await expect(menuPanel).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Job History Sidebar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.queue.mockQueueState()
|
||||
await comfyPage.queue.mockHistory([
|
||||
{ promptId: 'history-job-1', status: 'success' }
|
||||
])
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('right-click menu can inspect a completed job asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
|
||||
await jobRow.click({ button: 'right' })
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.job-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByLabel('Close')).toBeVisible()
|
||||
})
|
||||
|
||||
test('hover popover and actions menu stay clickable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
await jobRow.hover()
|
||||
|
||||
const popover = comfyPage.page.locator('.job-details-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
await popover.getByRole('button', { name: /^copy$/i }).click()
|
||||
|
||||
await jobRow.hover()
|
||||
const moreButton = jobRow.locator('.job-actions-menu-trigger')
|
||||
await expect(moreButton).toBeVisible()
|
||||
await moreButton.click()
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.job-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
const box = await menuPanel.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error('Job actions menu did not render a bounding box')
|
||||
}
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width / 2,
|
||||
box.y + Math.min(box.height / 2, 24)
|
||||
)
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await menuPanel.getByRole('menuitem', { name: /copy job id/i }).click()
|
||||
await expect(menuPanel).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getTextSlotPosition } from '../helpers/subgraphTestUtils'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget-input slot position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test('Promoted text widget slot is positioned at widget row, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
|
||||
// Render a few frames so arrange() runs
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const result = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.hasPos).toBe(true)
|
||||
|
||||
// The slot Y position should be well below the title area.
|
||||
// If it's near 0 or negative, the slot is stuck at the header (the bug).
|
||||
expect(result!.posY).toBeGreaterThan(result!.titleHeight)
|
||||
})
|
||||
|
||||
test('Slot position remains correct after renaming subgraph input label', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify initial position is correct
|
||||
const before = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(before).not.toBeNull()
|
||||
expect(before!.hasPos).toBe(true)
|
||||
expect(before!.posY).toBeGreaterThan(before!.titleHeight)
|
||||
|
||||
// Navigate into subgraph and rename the text input
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
const initialLabel = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph || !('inputNode' in graph)) return null
|
||||
const textInput = graph.inputs?.find(
|
||||
(i: { type: string }) => i.type === 'STRING'
|
||||
)
|
||||
return textInput?.label || textInput?.name || null
|
||||
})
|
||||
|
||||
if (!initialLabel)
|
||||
throw new Error('Could not find STRING input in subgraph')
|
||||
|
||||
await comfyPage.subgraph.rightClickInputSlot(initialLabel)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'visible' })
|
||||
await comfyPage.page.fill(dialog, '')
|
||||
await comfyPage.page.fill(dialog, 'my_custom_prompt')
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// Navigate back to parent graph
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Verify slot position is still at the widget row after rename
|
||||
const after = await getTextSlotPosition(comfyPage.page, '11')
|
||||
expect(after).not.toBeNull()
|
||||
expect(after!.hasPos).toBe(true)
|
||||
expect(after!.posY).toBeGreaterThan(after!.titleHeight)
|
||||
|
||||
// widget.name is the stable identity key — it does NOT change on rename.
|
||||
// The display label is on input.label, read via PromotedWidgetView.label.
|
||||
expect(after!.widgetName).not.toBe('my_custom_prompt')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
test.describe(
|
||||
'Subgraph promoted widget DOM position',
|
||||
{ tag: '@subgraph' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Promoted seed widget renders in node body, not header', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
// Convert KSampler (id 3) to subgraph — seed is auto-promoted.
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Enable Vue nodes now that the subgraph has been created
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
subgraphNodeId
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
// Wait for Vue nodes to render
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
await expect(nodeLocator).toBeVisible()
|
||||
|
||||
// The seed widget should be visible inside the node body
|
||||
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is inside the node body, not the header
|
||||
const headerBox = await nodeLocator
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
|
||||
// Widget top should be below the header bottom
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
const WORKFLOW = 'subgraphs/test-values-input-subgraph'
|
||||
const RENAMED_LABEL = 'my_seed'
|
||||
|
||||
/**
|
||||
* Regression test for subgraph input slot rename propagation.
|
||||
*
|
||||
* Renaming a SubgraphInput slot (e.g. "seed") inside the subgraph must
|
||||
* update the promoted widget label shown on the parent SubgraphNode and
|
||||
* keep the widget positioned in the node body (not the header).
|
||||
*
|
||||
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10195
|
||||
*/
|
||||
test.describe(
|
||||
'Subgraph input slot rename propagation',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
test('Renaming a subgraph input slot updates the widget label on the parent node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
// 1. Load workflow with subgraph containing a promoted seed widget input
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const sgNode = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNode).toBeVisible()
|
||||
|
||||
// 2. Verify the seed widget is visible on the parent node
|
||||
const seedWidget = sgNode.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Verify widget is in the node body, not the header
|
||||
const headerBox = await sgNode
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetBox = await seedWidget.boundingBox()
|
||||
expect(headerBox).not.toBeNull()
|
||||
expect(widgetBox).not.toBeNull()
|
||||
expect(widgetBox!.y).toBeGreaterThan(headerBox!.y + headerBox!.height)
|
||||
|
||||
// 3. Enter the subgraph and rename the seed slot.
|
||||
// The subgraph IO rename uses canvas.prompt() which requires the
|
||||
// litegraph context menu, so temporarily disable Vue nodes.
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const sgNodeRef = await comfyPage.nodeOps.getNodeRefById('19')
|
||||
await sgNodeRef.navigateIntoSubgraph()
|
||||
|
||||
// Find the seed SubgraphInput slot
|
||||
const seedSlotName = await page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph
|
||||
if (!graph) return null
|
||||
const inputs = (
|
||||
graph as { inputs?: Array<{ name: string; type: string }> }
|
||||
).inputs
|
||||
return inputs?.find((i) => i.name.includes('seed'))?.name ?? null
|
||||
})
|
||||
expect(seedSlotName).not.toBeNull()
|
||||
|
||||
// 4. Right-click the seed input slot and rename it
|
||||
await comfyPage.subgraph.rightClickInputSlot(seedSlotName!)
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const dialog = '.graphdialog input'
|
||||
await page.waitForSelector(dialog, { state: 'visible' })
|
||||
await page.fill(dialog, '')
|
||||
await page.fill(dialog, RENAMED_LABEL)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForSelector(dialog, { state: 'hidden' })
|
||||
|
||||
// 5. Navigate back to parent graph and re-enable Vue nodes
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
// 6. Verify the widget label updated to the renamed value
|
||||
const sgNodeAfter = comfyPage.vueNodes.getNodeLocator('19')
|
||||
await expect(sgNodeAfter).toBeVisible()
|
||||
|
||||
const updatedLabel = await page.evaluate(() => {
|
||||
const node = window.app!.canvas.graph!.getNodeById('19')
|
||||
if (!node) return null
|
||||
const w = node.widgets?.find((w: { name: string }) =>
|
||||
w.name.includes('seed')
|
||||
)
|
||||
return w?.label || w?.name || null
|
||||
})
|
||||
expect(updatedLabel).toBe(RENAMED_LABEL)
|
||||
|
||||
// 7. Verify the widget is still in the body, not the header
|
||||
const seedWidgetAfter = sgNodeAfter.getByLabel('seed', { exact: true })
|
||||
await expect(seedWidgetAfter).toBeVisible()
|
||||
|
||||
const headerAfter = await sgNodeAfter
|
||||
.locator('[data-testid^="node-header-"]')
|
||||
.boundingBox()
|
||||
const widgetAfter = await seedWidgetAfter.boundingBox()
|
||||
expect(headerAfter).not.toBeNull()
|
||||
expect(widgetAfter).not.toBeNull()
|
||||
expect(widgetAfter!.y).toBeGreaterThan(
|
||||
headerAfter!.y + headerAfter!.height
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -5,7 +5,6 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||
import { importX } from 'eslint-plugin-import-x'
|
||||
import oxlint from 'eslint-plugin-oxlint'
|
||||
import testingLibrary from 'eslint-plugin-testing-library'
|
||||
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
|
||||
@@ -272,20 +271,6 @@ export default defineConfig([
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.ts'],
|
||||
plugins: { 'testing-library': testingLibrary },
|
||||
rules: {
|
||||
'testing-library/prefer-screen-queries': 'error',
|
||||
'testing-library/no-container': 'error',
|
||||
'testing-library/no-node-access': 'error',
|
||||
'testing-library/no-wait-for-multiple-assertions': 'error',
|
||||
'testing-library/prefer-find-by': 'error',
|
||||
'testing-library/prefer-presence-queries': 'error',
|
||||
'testing-library/prefer-user-event': 'error',
|
||||
'testing-library/no-debugging-utils': 'error'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*.js'],
|
||||
languageOptions: {
|
||||
|
||||
@@ -6,6 +6,7 @@ const config: KnipConfig = {
|
||||
entry: [
|
||||
'{build,scripts}/**/*.{js,ts}',
|
||||
'src/assets/css/style.css',
|
||||
'src/main.ts',
|
||||
'src/scripts/ui/menu/index.ts',
|
||||
'src/types/index.ts',
|
||||
'src/storybook/mocks/**/*.ts'
|
||||
@@ -13,23 +14,25 @@ const config: KnipConfig = {
|
||||
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
|
||||
},
|
||||
'apps/desktop-ui': {
|
||||
entry: ['src/i18n.ts'],
|
||||
entry: ['src/main.ts', 'src/i18n.ts'],
|
||||
project: ['src/**/*.{js,ts,vue}']
|
||||
},
|
||||
'packages/tailwind-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/shared-frontend-utils': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
|
||||
},
|
||||
'packages/registry-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
},
|
||||
'packages/ingest-types': {
|
||||
project: ['src/**/*.{js,ts}']
|
||||
project: ['src/**/*.{js,ts}'],
|
||||
entry: ['src/index.ts']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreBinaries: ['python3', 'gh', 'generate'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
@@ -37,12 +40,19 @@ const config: KnipConfig = {
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
'@primeuix/utils',
|
||||
'@primevue/icons'
|
||||
'@primevue/icons',
|
||||
// Used by lucideStrokePlugin.js (CSS @plugin)
|
||||
'@iconify/utils'
|
||||
],
|
||||
ignore: [
|
||||
// Auto generated API types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'packages/ingest-types/src/types.gen.ts',
|
||||
'packages/ingest-types/src/zod.gen.ts',
|
||||
'packages/ingest-types/openapi-ts.config.ts',
|
||||
// Used by a custom node (that should move off of this)
|
||||
'src/scripts/ui/components/splitButton.ts',
|
||||
// Used by stacked PR (feat/glsl-live-preview)
|
||||
'src/renderer/glsl/useGLSLRenderer.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
@@ -50,8 +60,17 @@ const config: KnipConfig = {
|
||||
// Pending integration in stacked PR
|
||||
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
|
||||
// Agent review check config, not part of the build
|
||||
'.agents/checks/eslint.strict.config.js'
|
||||
'.agents/checks/eslint.strict.config.js',
|
||||
// Loaded via @plugin directive in CSS, not detected by knip
|
||||
'packages/design-system/src/css/lucideStrokePlugin.js'
|
||||
],
|
||||
compilers: {
|
||||
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
|
||||
css: (text: string) =>
|
||||
[...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)]
|
||||
.map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1'))
|
||||
.join('\n')
|
||||
},
|
||||
vite: {
|
||||
config: ['vite?(.*).config.mts']
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
|
||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||
|
||||
'./**/*.{ts,tsx,vue,mts,json,yaml}': (stagedFiles: string[]) => {
|
||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
|
||||
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
|
||||
|
||||
const hasBrowserTestsChanges = stagedFiles
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.3",
|
||||
"version": "1.43.2",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -135,9 +135,6 @@
|
||||
"@storybook/vue3": "catalog:",
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@testing-library/user-event": "catalog:",
|
||||
"@testing-library/vue": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -156,7 +153,6 @@
|
||||
"eslint-plugin-import-x": "catalog:",
|
||||
"eslint-plugin-oxlint": "catalog:",
|
||||
"eslint-plugin-storybook": "catalog:",
|
||||
"eslint-plugin-testing-library": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
@@ -181,7 +177,9 @@
|
||||
"storybook": "catalog:",
|
||||
"stylelint": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"tw-animate-css": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"unplugin-icons": "catalog:",
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
"dependencies": {
|
||||
"@iconify-json/lucide": "catalog:",
|
||||
"@iconify/tailwind4": "catalog:",
|
||||
"@iconify/utils": "catalog:",
|
||||
"tailwindcss-primeui": "catalog:",
|
||||
"tw-animate-css": "catalog:"
|
||||
"@iconify/utils": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
@plugin "./lucideStrokePlugin.js";
|
||||
|
||||
/* Safelist dynamic comfy icons for node library folders */
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
|
||||
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
|
||||
|
||||
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
|
||||
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="281" height="281" viewBox="0 0 281 281" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.069 0C217.427 0.000220786 280.138 62.7116 280.138 140.069V280.138H140.069C62.7116 280.138 0.000220844 217.427 0 140.069C0 62.7114 62.7114 0 140.069 0ZM74.961 66.6054C69.8263 64.8847 64.9385 69.7815 66.6687 74.913L123.558 243.619C125.929 250.65 136.321 248.945 136.321 241.524V135.823H241.329C248.756 135.823 250.453 125.416 243.41 123.056L74.961 66.6054Z" fill="#F8F8F8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 534 B |
@@ -631,10 +631,3 @@ export function isPreviewableMediaType(mediaType: MediaType): boolean {
|
||||
mediaType === '3D'
|
||||
)
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
1063
pnpm-lock.yaml
generated
1063
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -13,10 +13,10 @@ catalog:
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@nx/eslint': 22.6.1
|
||||
'@nx/playwright': 22.6.1
|
||||
'@nx/storybook': 22.6.1
|
||||
'@nx/vite': 22.6.1
|
||||
'@nx/eslint': 22.5.2
|
||||
'@nx/playwright': 22.5.2
|
||||
'@nx/storybook': 22.5.2
|
||||
'@nx/vite': 22.5.2
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
@@ -34,9 +34,6 @@ catalog:
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.2.0
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
'@testing-library/vue': ^8.1.0
|
||||
'@tiptap/core': ^2.27.2
|
||||
'@tiptap/extension-link': ^2.27.2
|
||||
'@tiptap/extension-table': ^2.27.2
|
||||
@@ -70,7 +67,6 @@ catalog:
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.55.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
eslint-plugin-vue: ^10.6.2
|
||||
fast-check: ^4.5.3
|
||||
@@ -83,11 +79,11 @@ catalog:
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^6.0.1
|
||||
knip: ^5.75.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.6.1
|
||||
nx: 22.5.2
|
||||
oxfmt: ^0.40.0
|
||||
oxlint: ^1.55.0
|
||||
oxlint-tsgolint: ^0.17.0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
useQueueSettingsStore,
|
||||
useQueueStore
|
||||
} from '@/stores/queueStore'
|
||||
import { render, screen } from '@/utils/test-utils'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
@@ -78,40 +78,38 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||
return new TaskItemImpl(job)
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
BatchCountEdit: BatchCountEditStub,
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' }
|
||||
}
|
||||
|
||||
function renderQueueButton() {
|
||||
function createWrapper() {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
|
||||
return render(ComfyQueueButton, {
|
||||
return mount(ComfyQueueButton, {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
directives: {
|
||||
tooltip: () => {}
|
||||
},
|
||||
stubs
|
||||
stubs: {
|
||||
BatchCountEdit: BatchCountEditStub,
|
||||
DropdownMenuRoot: { template: '<div><slot /></div>' },
|
||||
DropdownMenuTrigger: { template: '<div><slot /></div>' },
|
||||
DropdownMenuPortal: { template: '<div><slot /></div>' },
|
||||
DropdownMenuContent: { template: '<div><slot /></div>' },
|
||||
DropdownMenuItem: { template: '<div><slot /></div>' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyQueueButton', () => {
|
||||
it('renders the batch count control before the run button', () => {
|
||||
renderQueueButton()
|
||||
const controls = screen.getAllByTestId(/batch-count-edit|queue-button/)
|
||||
const wrapper = createWrapper()
|
||||
const controls = wrapper.get('.queue-button-group').element.children
|
||||
|
||||
expect(controls[0]).toHaveAttribute('data-testid', 'batch-count-edit')
|
||||
expect(controls[1]).toHaveAttribute('data-testid', 'queue-button')
|
||||
expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit')
|
||||
expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button')
|
||||
})
|
||||
|
||||
it('keeps the run instant presentation while idle even with active jobs', async () => {
|
||||
renderQueueButton()
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
@@ -119,27 +117,29 @@ describe('ComfyQueueButton', () => {
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
const queueButton = screen.getByTestId('queue-button')
|
||||
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||
|
||||
expect(queueButton).toHaveTextContent('Run (Instant)')
|
||||
expect(queueButton).toHaveAttribute('data-variant', 'primary')
|
||||
expect(queueButton.text()).toContain('Run (Instant)')
|
||||
expect(queueButton.attributes('data-variant')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('switches to stop presentation when instant mode is armed', async () => {
|
||||
renderQueueButton()
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-running'
|
||||
await nextTick()
|
||||
|
||||
const queueButton = screen.getByTestId('queue-button')
|
||||
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||
|
||||
expect(queueButton).toHaveTextContent('Stop Run (Instant)')
|
||||
expect(queueButton).toHaveAttribute('data-variant', 'destructive')
|
||||
expect(queueButton.text()).toContain('Stop Run (Instant)')
|
||||
expect(queueButton.attributes('data-variant')).toBe('destructive')
|
||||
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disarms instant mode without interrupting even when jobs are active', async () => {
|
||||
const { user } = renderQueueButton()
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const queueStore = useQueueStore()
|
||||
const commandStore = useCommandStore()
|
||||
@@ -148,26 +148,33 @@ describe('ComfyQueueButton', () => {
|
||||
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
|
||||
await nextTick()
|
||||
|
||||
await user!.click(screen.getByTestId('queue-button'))
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
const queueButton = screen.getByTestId('queue-button')
|
||||
expect(queueButton).toHaveTextContent('Run (Instant)')
|
||||
expect(queueButton).toHaveAttribute('data-variant', 'primary')
|
||||
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)')
|
||||
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
|
||||
expect(commandStore.execute).not.toHaveBeenCalled()
|
||||
|
||||
const queueButton = wrapper.get('[data-testid="queue-button"]')
|
||||
expect(queueSettingsStore.mode).toBe('instant-idle')
|
||||
expect(queueButton.text()).toContain('Run (Instant)')
|
||||
expect(queueButton.attributes('data-variant')).toBe('primary')
|
||||
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('activates instant running mode when queueing again', async () => {
|
||||
const { user } = renderQueueButton()
|
||||
const wrapper = createWrapper()
|
||||
const queueSettingsStore = useQueueSettingsStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
queueSettingsStore.mode = 'instant-idle'
|
||||
await nextTick()
|
||||
|
||||
await user!.click(screen.getByTestId('queue-button'))
|
||||
await wrapper.get('[data-testid="queue-button"]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(queueSettingsStore.mode).toBe('instant-running')
|
||||
|
||||
@@ -25,7 +25,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { DOMWidgetImpl } from '@/scripts/domWidget'
|
||||
import { renameWidget } from '@/utils/widgetUtil'
|
||||
import { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
|
||||
import { resolveNodeWidget } from '@/utils/litegraphUtil'
|
||||
@@ -63,7 +63,7 @@ const inputsWithState = computed(() =>
|
||||
widgetName,
|
||||
label: widget.label,
|
||||
subLabel: node.title,
|
||||
canRename: true
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -74,16 +74,6 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
|
||||
])
|
||||
)
|
||||
|
||||
function inlineRenameInput(
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
newLabel: string
|
||||
) {
|
||||
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
|
||||
if (!node || !widget) return
|
||||
renameWidget(widget, node, newLabel)
|
||||
}
|
||||
|
||||
function getHovered(
|
||||
e: MouseEvent
|
||||
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
|
||||
@@ -244,7 +234,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
canRename
|
||||
rename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
@@ -252,7 +242,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:can-rename="canRename"
|
||||
:rename
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
@@ -260,7 +250,6 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
@rename="inlineRenameInput(nodeId, widgetName, $event)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
@@ -45,7 +44,6 @@ const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
|
||||
@@ -2,43 +2,31 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const titleTooltip = ref<string | null>(null)
|
||||
const subTitleTooltip = ref<string | null>(null)
|
||||
const isEditing = ref(false)
|
||||
|
||||
function isTruncated(e: MouseEvent): boolean {
|
||||
const el = e.currentTarget as HTMLElement
|
||||
return el.scrollWidth > el.clientWidth
|
||||
}
|
||||
const { title, canRename, remove } = defineProps<{
|
||||
const { rename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
canRename?: boolean
|
||||
rename?: () => void
|
||||
remove?: () => void
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [newName: string]
|
||||
}>()
|
||||
|
||||
function onEditComplete(newName: string) {
|
||||
isEditing.value = false
|
||||
const trimmed = newName.trim()
|
||||
if (trimmed && trimmed !== title) emit('rename', trimmed)
|
||||
}
|
||||
|
||||
const entries = computed(() => {
|
||||
const items = []
|
||||
if (canRename)
|
||||
if (rename)
|
||||
items.push({
|
||||
label: t('g.rename'),
|
||||
command: () => setTimeout(() => (isEditing.value = true)),
|
||||
command: rename,
|
||||
icon: 'icon-[lucide--pencil]'
|
||||
})
|
||||
if (remove)
|
||||
@@ -55,24 +43,13 @@ const entries = computed(() => {
|
||||
class="my-2 flex items-center-safe gap-2 rounded-lg p-2"
|
||||
data-testid="builder-io-item"
|
||||
>
|
||||
<div class="drag-handle mr-auto flex w-full min-w-0 flex-col gap-1">
|
||||
<EditableText
|
||||
:model-value="title"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ class: 'p-1' }"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle h-5 text-sm',
|
||||
isEditing && 'relative -top-0.5 -left-1 -mt-px mb-px -ml-px',
|
||||
!isEditing && 'truncate'
|
||||
)
|
||||
"
|
||||
<div class="drag-handle mr-auto flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-sm"
|
||||
data-testid="builder-io-item-title"
|
||||
label-class="drag-handle"
|
||||
label-type="div"
|
||||
@dblclick="canRename && (isEditing = true)"
|
||||
@edit="onEditComplete"
|
||||
@cancel="isEditing = false"
|
||||
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
|
||||
v-text="title"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<NotificationPopup
|
||||
v-if="appModeStore.showVueNodeSwitchPopup"
|
||||
:title="$t('appBuilder.vueNodeSwitch.title')"
|
||||
show-close
|
||||
position="bottom-left"
|
||||
@close="dismiss"
|
||||
>
|
||||
{{ $t('appBuilder.vueNodeSwitch.content') }}
|
||||
|
||||
<template #footer-start>
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<input
|
||||
v-model="dontShowAgain"
|
||||
type="checkbox"
|
||||
class="accent-primary-background"
|
||||
/>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template #footer-end>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
class="font-normal"
|
||||
@click="dismiss"
|
||||
>
|
||||
{{ $t('appBuilder.vueNodeSwitch.dismiss') }}
|
||||
</Button>
|
||||
</template>
|
||||
</NotificationPopup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import NotificationPopup from '@/components/common/NotificationPopup.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const dontShowAgain = ref(false)
|
||||
|
||||
function dismiss() {
|
||||
if (dontShowAgain.value) {
|
||||
void settingStore.set('Comfy.AppBuilder.VueNodeSwitchDismissed', true)
|
||||
}
|
||||
appModeStore.showVueNodeSwitchPopup = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="editable-text">
|
||||
<component :is="labelType" v-if="!isEditing" :class="labelClass">
|
||||
<span v-if="!isEditing">
|
||||
{{ modelValue }}
|
||||
</component>
|
||||
</span>
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
@@ -35,15 +35,11 @@ import { nextTick, ref, watch } from 'vue'
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
inputAttrs = {},
|
||||
labelClass = '',
|
||||
labelType = 'span'
|
||||
inputAttrs = {}
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, string>
|
||||
labelClass?: string
|
||||
labelType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import NotificationPopup from './NotificationPopup.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: { g: { close: 'Close' } }
|
||||
}
|
||||
})
|
||||
|
||||
function mountPopup(
|
||||
props: ComponentProps<typeof NotificationPopup> = {
|
||||
title: 'Test'
|
||||
},
|
||||
slots: Record<string, string> = {}
|
||||
) {
|
||||
return mount(NotificationPopup, {
|
||||
global: { plugins: [i18n] },
|
||||
props,
|
||||
slots
|
||||
})
|
||||
}
|
||||
|
||||
describe('NotificationPopup', () => {
|
||||
it('renders title', () => {
|
||||
const wrapper = mountPopup({ title: 'Hello World' })
|
||||
expect(wrapper.text()).toContain('Hello World')
|
||||
})
|
||||
|
||||
it('has role="status" for accessibility', () => {
|
||||
const wrapper = mountPopup()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders subtitle when provided', () => {
|
||||
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
|
||||
expect(wrapper.text()).toContain('v1.2.3')
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
const wrapper = mountPopup({
|
||||
title: 'T',
|
||||
icon: 'icon-[lucide--rocket]'
|
||||
})
|
||||
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('emits close when close button clicked', async () => {
|
||||
const wrapper = mountPopup({ title: 'T', showClose: true })
|
||||
await wrapper.find('[aria-label="Close"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders default slot content', () => {
|
||||
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
|
||||
expect(wrapper.text()).toContain('Body text here')
|
||||
})
|
||||
|
||||
it('renders footer slots', () => {
|
||||
const wrapper = mountPopup(
|
||||
{ title: 'T' },
|
||||
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
|
||||
)
|
||||
expect(wrapper.text()).toContain('Left side')
|
||||
expect(wrapper.text()).toContain('Right side')
|
||||
})
|
||||
|
||||
it('positions bottom-right when specified', () => {
|
||||
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
|
||||
const root = wrapper.find('[role="status"]')
|
||||
expect(root.attributes('data-position')).toBe('bottom-right')
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:data-position="position"
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-auto absolute z-1000 flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-interface',
|
||||
position === 'bottom-left' && 'bottom-4 left-4',
|
||||
position === 'bottom-right' && 'right-4 bottom-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
v-if="icon"
|
||||
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
|
||||
>
|
||||
<i :class="cn('size-4 text-white', icon)" />
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="text-sm leading-[1.429] font-normal text-base-foreground">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div
|
||||
v-if="subtitle"
|
||||
class="text-sm leading-[1.21] font-normal text-muted-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="showClose"
|
||||
class="size-6 shrink-0 self-start"
|
||||
size="icon-sm"
|
||||
variant="muted-textonly"
|
||||
:aria-label="$t('g.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="min-h-0 flex-1 overflow-y-auto text-sm text-muted-foreground"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots['footer-start'] || $slots['footer-end']"
|
||||
class="flex items-center justify-between px-4 pb-4"
|
||||
>
|
||||
<div>
|
||||
<slot name="footer-start" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<slot name="footer-end" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
showClose = false,
|
||||
position = 'bottom-left'
|
||||
} = defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
showClose?: boolean
|
||||
position?: 'bottom-left' | 'bottom-right'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import WaveAudioPlayer from './WaveAudioPlayer.vue'
|
||||
|
||||
const meta: Meta<typeof WaveAudioPlayer> = {
|
||||
title: 'Components/Audio/WaveAudioPlayer',
|
||||
component: WaveAudioPlayer,
|
||||
tags: ['autodocs'],
|
||||
parameters: { layout: 'centered' }
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
barCount: 40,
|
||||
height: 32
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const BottomAligned: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
barCount: 40,
|
||||
height: 48,
|
||||
align: 'bottom'
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-80 rounded-lg bg-base-background p-4"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const Expanded: Story = {
|
||||
args: {
|
||||
src: '/assets/audio/sample.wav',
|
||||
variant: 'expanded',
|
||||
barCount: 80,
|
||||
height: 120
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
template:
|
||||
'<div class="w-[600px] rounded-2xl bg-base-background/80 p-8 backdrop-blur-sm"><story /></div>'
|
||||
})
|
||||
]
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
<template>
|
||||
<!-- Compact: [▶] [waveform] [time] -->
|
||||
<div
|
||||
v-if="variant === 'compact'"
|
||||
:class="
|
||||
cn('flex w-full gap-2', align === 'center' ? 'items-center' : 'items-end')
|
||||
"
|
||||
@pointerdown.stop
|
||||
@click.stop
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
||||
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
||||
:loading="loading"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="ml-0.5 icon-[lucide--play] size-3 text-base-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--pause] size-3 text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
:ref="(el) => (waveformRef = el as HTMLElement)"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer gap-px',
|
||||
align === 'center' ? 'items-center' : 'items-end'
|
||||
)
|
||||
"
|
||||
:style="{ height: height + 'px' }"
|
||||
@click="handleWaveformClick"
|
||||
>
|
||||
<div
|
||||
v-for="(bar, index) in bars"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-0.5 flex-1 rounded-full',
|
||||
loading
|
||||
? 'bg-muted-foreground/20'
|
||||
: index <= playedBarIndex
|
||||
? 'bg-base-foreground'
|
||||
: 'bg-muted-foreground/40'
|
||||
)
|
||||
"
|
||||
:style="{ height: (bar.height / 100) * height + 'px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{{ formattedCurrentTime }} / {{ formattedDuration }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded: waveform / progress bar + times / transport -->
|
||||
<div v-else class="flex w-full flex-col gap-4" @pointerdown.stop @click.stop>
|
||||
<div
|
||||
class="flex w-full items-center gap-0.5"
|
||||
:style="{ height: height + 'px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(bar, index) in bars"
|
||||
:key="index"
|
||||
:class="
|
||||
cn(
|
||||
'min-h-0.5 flex-1 rounded-full',
|
||||
loading ? 'bg-muted-foreground/20' : 'bg-base-foreground'
|
||||
)
|
||||
"
|
||||
:style="{ height: (bar.height / 100) * height + 'px' }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
ref="progressRef"
|
||||
class="relative h-1 w-full cursor-pointer rounded-full bg-muted-foreground/20"
|
||||
@click="handleProgressClick"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full rounded-full bg-base-foreground"
|
||||
:style="{ width: progressRatio + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
<span>{{ formattedCurrentTime }}</span>
|
||||
<span>{{ formattedDuration }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-20" />
|
||||
|
||||
<div class="flex flex-1 items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-full"
|
||||
:aria-label="$t('g.skipToStart')"
|
||||
:disabled="loading"
|
||||
@click="seekToStart"
|
||||
>
|
||||
<i class="icon-[lucide--skip-back] size-4 text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-10 rounded-full bg-muted-foreground/15 hover:bg-muted-foreground/25"
|
||||
:aria-label="isPlaying ? $t('g.pause') : $t('g.play')"
|
||||
:loading="loading"
|
||||
@click="togglePlayPause"
|
||||
>
|
||||
<i
|
||||
v-if="!isPlaying"
|
||||
class="ml-0.5 icon-[lucide--play] size-5 text-base-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--pause] size-5 text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 rounded-full"
|
||||
:aria-label="$t('g.skipToEnd')"
|
||||
:disabled="loading"
|
||||
@click="seekToEnd"
|
||||
>
|
||||
<i class="icon-[lucide--skip-forward] size-4 text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex w-20 items-center gap-1">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 rounded-full"
|
||||
:aria-label="$t('g.volume')"
|
||||
:disabled="loading"
|
||||
@click="toggleMute"
|
||||
>
|
||||
<i :class="cn(volumeIcon, 'size-4 text-base-foreground')" />
|
||||
</Button>
|
||||
<Slider
|
||||
:model-value="[volume * 100]"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="1"
|
||||
class="flex-1"
|
||||
@update:model-value="(v) => (volume = (v?.[0] ?? 100) / 100)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
||||
:src="audioSrc"
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, toRef } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import { useWaveAudioPlayer } from '@/composables/useWaveAudioPlayer'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
barCount = 40,
|
||||
height = 32,
|
||||
align = 'center',
|
||||
variant = 'compact'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
barCount?: number
|
||||
height?: number
|
||||
align?: 'center' | 'bottom'
|
||||
variant?: 'compact' | 'expanded'
|
||||
}>()
|
||||
|
||||
const progressRef = ref<HTMLElement>()
|
||||
|
||||
const {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying,
|
||||
playedBarIndex,
|
||||
progressRatio,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
togglePlayPause,
|
||||
seekToStart,
|
||||
seekToEnd,
|
||||
volume,
|
||||
volumeIcon,
|
||||
toggleMute,
|
||||
seekToRatio,
|
||||
handleWaveformClick
|
||||
} = useWaveAudioPlayer({
|
||||
src: toRef(() => src),
|
||||
barCount
|
||||
})
|
||||
|
||||
function handleProgressClick(event: MouseEvent) {
|
||||
if (!progressRef.value) return
|
||||
const rect = progressRef.value.getBoundingClientRect()
|
||||
seekToRatio((event.clientX - rect.left) / rect.width)
|
||||
}
|
||||
</script>
|
||||
@@ -186,13 +186,13 @@ const toggleState = () => {
|
||||
}
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
if (await authActions.signInWithGoogle({ isNewUser: !isSignIn.value })) {
|
||||
if (await authActions.signInWithGoogle()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
const signInWithGithub = async () => {
|
||||
if (await authActions.signInWithGithub({ isNewUser: !isSignIn.value })) {
|
||||
if (await authActions.signInWithGithub()) {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
canvasStore.canvas to be initialized. -->
|
||||
@@ -129,7 +128,6 @@ import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitter
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import VueNodeSwitchPopup from '@/components/builder/VueNodeSwitchPopup.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
@@ -195,7 +193,6 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -456,9 +453,8 @@ useEventListener(
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
|
||||
// Set up invite loader during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
@@ -578,18 +574,6 @@ onMounted(async () => {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[GraphCanvas] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
await import('@/platform/updates/common/releaseStore')
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
<!-- Release Notification Toast positioned within canvas area -->
|
||||
<Teleport to="#graph-canvas-container">
|
||||
<ReleaseNotificationToast
|
||||
:position="sidebarLocation === 'right' ? 'bottom-right' : 'bottom-left'"
|
||||
:class="{
|
||||
'sidebar-left': sidebarLocation === 'left',
|
||||
'sidebar-right': sidebarLocation === 'right',
|
||||
'small-sidebar': isSmall
|
||||
}"
|
||||
/>
|
||||
</Teleport>
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ const meta: Meta<typeof Loader> = {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg'],
|
||||
description: 'Spinner size: sm (16px), md (32px), lg (48px)'
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['loader', 'loader-circle'],
|
||||
description: 'The type of loader displayed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ getJobMenuEntries: () => [] })
|
||||
useJobMenu: () => ({ jobMenuEntries: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
@@ -30,6 +30,10 @@ const JobAssetsListStub = {
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
}
|
||||
|
||||
const JobContextMenuStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const createJob = (): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
@@ -52,7 +56,8 @@ const mountComponent = () =>
|
||||
stubs: {
|
||||
QueueOverlayHeader: QueueOverlayHeaderStub,
|
||||
JobFiltersBar: JobFiltersBarStub,
|
||||
JobAssetsList: JobAssetsListStub
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
JobContextMenu: JobContextMenuStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,28 +23,36 @@
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:get-menu-entries="getJobMenuEntries"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
@delete-item="onDeleteItemEvent"
|
||||
@menu-action="onJobMenuAction"
|
||||
@view-item="$emit('viewItem', $event)"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
JobSortMode,
|
||||
JobTab
|
||||
} from '@/composables/queue/useJobList'
|
||||
import type { MenuActionEntry } from '@/types/menuTypes'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
import JobAssetsList from './job/JobAssetsList.vue'
|
||||
import JobFiltersBar from './job/JobFiltersBar.vue'
|
||||
|
||||
@@ -70,9 +78,14 @@ const emit = defineEmits<{
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { getJobMenuEntries } = useJobMenu((item) => emit('viewItem', item))
|
||||
const { jobMenuEntries } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
(item) => emit('viewItem', item)
|
||||
)
|
||||
|
||||
const onCancelItemEvent = (item: JobListItem) => {
|
||||
emit('cancelItem', item)
|
||||
@@ -82,9 +95,14 @@ const onDeleteItemEvent = (item: JobListItem) => {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(
|
||||
async (entry: MenuActionEntry) => {
|
||||
if (entry.onClick) await entry.onClick()
|
||||
}
|
||||
)
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,20 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
type JobPreviewOutput = NonNullable<
|
||||
NonNullable<JobListItem['taskRef']>['previewOutput']
|
||||
>
|
||||
|
||||
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
|
||||
default: {
|
||||
name: 'JobDetailsPopover',
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
@@ -41,32 +32,43 @@ vi.mock('vue-i18n', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const createPreviewOutput = (
|
||||
const createResultItem = (
|
||||
filename: string,
|
||||
mediaType: string = 'images'
|
||||
): JobPreviewOutput =>
|
||||
({
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename,
|
||||
mediaType,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video',
|
||||
isAudio: mediaType === 'audio',
|
||||
is3D: mediaType === 'model',
|
||||
url: `/api/view/${filename}`
|
||||
}) as JobPreviewOutput
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType
|
||||
})
|
||||
Object.defineProperty(item, 'url', {
|
||||
get: () => `/api/view/${filename}`
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: JobPreviewOutput): JobListItem['taskRef'] =>
|
||||
({
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: preview
|
||||
}) as JobListItem['taskRef']
|
||||
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
const job: ApiJobListItem = {
|
||||
id: `task-${Math.random().toString(36).slice(2)}`,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
workflow_id: 'workflow-1',
|
||||
priority: 0
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
|
||||
taskRef: createTaskRef(createResultItem('job-1.png')),
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -80,10 +82,7 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
]
|
||||
|
||||
return mount(JobAssetsList, {
|
||||
props: {
|
||||
displayedJobGroups,
|
||||
getMenuEntries: () => []
|
||||
},
|
||||
props: { displayedJobGroups },
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
@@ -148,7 +147,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
@@ -165,7 +164,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
@@ -180,7 +179,7 @@ describe('JobAssetsList', () => {
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
|
||||
@@ -15,97 +15,64 @@
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
>
|
||||
<ContextMenu :modal="false">
|
||||
<ContextMenuTrigger as-child>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(job)"
|
||||
>
|
||||
<template v-if="shouldShowActionsMenu(job.id)" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
:open="openActionsJobId === job.id"
|
||||
@update:open="onActionsMenuOpenChange(job.id, $event)"
|
||||
>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
class="job-actions-menu-trigger"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
close-on-scroll
|
||||
class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
:side-offset="4"
|
||||
:collision-padding="8"
|
||||
>
|
||||
<JobMenuItems
|
||||
:entries="getMenuEntries(job)"
|
||||
surface="dropdown"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
close-on-scroll
|
||||
class="z-1700 bg-transparent p-0 font-inter shadow-lg"
|
||||
>
|
||||
<JobMenuItems
|
||||
:entries="getMenuEntries(job)"
|
||||
surface="context"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +80,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeDetails && popoverPosition"
|
||||
class="job-details-popover fixed z-1700"
|
||||
class="job-details-popover fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
@@ -136,36 +103,24 @@ import { nextTick, ref } from 'vue'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
|
||||
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
|
||||
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
|
||||
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
|
||||
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
|
||||
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
import JobMenuItems from './JobMenuItems.vue'
|
||||
|
||||
const { displayedJobGroups, getMenuEntries } = defineProps<{
|
||||
displayedJobGroups: JobGroup[]
|
||||
getMenuEntries: (item: JobListItem) => MenuEntry[]
|
||||
}>()
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu-action', entry: MenuActionEntry): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const openActionsJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const {
|
||||
@@ -197,7 +152,7 @@ function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
scheduleDetailsHide(jobId)
|
||||
scheduleDetailsHide(jobId, clearPopoverAnchor)
|
||||
}
|
||||
|
||||
function onJobEnter(job: JobListItem, event: MouseEvent) {
|
||||
@@ -233,26 +188,6 @@ function isFailedDeletable(job: JobListItem) {
|
||||
return job.showClear !== false && job.state === 'failed'
|
||||
}
|
||||
|
||||
function shouldShowActionsMenu(jobId: string) {
|
||||
return hoveredJobId.value === jobId || openActionsJobId.value === jobId
|
||||
}
|
||||
|
||||
function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
openActionsJobId.value = jobId
|
||||
return
|
||||
}
|
||||
|
||||
if (openActionsJobId.value === jobId) {
|
||||
openActionsJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuAction(entry: MenuActionEntry) {
|
||||
resetActiveDetails()
|
||||
emit('menu-action', entry)
|
||||
}
|
||||
|
||||
function getPreviewOutput(job: JobListItem) {
|
||||
return job.taskRef?.previewOutput
|
||||
}
|
||||
@@ -300,7 +235,7 @@ function onPopoverEnter() {
|
||||
}
|
||||
|
||||
function onPopoverLeave() {
|
||||
scheduleDetailsHide(activeDetails.value?.jobId)
|
||||
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
|
||||
195
src/components/queue/job/JobContextMenu.test.ts
Normal file
195
src/components/queue/job/JobContextMenu.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const popoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
emits: ['show', 'hide'],
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
container: null as HTMLElement | null,
|
||||
eventTarget: null as EventTarget | null,
|
||||
target: null as EventTarget | null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
updated() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
methods: {
|
||||
toggle(event: Event, target?: EventTarget | null) {
|
||||
if (this.visible) {
|
||||
this.hide()
|
||||
return
|
||||
}
|
||||
|
||||
this.show(event, target)
|
||||
},
|
||||
show(event: Event, target?: EventTarget | null) {
|
||||
this.visible = true
|
||||
this.eventTarget = event.currentTarget
|
||||
this.target = target ?? event.currentTarget
|
||||
this.$emit('show')
|
||||
},
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.$emit('hide')
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div v-if="visible" ref="container" class="popover-stub">
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const buttonStub = {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="button-stub"
|
||||
:data-disabled="String(disabled)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const createEntries = (): MenuEntry[] => [
|
||||
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
},
|
||||
{ kind: 'divider', key: 'divider-1' }
|
||||
]
|
||||
|
||||
const mountComponent = (entries: MenuEntry[]) =>
|
||||
mount(JobContextMenu, {
|
||||
props: { entries },
|
||||
global: {
|
||||
stubs: {
|
||||
Popover: popoverStub,
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
|
||||
({
|
||||
type,
|
||||
currentTarget,
|
||||
target: currentTarget
|
||||
}) as Event
|
||||
|
||||
const openMenu = async (
|
||||
wrapper: ReturnType<typeof mountComponent>,
|
||||
type: string = 'click'
|
||||
) => {
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
await wrapper.vm.open(createTriggerEvent(type, trigger))
|
||||
await nextTick()
|
||||
return trigger
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('JobContextMenu', () => {
|
||||
it('passes disabled state to action buttons', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
await openMenu(wrapper)
|
||||
|
||||
const buttons = wrapper.findAll('.button-stub')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].attributes('data-disabled')).toBe('false')
|
||||
expect(buttons[1].attributes('data-disabled')).toBe('true')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits action for enabled entries', async () => {
|
||||
const entries = createEntries()
|
||||
const wrapper = mountComponent(entries)
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.findAll('.button-stub')[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit action for disabled entries', async () => {
|
||||
const wrapper = mountComponent([
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
}
|
||||
])
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.get('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides on pointerdown outside the popover', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(trigger, outside)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
118
src/components/queue/job/JobContextMenu.vue
Normal file
118
src/components/queue/job/JobContextMenu.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<Popover
|
||||
ref="jobItemPopoverRef"
|
||||
:dismissable="false"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
@show="isVisible = true"
|
||||
@hide="onHide"
|
||||
>
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<template v-for="entry in entries" :key="entry.key">
|
||||
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
|
||||
<div class="h-px bg-interface-stroke" />
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
class="w-full justify-start bg-transparent"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:aria-label="entry.label"
|
||||
:disabled="entry.disabled"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
<span>{{ entry.label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'action', entry: MenuEntry): void
|
||||
}>()
|
||||
|
||||
type PopoverHandle = {
|
||||
hide: () => void
|
||||
show: (event: Event, target?: EventTarget | null) => void
|
||||
}
|
||||
|
||||
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isVisible = ref(false)
|
||||
const openedByClick = ref(false)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: isVisible,
|
||||
getOverlayEl: () => contentRef.value,
|
||||
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
|
||||
onDismiss: hide
|
||||
})
|
||||
|
||||
async function open(event: Event) {
|
||||
const trigger =
|
||||
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
||||
const isSameClickTrigger =
|
||||
event.type === 'click' && trigger === triggerRef.value && isVisible.value
|
||||
|
||||
if (isSameClickTrigger) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
openedByClick.value = event.type === 'click'
|
||||
triggerRef.value = trigger
|
||||
|
||||
if (isVisible.value) {
|
||||
hide()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
jobItemPopoverRef.value?.show(event, trigger)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
jobItemPopoverRef.value?.hide()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
isVisible.value = false
|
||||
openedByClick.value = false
|
||||
}
|
||||
|
||||
function onEntry(entry: MenuEntry) {
|
||||
if (entry.kind === 'divider' || entry.disabled) return
|
||||
emit('action', entry)
|
||||
}
|
||||
|
||||
defineExpose({ open, hide })
|
||||
</script>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ContextMenuItem from '@/components/ui/context-menu/ContextMenuItem.vue'
|
||||
import ContextMenuSeparator from '@/components/ui/context-menu/ContextMenuSeparator.vue'
|
||||
import DropdownMenuItem from '@/components/ui/dropdown-menu/DropdownMenuItem.vue'
|
||||
import DropdownMenuSeparator from '@/components/ui/dropdown-menu/DropdownMenuSeparator.vue'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { entries, surface } = defineProps<{
|
||||
entries: MenuEntry[]
|
||||
surface: 'context' | 'dropdown'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [entry: MenuActionEntry]
|
||||
}>()
|
||||
|
||||
function isActionEntry(entry: MenuEntry): entry is MenuActionEntry {
|
||||
return entry.kind !== 'divider'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="job-menu-panel flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<template v-for="entry in entries" :key="entry.key">
|
||||
<ContextMenuSeparator
|
||||
v-if="surface === 'context' && entry.kind === 'divider'"
|
||||
class="mx-2 my-1 h-px bg-interface-stroke"
|
||||
/>
|
||||
<DropdownMenuSeparator
|
||||
v-else-if="surface === 'dropdown' && entry.kind === 'divider'"
|
||||
class="mx-2 my-1 h-px bg-interface-stroke"
|
||||
/>
|
||||
<ContextMenuItem
|
||||
v-else-if="surface === 'context' && isActionEntry(entry)"
|
||||
as-child
|
||||
:disabled="entry.disabled"
|
||||
:text-value="entry.label"
|
||||
@select="emit('action', entry)"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover"
|
||||
>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="
|
||||
cn(
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span>{{ entry.label }}</span>
|
||||
</Button>
|
||||
</ContextMenuItem>
|
||||
<DropdownMenuItem
|
||||
v-else-if="isActionEntry(entry)"
|
||||
as-child
|
||||
:disabled="entry.disabled"
|
||||
:text-value="entry.label"
|
||||
@select="emit('action', entry)"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover"
|
||||
>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="
|
||||
cn(
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span>{{ entry.label }}</span>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,7 +9,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="!isPreviewVisible && showDetails && popoverPosition"
|
||||
class="fixed z-1700"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
@@ -23,7 +23,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isPreviewVisible && canShowPreview && popoverPosition"
|
||||
class="fixed z-1700"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
|
||||
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useAdvancedWidgetOverridesStore } from '@/stores/workspace/advancedWidgetOverridesStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
|
||||
import { computedSectionDataList, searchWidgetsAndNodes } from '../shared'
|
||||
@@ -22,6 +23,7 @@ const { t } = useI18n()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const advancedOverridesStore = useAdvancedWidgetOverridesStore()
|
||||
const { searchQuery } = storeToRefs(rightSidePanelStore)
|
||||
|
||||
const { widgetsSectionDataList, includesAdvanced } = computedSectionDataList(
|
||||
@@ -38,7 +40,8 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
|
||||
const advancedWidgets = widgets
|
||||
.filter(
|
||||
(w) =>
|
||||
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
|
||||
!(w.options?.canvasOnly || w.options?.hidden) &&
|
||||
advancedOverridesStore.getAdvancedState(node, w)
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
return { widgets: advancedWidgets, node }
|
||||
@@ -116,6 +119,14 @@ const advancedLabel = computed(() => {
|
||||
? t('rightSidePanel.advancedInputs')
|
||||
: undefined // SectionWidgets display node titles by default
|
||||
})
|
||||
|
||||
const showAdvancedSection = computed(
|
||||
() =>
|
||||
advancedWidgetsSectionDataList.value.length > 0 &&
|
||||
!isSearching.value &&
|
||||
!isMultipleNodesSelected.value &&
|
||||
!mustShowNodeTitle
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -165,7 +176,7 @@ const advancedLabel = computed(() => {
|
||||
@update:collapse="setSectionCollapsed(String(node.id), $event)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<template v-if="advancedWidgetsSectionDataList.length > 0 && !isSearching">
|
||||
<template v-if="showAdvancedSection">
|
||||
<SectionWidgets
|
||||
v-for="{ widgets, node } in advancedWidgetsSectionDataList"
|
||||
:key="`advanced-${node.id}`"
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useAdvancedWidgetOverridesStore } from '@/stores/workspace/advancedWidgetOverridesStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
@@ -41,7 +43,9 @@ const emit = defineEmits<{
|
||||
const label = defineModel<string>('label', { required: true })
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const advancedOverridesStore = useAdvancedWidgetOverridesStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const promotionStore = usePromotionStore()
|
||||
const { t } = useI18n()
|
||||
@@ -54,6 +58,18 @@ const isFavorited = computed(() =>
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||
)
|
||||
|
||||
const isEffectivelyAdvanced = computed(() =>
|
||||
advancedOverridesStore.getAdvancedState(node, widget)
|
||||
)
|
||||
|
||||
const backendDefaultAdvanced = computed(() => !!widget.options?.advanced)
|
||||
|
||||
const showAdvancedToggle = computed(
|
||||
() =>
|
||||
!hasParents.value &&
|
||||
!settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
|
||||
)
|
||||
|
||||
const inputSpec = computed(() =>
|
||||
nodeDefStore.getInputSpecForWidget(node, widget.name)
|
||||
)
|
||||
@@ -105,6 +121,17 @@ function handleToggleFavorite() {
|
||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||
}
|
||||
|
||||
function handleToggleAdvanced() {
|
||||
const newEffective = !isEffectivelyAdvanced.value
|
||||
if (newEffective === backendDefaultAdvanced.value) {
|
||||
advancedOverridesStore.clearOverride(node, widget.name)
|
||||
} else {
|
||||
advancedOverridesStore.setAdvanced(node, widget.name, newEffective)
|
||||
}
|
||||
|
||||
node.expandToFitContent()
|
||||
}
|
||||
|
||||
function handleResetToDefault() {
|
||||
if (!hasDefault.value) return
|
||||
emit('resetToDefault', defaultValue.value)
|
||||
@@ -176,6 +203,28 @@ function handleResetToDefault() {
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="showAdvancedToggle"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
@click="
|
||||
() => {
|
||||
handleToggleAdvanced()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isEffectivelyAdvanced">
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--eye-off] size-4" />
|
||||
<span>{{ t('rightSidePanel.hideInput') }}</span>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasDefault"
|
||||
variant="textonly"
|
||||
|
||||
@@ -7,8 +7,9 @@ import type { Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAdvancedWidgetOverridesStore } from '@/stores/workspace/advancedWidgetOverridesStore'
|
||||
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
export const GetNodeParentGroupKey: InjectionKey<
|
||||
(node: LGraphNode) => LGraphGroup | null
|
||||
@@ -252,6 +253,7 @@ function repeatItems<T>(items: T[]): T[] {
|
||||
|
||||
export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
||||
const settingStore = useSettingStore()
|
||||
const advancedOverridesStore = useAdvancedWidgetOverridesStore()
|
||||
|
||||
const includesAdvanced = computed(() =>
|
||||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
|
||||
@@ -266,7 +268,8 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
|
||||
!(
|
||||
w.options?.canvasOnly ||
|
||||
w.options?.hidden ||
|
||||
(w.options?.advanced && !includesAdvanced.value)
|
||||
(advancedOverridesStore.getAdvancedState(node, w) &&
|
||||
!includesAdvanced.value)
|
||||
)
|
||||
)
|
||||
.map((widget) => ({ node, widget }))
|
||||
|
||||
@@ -9,56 +9,15 @@
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
v-if="assetsStore.isAssetDeleting(item.asset.id)"
|
||||
:asset="item.asset"
|
||||
:selected="isSelected(item.asset.id)"
|
||||
:show-output-count="showOutputCount(item.asset)"
|
||||
:output-count="getOutputCount(item.asset)"
|
||||
:show-delete-button="showDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@click="emit('select-asset', item.asset)"
|
||||
@context-menu="emit('context-menu', $event, item.asset)"
|
||||
@zoom="emit('zoom', item.asset)"
|
||||
@asset-deleted="emit('asset-deleted')"
|
||||
@bulk-download="emit('bulk-download', $event)"
|
||||
@bulk-delete="emit('bulk-delete', $event)"
|
||||
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
|
||||
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
|
||||
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
|
||||
@output-count-click="emit('output-count-click', item.asset)"
|
||||
/>
|
||||
<ContextMenu v-else :modal="false">
|
||||
<ContextMenuTrigger as-child>
|
||||
<MediaAssetCard
|
||||
:asset="item.asset"
|
||||
:selected="isSelected(item.asset.id)"
|
||||
:show-output-count="showOutputCount(item.asset)"
|
||||
:output-count="getOutputCount(item.asset)"
|
||||
:show-delete-button="showDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@click="emit('select-asset', item.asset)"
|
||||
@zoom="emit('zoom', item.asset)"
|
||||
@asset-deleted="emit('asset-deleted')"
|
||||
@bulk-download="emit('bulk-download', $event)"
|
||||
@bulk-delete="emit('bulk-delete', $event)"
|
||||
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
|
||||
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
|
||||
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
|
||||
@output-count-click="emit('output-count-click', item.asset)"
|
||||
/>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
close-on-scroll
|
||||
class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
>
|
||||
<MediaAssetMenuItems
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
surface="context"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
@@ -68,60 +27,24 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
|
||||
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
|
||||
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import MediaAssetMenuItems from '@/platform/assets/components/MediaAssetMenuItems.vue'
|
||||
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
showOutputCount,
|
||||
getOutputCount,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'zoom', asset: AssetItem): void
|
||||
(e: 'asset-deleted'): void
|
||||
(e: 'bulk-download', assets: AssetItem[]): void
|
||||
(e: 'bulk-delete', assets: AssetItem[]): void
|
||||
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-open-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-export-workflow', assets: AssetItem[]): void
|
||||
(e: 'output-count-click', asset: AssetItem): void
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
const { getMenuEntries } = useMediaAssetMenu({
|
||||
inspectAsset: (asset) => emit('zoom', asset),
|
||||
assetDeleted: () => emit('asset-deleted'),
|
||||
bulkDownload: (assets) => emit('bulk-download', assets),
|
||||
bulkDelete: (assets) => emit('bulk-delete', assets),
|
||||
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
|
||||
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
|
||||
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
|
||||
})
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
const assetItems = computed<AssetGridItem[]>(() =>
|
||||
@@ -131,21 +54,6 @@ const assetItems = computed<AssetGridItem[]>(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
|
||||
return getMenuEntries({
|
||||
asset,
|
||||
assetType: getAssetType(asset.tags),
|
||||
fileKind: getMediaTypeFromFilename(asset.name),
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
})
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
const gridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
@@ -8,9 +7,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
|
||||
useMediaAssetMenu: () => ({
|
||||
getMenuEntries: () => []
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -20,18 +19,7 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {}
|
||||
}
|
||||
})
|
||||
|
||||
const VirtualGridStub = {
|
||||
const VirtualGridStub = defineComponent({
|
||||
name: 'VirtualGrid',
|
||||
props: {
|
||||
items: {
|
||||
@@ -41,59 +29,7 @@ const VirtualGridStub = {
|
||||
},
|
||||
template:
|
||||
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
|
||||
}
|
||||
|
||||
const AssetsListItemStub = {
|
||||
name: 'AssetsListItem',
|
||||
template:
|
||||
'<div class="assets-list-item-stub"><slot /><slot name="actions" /></div>'
|
||||
}
|
||||
|
||||
const ContextMenuStub = {
|
||||
name: 'ContextMenu',
|
||||
template: '<div class="context-menu-stub"><slot /></div>'
|
||||
}
|
||||
|
||||
const ContextMenuTriggerStub = {
|
||||
name: 'ContextMenuTrigger',
|
||||
template: '<div class="context-menu-trigger-stub"><slot /></div>'
|
||||
}
|
||||
|
||||
const ContextMenuContentStub = {
|
||||
name: 'ContextMenuContent',
|
||||
template: '<div class="context-menu-content-stub"><slot /></div>'
|
||||
}
|
||||
|
||||
const DropdownMenuStub = {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: '<div class="dropdown-menu-stub"><slot /></div>'
|
||||
}
|
||||
|
||||
const DropdownMenuTriggerStub = {
|
||||
name: 'DropdownMenuTrigger',
|
||||
template: '<div class="dropdown-menu-trigger-stub"><slot /></div>'
|
||||
}
|
||||
|
||||
const DropdownMenuContentStub = {
|
||||
name: 'DropdownMenuContent',
|
||||
template: '<div class="dropdown-menu-content-stub"><slot /></div>'
|
||||
}
|
||||
|
||||
const ButtonComponentStub = {
|
||||
name: 'AppButton',
|
||||
template: '<button class="button-stub" type="button"><slot /></button>'
|
||||
}
|
||||
|
||||
const MediaAssetMenuItemsStub = {
|
||||
name: 'MediaAssetMenuItems',
|
||||
template: '<div class="media-asset-menu-items-stub" />'
|
||||
}
|
||||
})
|
||||
|
||||
const buildAsset = (id: string, name: string): AssetItem =>
|
||||
({
|
||||
@@ -117,41 +53,7 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
toggleStack: async () => {}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ContextMenu: ContextMenuStub,
|
||||
ContextMenuTrigger: ContextMenuTriggerStub,
|
||||
ContextMenuContent: ContextMenuContentStub,
|
||||
DropdownMenu: DropdownMenuStub,
|
||||
DropdownMenuTrigger: DropdownMenuTriggerStub,
|
||||
DropdownMenuContent: DropdownMenuContentStub,
|
||||
MediaAssetMenuItems: MediaAssetMenuItemsStub,
|
||||
VirtualGrid: VirtualGridStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountInteractiveListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
mount(AssetsSidebarListView, {
|
||||
props: {
|
||||
assetItems,
|
||||
selectableAssets: [],
|
||||
isSelected: () => false,
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: async () => {}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
AssetsListItem: AssetsListItemStub,
|
||||
Button: ButtonComponentStub,
|
||||
ContextMenu: ContextMenuStub,
|
||||
ContextMenuTrigger: ContextMenuTriggerStub,
|
||||
ContextMenuContent: ContextMenuContentStub,
|
||||
DropdownMenu: DropdownMenuStub,
|
||||
DropdownMenuTrigger: DropdownMenuTriggerStub,
|
||||
DropdownMenuContent: DropdownMenuContentStub,
|
||||
MediaAssetMenuItems: MediaAssetMenuItemsStub,
|
||||
VirtualGrid: VirtualGridStub
|
||||
}
|
||||
}
|
||||
@@ -229,46 +131,4 @@ describe('AssetsSidebarListView', () => {
|
||||
|
||||
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
|
||||
})
|
||||
|
||||
it('keeps the row actions menu available after the row loses hover', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-open', 'image.png'),
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
|
||||
const assetListItem = wrapper.find('.assets-list-item-stub')
|
||||
|
||||
await assetListItem.trigger('mouseenter')
|
||||
|
||||
const actionsMenu = wrapper.findComponent(DropdownMenuStub)
|
||||
expect(actionsMenu.exists()).toBe(true)
|
||||
|
||||
actionsMenu.vm.$emit('update:open', true)
|
||||
await nextTick()
|
||||
await assetListItem.trigger('mouseleave')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(true)
|
||||
|
||||
wrapper.findComponent(DropdownMenuStub).vm.$emit('update:open', false)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not select the row when clicking the actions trigger', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-actions', 'image.png'),
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
|
||||
const assetListItem = wrapper.find('.assets-list-item-stub')
|
||||
|
||||
await assetListItem.trigger('mouseenter')
|
||||
await wrapper.find('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select-asset')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,83 +16,49 @@
|
||||
>
|
||||
<i class="pi pi-trash text-xs" />
|
||||
</LoadingOverlay>
|
||||
<ContextMenu :modal="false">
|
||||
<ContextMenuTrigger as-child>
|
||||
<AssetsListItem
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
getAssetCardClass(isSelected(item.asset.id)),
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
||||
:stack-expanded="isStackExpanded(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@dblclick.stop="emit('preview-asset', item.asset)"
|
||||
@preview-click="emit('preview-asset', item.asset)"
|
||||
@stack-toggle="void toggleStack(item.asset)"
|
||||
<AssetsListItem
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
getAssetCardClass(isSelected(item.asset.id)),
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
||||
:stack-expanded="isStackExpanded(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@dblclick.stop="emit('preview-asset', item.asset)"
|
||||
@preview-click="emit('preview-asset', item.asset)"
|
||||
@stack-toggle="void toggleStack(item.asset)"
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="emit('context-menu', $event, item.asset)"
|
||||
>
|
||||
<template v-if="shouldShowActionsMenu(item.asset.id)" #actions>
|
||||
<DropdownMenu
|
||||
:open="openActionsAssetId === item.asset.id"
|
||||
@update:open="
|
||||
onActionsMenuOpenChange(item.asset.id, $event)
|
||||
"
|
||||
>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
close-on-scroll
|
||||
class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
:side-offset="4"
|
||||
:collision-padding="8"
|
||||
>
|
||||
<MediaAssetMenuItems
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
surface="dropdown"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
close-on-scroll
|
||||
class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
>
|
||||
<MediaAssetMenuItems
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
surface="context"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -106,23 +72,13 @@ import { useI18n } from 'vue-i18n'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/ContextMenu.vue'
|
||||
import ContextMenuContent from '@/components/ui/context-menu/ContextMenuContent.vue'
|
||||
import ContextMenuTrigger from '@/components/ui/context-menu/ContextMenuTrigger.vue'
|
||||
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
|
||||
import DropdownMenuContent from '@/components/ui/dropdown-menu/DropdownMenuContent.vue'
|
||||
import DropdownMenuTrigger from '@/components/ui/dropdown-menu/DropdownMenuTrigger.vue'
|
||||
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import MediaAssetMenuItems from '@/platform/assets/components/MediaAssetMenuItems.vue'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
@@ -136,19 +92,13 @@ const {
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
toggleStack
|
||||
} = defineProps<{
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => Promise<void>
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
@@ -156,27 +106,12 @@ const assetsStore = useAssetsStore()
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
||||
(e: 'preview-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'asset-deleted'): void
|
||||
(e: 'bulk-download', assets: AssetItem[]): void
|
||||
(e: 'bulk-delete', assets: AssetItem[]): void
|
||||
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-open-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-export-workflow', assets: AssetItem[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
const openActionsAssetId = ref<string | null>(null)
|
||||
const { getMenuEntries } = useMediaAssetMenu({
|
||||
inspectAsset: (asset) => emit('preview-asset', asset),
|
||||
assetDeleted: () => emit('asset-deleted'),
|
||||
bulkDownload: (assets) => emit('bulk-download', assets),
|
||||
bulkDelete: (assets) => emit('bulk-delete', assets),
|
||||
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
|
||||
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
|
||||
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
|
||||
})
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
@@ -193,17 +128,6 @@ function getAssetMediaType(asset: AssetItem) {
|
||||
return getMediaTypeFromFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
|
||||
return getMenuEntries({
|
||||
asset,
|
||||
assetType: getAssetType(asset.tags),
|
||||
fileKind: getAssetMediaType(asset),
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
})
|
||||
}
|
||||
|
||||
function isVideoAsset(asset: AssetItem): boolean {
|
||||
return getAssetMediaType(asset) === 'video'
|
||||
}
|
||||
@@ -256,27 +180,6 @@ function getAssetCardClass(selected: boolean): string {
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowActionsMenu(assetId: string): boolean {
|
||||
return (
|
||||
hoveredAssetId.value === assetId || openActionsAssetId.value === assetId
|
||||
)
|
||||
}
|
||||
|
||||
function onActionsMenuOpenChange(assetId: string, isOpen: boolean): void {
|
||||
if (isOpen) {
|
||||
openActionsAssetId.value = assetId
|
||||
return
|
||||
}
|
||||
|
||||
if (openActionsAssetId.value === assetId) {
|
||||
openActionsAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
function onAssetEnter(assetId: string) {
|
||||
hoveredAssetId.value = assetId
|
||||
}
|
||||
|
||||
@@ -94,18 +94,10 @@
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<AssetsSidebarGridView
|
||||
@@ -114,16 +106,8 @@
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
@@ -190,6 +174,24 @@
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<MediaAssetContextMenu
|
||||
v-if="contextMenuAsset"
|
||||
ref="contextMenuRef"
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@zoom="handleZoomClick(contextMenuAsset)"
|
||||
@hide="handleContextMenuHide"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -198,12 +200,14 @@ import {
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
@@ -220,7 +224,9 @@ import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
@@ -230,6 +236,7 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -260,6 +267,9 @@ const viewMode = useStorage<'list' | 'grid'>(
|
||||
)
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
@@ -267,6 +277,14 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -484,6 +502,26 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
|
||||
() => {
|
||||
contextMenuAsset.value = null
|
||||
},
|
||||
0,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
cancelCleanup()
|
||||
contextMenuAsset.value = asset
|
||||
void nextTick(() => {
|
||||
contextMenuRef.value?.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
scheduleCleanup()
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
downloadMultipleAssets(assets)
|
||||
clearSelection()
|
||||
|
||||
@@ -1,76 +1,78 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
groupedJobItems: [] as Array<{
|
||||
key: string
|
||||
label: string
|
||||
items: JobListItem[]
|
||||
}>,
|
||||
filteredTasks: [] as JobListItem[],
|
||||
getJobMenuEntries: vi.fn(() => []),
|
||||
cancelJob: vi.fn(),
|
||||
openResultGallery: vi.fn(),
|
||||
showQueueClearHistoryDialog: vi.fn(),
|
||||
commandExecute: vi.fn(),
|
||||
showDialog: vi.fn(),
|
||||
clearInitializationByJobIds: vi.fn(),
|
||||
queueDelete: vi.fn()
|
||||
}))
|
||||
|
||||
const JobAssetsListStub = defineComponent({
|
||||
name: 'JobAssetsList',
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
displayedJobGroups: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
getMenuEntries: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobList', () => ({
|
||||
useJobList: () => ({
|
||||
selectedJobTab: ref('All'),
|
||||
selectedWorkflowFilter: ref('all'),
|
||||
selectedSortMode: ref('mostRecent'),
|
||||
searchQuery: ref(''),
|
||||
hasFailedJobs: ref(false),
|
||||
filteredTasks: computed(() => testState.filteredTasks),
|
||||
groupedJobItems: computed(() => testState.groupedJobItems)
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/queue/useJobList', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const jobHistoryItem = {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
url: '/api/view/job-1.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
useJobList: () => ({
|
||||
selectedJobTab: ref('All'),
|
||||
selectedWorkflowFilter: ref('all'),
|
||||
selectedSortMode: ref('mostRecent'),
|
||||
searchQuery: ref(''),
|
||||
hasFailedJobs: ref(false),
|
||||
filteredTasks: ref([]),
|
||||
groupedJobItems: ref([
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: [jobHistoryItem]
|
||||
}
|
||||
])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({
|
||||
getJobMenuEntries: testState.getJobMenuEntries,
|
||||
cancelJob: testState.cancelJob
|
||||
jobMenuEntries: [],
|
||||
cancelJob: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
|
||||
useQueueClearHistoryDialog: () => ({
|
||||
showQueueClearHistoryDialog: testState.showQueueClearHistoryDialog
|
||||
showQueueClearHistoryDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useResultGallery', () => ({
|
||||
useResultGallery: () => ({
|
||||
galleryActiveIndex: ref(-1),
|
||||
galleryItems: ref([]),
|
||||
onViewItem: testState.openResultGallery
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/queue/useResultGallery', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useResultGallery: () => ({
|
||||
galleryActiveIndex: ref(-1),
|
||||
galleryItems: ref([]),
|
||||
onViewItem: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
@@ -82,19 +84,19 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: testState.commandExecute
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: testState.showDialog
|
||||
showDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
clearInitializationByJobIds: testState.clearInitializationByJobIds
|
||||
clearInitializationByJobIds: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -102,7 +104,7 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
runningTasks: [],
|
||||
pendingTasks: [],
|
||||
delete: testState.queueDelete
|
||||
delete: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -112,33 +114,11 @@ const i18n = createI18n({
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem =>
|
||||
({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
is3D: false,
|
||||
url: '/api/view/job-1.png'
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
}) as JobListItem
|
||||
|
||||
const setDisplayedJobs = (items: JobListItem[]) => {
|
||||
testState.filteredTasks = items
|
||||
testState.groupedJobItems = [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items
|
||||
}
|
||||
]
|
||||
const SidebarTabTemplateStub = {
|
||||
name: 'SidebarTabTemplate',
|
||||
props: ['title'],
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
|
||||
function mountComponent() {
|
||||
@@ -146,106 +126,38 @@ function mountComponent() {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SidebarTabTemplate: {
|
||||
name: 'SidebarTabTemplate',
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
|
||||
},
|
||||
SidebarTabTemplate: SidebarTabTemplateStub,
|
||||
JobFilterTabs: true,
|
||||
JobFilterActions: true,
|
||||
JobHistoryActionsMenu: true,
|
||||
MediaLightbox: true,
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
teleport: true
|
||||
JobContextMenu: true,
|
||||
ResultGallery: true,
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('JobHistorySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setDisplayedJobs([buildJob()])
|
||||
})
|
||||
|
||||
it('passes grouped jobs and menu getter to JobAssetsList', () => {
|
||||
it('shows the job details popover for jobs in the history panel', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountComponent()
|
||||
const jobAssetsList = wrapper.findComponent(JobAssetsListStub)
|
||||
const jobRow = wrapper.find('[data-job-id="job-1"]')
|
||||
|
||||
expect(jobAssetsList.props('displayedJobGroups')).toEqual(
|
||||
testState.groupedJobItems
|
||||
)
|
||||
expect(jobAssetsList.props('getMenuEntries')).toBe(
|
||||
testState.getJobMenuEntries
|
||||
)
|
||||
})
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
it('forwards regular view-item events to the result gallery', async () => {
|
||||
const job = buildJob()
|
||||
setDisplayedJobs([job])
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
|
||||
|
||||
expect(testState.openResultGallery).toHaveBeenCalledWith(job)
|
||||
expect(testState.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the 3D viewer dialog for 3D view-item events', async () => {
|
||||
const job = buildJob({
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: false,
|
||||
isVideo: false,
|
||||
is3D: true,
|
||||
url: '/api/view/job-1.glb'
|
||||
}
|
||||
} as JobListItem['taskRef']
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props()).toMatchObject({
|
||||
jobId: 'job-1',
|
||||
workflowId: 'workflow-1'
|
||||
})
|
||||
setDisplayedJobs([job])
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
|
||||
|
||||
expect(testState.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'asset-3d-viewer',
|
||||
title: job.title,
|
||||
props: { modelUrl: '/api/view/job-1.glb' }
|
||||
})
|
||||
)
|
||||
expect(testState.openResultGallery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards cancel-item events to useJobMenu.cancelJob', async () => {
|
||||
const job = buildJob({ state: 'running' })
|
||||
setDisplayedJobs([job])
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('cancel-item', job)
|
||||
|
||||
expect(testState.cancelJob).toHaveBeenCalledWith(job)
|
||||
})
|
||||
|
||||
it('forwards delete-item events to queueStore.delete', async () => {
|
||||
const job = buildJob()
|
||||
const taskRef = job.taskRef
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('delete-item', job)
|
||||
|
||||
expect(testState.queueDelete).toHaveBeenCalledWith(taskRef)
|
||||
})
|
||||
|
||||
it('runs menu actions emitted by JobAssetsList', async () => {
|
||||
const onClick = vi.fn()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper
|
||||
.findComponent(JobAssetsListStub)
|
||||
.vm.$emit('menu-action', { key: 'test', label: 'Test', onClick })
|
||||
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,11 +48,15 @@
|
||||
<template #body>
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:get-menu-entries="getJobMenuEntries"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@menu-action="onJobMenuAction"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
@@ -63,14 +67,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
|
||||
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
import type { MenuActionEntry } from '@/types/menuTypes'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
@@ -177,7 +182,13 @@ const onInspectAsset = (item: JobListItem) => {
|
||||
void onViewItem(item)
|
||||
}
|
||||
|
||||
const { getJobMenuEntries, cancelJob } = useJobMenu(onInspectAsset)
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
const { jobMenuEntries, cancelJob } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
onInspectAsset
|
||||
)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await cancelJob(item)
|
||||
@@ -188,9 +199,14 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await queueStore.delete(item.taskRef)
|
||||
})
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(
|
||||
async (entry: MenuActionEntry) => {
|
||||
if (entry.onClick) await entry.onClick()
|
||||
}
|
||||
)
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<template>
|
||||
<div
|
||||
class="m-auto w-[min(90vw,42rem)] rounded-2xl bg-base-background/80 p-8 backdrop-blur-sm"
|
||||
>
|
||||
<WaveAudioPlayer
|
||||
:src="result.url"
|
||||
variant="expanded"
|
||||
:height="120"
|
||||
:bar-count="80"
|
||||
/>
|
||||
</div>
|
||||
<audio controls width="100%" height="100%">
|
||||
<source :src="url" :type="htmlAudioType" />
|
||||
{{ $t('g.audioFailedToLoad') }}
|
||||
</audio>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WaveAudioPlayer from '@/components/common/WaveAudioPlayer.vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
defineProps<{
|
||||
const { result } = defineProps<{
|
||||
result: ResultItemImpl
|
||||
}>()
|
||||
|
||||
const url = computed(() => result.url)
|
||||
const htmlAudioType = computed(() => result.htmlAudioType)
|
||||
</script>
|
||||
|
||||
@@ -19,7 +19,6 @@ export const buttonVariants = cva({
|
||||
'bg-transparent text-muted-foreground hover:bg-secondary-background-hover',
|
||||
'destructive-textonly':
|
||||
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
|
||||
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
|
||||
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
gradient:
|
||||
@@ -52,7 +51,6 @@ const variants = [
|
||||
'textonly',
|
||||
'muted-textonly',
|
||||
'destructive-textonly',
|
||||
'link',
|
||||
'base',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import ContextMenu from './ContextMenu.vue'
|
||||
import ContextMenuContent from './ContextMenuContent.vue'
|
||||
import ContextMenuItem from './ContextMenuItem.vue'
|
||||
import ContextMenuSeparator from './ContextMenuSeparator.vue'
|
||||
import ContextMenuTrigger from './ContextMenuTrigger.vue'
|
||||
|
||||
const TestContextMenu = defineComponent({
|
||||
components: {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
|
||||
return { open }
|
||||
},
|
||||
template: `
|
||||
<ContextMenu v-model:open="open" :modal="false">
|
||||
<ContextMenuTrigger as-child>
|
||||
<button type="button">Trigger</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent close-on-scroll>
|
||||
<ContextMenuItem text-value="First item">First item</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ContextMenu', () => {
|
||||
it('closes the content on scroll when close-on-scroll is enabled', async () => {
|
||||
const wrapper = mount(TestContextMenu, {
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('contextmenu')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.open).toBe(true)
|
||||
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
|
||||
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuRootProps>()
|
||||
const emits = defineEmits<ContextMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</ContextMenuRoot>
|
||||
</template>
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import { reactiveOmit, useEventListener } from '@vueuse/core'
|
||||
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuPortal,
|
||||
injectContextMenuRootContext,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
ContextMenuContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
closeOnScroll?: boolean
|
||||
}
|
||||
>(),
|
||||
{
|
||||
closeOnScroll: false
|
||||
}
|
||||
)
|
||||
const emits = defineEmits<ContextMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const rootContext = injectContextMenuRootContext()
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
() => {
|
||||
if (props.closeOnScroll) {
|
||||
rootContext.onOpenChange(false)
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuItem, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<
|
||||
ContextMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
|
||||
>()
|
||||
const emits = defineEmits<ContextMenuItemEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuItem
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50',
|
||||
inset && 'pl-8',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { ContextMenuSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ContextMenuSeparator } from 'reka-ui'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<
|
||||
ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuSeparator
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { ContextMenuTriggerProps } from 'reka-ui'
|
||||
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<ContextMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuTrigger v-bind="forwardedProps">
|
||||
<slot />
|
||||
</ContextMenuTrigger>
|
||||
</template>
|
||||
@@ -1,57 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
import DropdownMenuContent from './DropdownMenuContent.vue'
|
||||
import DropdownMenuItem from './DropdownMenuItem.vue'
|
||||
import DropdownMenuSeparator from './DropdownMenuSeparator.vue'
|
||||
import DropdownMenuTrigger from './DropdownMenuTrigger.vue'
|
||||
|
||||
const TestDropdownMenu = defineComponent({
|
||||
components: {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator
|
||||
},
|
||||
setup() {
|
||||
const open = ref(false)
|
||||
|
||||
return { open }
|
||||
},
|
||||
template: `
|
||||
<DropdownMenu v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button type="button">Trigger</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent close-on-scroll>
|
||||
<DropdownMenuItem text-value="First item">First item</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
`
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DropdownMenu', () => {
|
||||
it('closes the content on scroll when close-on-scroll is enabled', async () => {
|
||||
const wrapper = mount(TestDropdownMenu, {
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
await wrapper.find('button').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.open).toBe(true)
|
||||
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.open).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from 'reka-ui'
|
||||
import { DropdownMenuRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuRootProps>()
|
||||
const emits = defineEmits<DropdownMenuRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import { reactiveOmit, useEventListener } from '@vueuse/core'
|
||||
import type {
|
||||
DropdownMenuContentEmits,
|
||||
DropdownMenuContentProps
|
||||
} from 'reka-ui'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuPortal,
|
||||
injectDropdownMenuRootContext,
|
||||
useForwardPropsEmits
|
||||
} from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
DropdownMenuContentProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
closeOnScroll?: boolean
|
||||
}
|
||||
>(),
|
||||
{
|
||||
closeOnScroll: false,
|
||||
sideOffset: 4
|
||||
}
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'closeOnScroll')
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
const rootContext = injectDropdownMenuRootContext()
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
() => {
|
||||
if (props.closeOnScroll) {
|
||||
rootContext.onOpenChange(false)
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground z-1700 min-w-32 overflow-hidden rounded-md border p-1 shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</template>
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { DropdownMenuItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuItem, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<
|
||||
DropdownMenuItemProps & { class?: HTMLAttributes['class']; inset?: boolean }
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuItem
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
inset && 'pl-8',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DropdownMenuItem>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { DropdownMenuSeparatorProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { DropdownMenuSeparator } from 'reka-ui'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<
|
||||
DropdownMenuSeparatorProps & {
|
||||
class?: HTMLAttributes['class']
|
||||
}
|
||||
>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuSeparator
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('-mx-1 my-1 h-px bg-muted', props.class)"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable vue/no-unused-properties */
|
||||
import type { DropdownMenuTriggerProps } from 'reka-ui'
|
||||
import { DropdownMenuTrigger, useForwardProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<DropdownMenuTriggerProps>()
|
||||
|
||||
const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuTrigger class="outline-none" v-bind="forwardedProps">
|
||||
<slot />
|
||||
</DropdownMenuTrigger>
|
||||
</template>
|
||||
@@ -140,19 +140,13 @@ export const useFirebaseAuthActions = () => {
|
||||
return result
|
||||
}, reportError)
|
||||
|
||||
const signInWithGoogle = wrapWithErrorHandlingAsync(
|
||||
async (options?: { isNewUser?: boolean }) => {
|
||||
return await authStore.loginWithGoogle(options)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
const signInWithGoogle = wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGoogle()
|
||||
}, reportError)
|
||||
|
||||
const signInWithGithub = wrapWithErrorHandlingAsync(
|
||||
async (options?: { isNewUser?: boolean }) => {
|
||||
return await authStore.loginWithGithub(options)
|
||||
},
|
||||
reportError
|
||||
)
|
||||
const signInWithGithub = wrapWithErrorHandlingAsync(async () => {
|
||||
return await authStore.loginWithGithub()
|
||||
}, reportError)
|
||||
|
||||
const signInWithEmail = wrapWithErrorHandlingAsync(
|
||||
async (email: string, password: string) => {
|
||||
|
||||
@@ -197,16 +197,14 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
||||
|
||||
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
||||
// Create a PromotedWidgetView with displayName="value" (subgraph input
|
||||
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
||||
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
||||
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
|
||||
// SafeWidgetData.name to sourceWidgetName ("prompt").
|
||||
const promotedView = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'10',
|
||||
'prompt',
|
||||
'value',
|
||||
undefined,
|
||||
'value'
|
||||
)
|
||||
|
||||
|
||||
@@ -92,10 +92,6 @@ export interface SafeWidgetData {
|
||||
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
|
||||
*/
|
||||
sourceExecutionId?: string
|
||||
/** Tooltip text from the resolved widget. */
|
||||
tooltip?: string
|
||||
/** For promoted widgets, the display label from the subgraph input slot. */
|
||||
promotedLabel?: string
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
@@ -356,8 +352,7 @@ function safeWidgetMapper(
|
||||
sourceNode && app.rootGraph
|
||||
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
|
||||
: undefined,
|
||||
tooltip: widget.tooltip,
|
||||
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
|
||||
tooltip: widget.tooltip
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
@@ -808,8 +803,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
|
||||
nodeRef.outputs = [...nodeRef.outputs]
|
||||
}
|
||||
// Re-extract widget data so promotedLabel reflects the rename
|
||||
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,24 +86,6 @@ function useVueNodeLifecycleIndividual() {
|
||||
() => !shouldRenderVueNodes.value,
|
||||
() => {
|
||||
disposeNodeManagerAndSyncs()
|
||||
|
||||
// Force arrange() on all nodes so input.pos is computed before
|
||||
// the first legacy drawConnections frame (which may run before
|
||||
// drawNode on the foreground canvas).
|
||||
const graph = comfyApp.canvas?.graph
|
||||
if (!graph) {
|
||||
comfyApp.canvas?.setDirty(true, true)
|
||||
return
|
||||
}
|
||||
for (const node of graph._nodes) {
|
||||
if (node.flags.collapsed) continue
|
||||
try {
|
||||
node.arrange()
|
||||
} catch {
|
||||
/* skip nodes not fully initialized */
|
||||
}
|
||||
}
|
||||
|
||||
comfyApp.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useJobDetailsHover<TActive>({
|
||||
}, DETAILS_SHOW_DELAY_MS)
|
||||
}
|
||||
|
||||
function scheduleDetailsHide(jobId?: string) {
|
||||
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
|
||||
if (!jobId) return
|
||||
|
||||
clearShowTimer()
|
||||
@@ -79,7 +79,7 @@ export function useJobDetailsHover<TActive>({
|
||||
const currentActive = activeDetails.value
|
||||
if (currentActive && getActiveId(currentActive) === jobId) {
|
||||
activeDetails.value = null
|
||||
onReset?.()
|
||||
onHide?.()
|
||||
}
|
||||
hideTimer.value = null
|
||||
hideTimerJobId.value = null
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/types/menuTypes'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
@@ -170,13 +172,10 @@ const createJobItem = (
|
||||
computeHours: overrides.computeHours
|
||||
})
|
||||
|
||||
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
|
||||
useJobMenu(onInspectAsset)
|
||||
let currentItem: Ref<JobListItem | null>
|
||||
|
||||
const getMenuEntries = (
|
||||
item: JobListItem | null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) => mountJobMenu(onInspectAsset).getJobMenuEntries(item)
|
||||
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
|
||||
useJobMenu(() => currentItem.value, onInspectAsset)
|
||||
|
||||
const findActionEntry = (entries: MenuEntry[], key: string) =>
|
||||
entries.find(
|
||||
@@ -187,6 +186,7 @@ const findActionEntry = (entries: MenuEntry[], key: string) =>
|
||||
describe('useJobMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentItem = ref<JobListItem | null>(null)
|
||||
settingStoreMock.get.mockReturnValue(false)
|
||||
dialogServiceMock.prompt.mockResolvedValue(undefined)
|
||||
litegraphServiceMock.getCanvasCenter.mockReturnValue([100, 200])
|
||||
@@ -212,14 +212,18 @@ describe('useJobMenu', () => {
|
||||
getJobWorkflowMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const setCurrentItem = (item: JobListItem | null) => {
|
||||
currentItem.value = item
|
||||
}
|
||||
|
||||
it('opens workflow when workflow data exists', async () => {
|
||||
const { openJobWorkflow } = mountJobMenu()
|
||||
const workflow = { nodes: [] }
|
||||
// Mock lazy loading via fetchJobDetail + extractWorkflow
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
const item = createJobItem({ id: '55' })
|
||||
setCurrentItem(createJobItem({ id: '55' }))
|
||||
|
||||
await openJobWorkflow(item)
|
||||
await openJobWorkflow()
|
||||
|
||||
expect(getJobWorkflowMock).toHaveBeenCalledWith('55')
|
||||
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
|
||||
@@ -234,9 +238,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('does nothing when workflow is missing', async () => {
|
||||
const { openJobWorkflow } = mountJobMenu()
|
||||
const item = createJobItem({ taskRef: {} })
|
||||
setCurrentItem(createJobItem({ taskRef: {} }))
|
||||
|
||||
await openJobWorkflow(item)
|
||||
await openJobWorkflow()
|
||||
|
||||
expect(workflowStoreMock.createTemporary).not.toHaveBeenCalled()
|
||||
expect(workflowServiceMock.openWorkflow).not.toHaveBeenCalled()
|
||||
@@ -244,9 +248,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('copies job id to clipboard', async () => {
|
||||
const { copyJobId } = mountJobMenu()
|
||||
const item = createJobItem({ id: 'job-99' })
|
||||
setCurrentItem(createJobItem({ id: 'job-99' }))
|
||||
|
||||
await copyJobId(item)
|
||||
await copyJobId()
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('job-99')
|
||||
})
|
||||
@@ -264,9 +268,9 @@ describe('useJobMenu', () => {
|
||||
['initialization', interruptMock, deleteItemMock]
|
||||
])('cancels %s job via interrupt', async (state) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
const item = createJobItem({ state: state as JobListItem['state'] })
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
|
||||
await cancelJob(item)
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
@@ -275,9 +279,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('cancels pending job via deleteItem', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
const item = createJobItem({ state: 'pending' })
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
await cancelJob(item)
|
||||
await cancelJob()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
@@ -285,9 +289,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('still updates queue for uncancellable states', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
const item = createJobItem({ state: 'completed' })
|
||||
setCurrentItem(createJobItem({ state: 'completed' }))
|
||||
|
||||
await cancelJob(item)
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).not.toHaveBeenCalled()
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
@@ -295,7 +299,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('copies error message from failed job entry', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
@@ -304,7 +309,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'copy-error')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
|
||||
@@ -323,7 +329,8 @@ describe('useJobMenu', () => {
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
@@ -334,7 +341,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'report-error')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||
@@ -345,7 +353,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('falls back to simple error dialog when no execution_error', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
@@ -354,7 +363,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'report-error')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
@@ -367,16 +377,18 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: undefined } as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
const copyEntry = findActionEntry(entries, 'copy-error')
|
||||
await nextTick()
|
||||
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
await copyEntry?.onClick?.()
|
||||
const reportEntry = findActionEntry(entries, 'report-error')
|
||||
const reportEntry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await reportEntry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
@@ -414,6 +426,7 @@ describe('useJobMenu', () => {
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const preview = {
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
@@ -421,14 +434,15 @@ describe('useJobMenu', () => {
|
||||
url: 'http://asset',
|
||||
...flags
|
||||
}
|
||||
const entries = getMenuEntries(
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: preview }
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalledWith(
|
||||
@@ -443,7 +457,8 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('skips adding node when no loader definition', async () => {
|
||||
delete nodeDefStoreMock.nodeDefsByName.LoadImage
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -457,14 +472,16 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips adding node when preview output lacks media flags', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -477,7 +494,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -486,7 +504,8 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('skips annotating when litegraph node creation fails', async () => {
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(null)
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -500,7 +519,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalled()
|
||||
@@ -508,21 +528,24 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('ignores add-to-current entry when preview missing entirely', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads preview asset when requested', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -531,21 +554,24 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'download')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
void entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
|
||||
})
|
||||
|
||||
it('ignores download request when preview missing', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'download')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
void entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
@@ -554,14 +580,16 @@ describe('useJobMenu', () => {
|
||||
it('exports workflow with default filename when prompting disabled', async () => {
|
||||
const workflow = { foo: 'bar' }
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '7',
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
|
||||
@@ -577,13 +605,15 @@ describe('useJobMenu', () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||
getJobWorkflowMock.mockResolvedValue({})
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.prompt).toHaveBeenCalledWith({
|
||||
@@ -599,14 +629,16 @@ describe('useJobMenu', () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('existing.json')
|
||||
getJobWorkflowMock.mockResolvedValue({ foo: 'bar' })
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '42',
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
|
||||
@@ -618,13 +650,15 @@ describe('useJobMenu', () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('')
|
||||
getJobWorkflowMock.mockResolvedValue({})
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
@@ -632,13 +666,13 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('deletes preview asset when confirmed', async () => {
|
||||
mediaAssetActionsMock.deleteAssets.mockResolvedValue(true)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
|
||||
const taskRef = { previewOutput: preview }
|
||||
const entries = getMenuEntries(
|
||||
createJobItem({ state: 'completed', taskRef })
|
||||
)
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef }))
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(mapTaskOutputToAssetItemMock).toHaveBeenCalledWith(taskRef, preview)
|
||||
@@ -647,14 +681,16 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('does not refresh queue when delete cancelled', async () => {
|
||||
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
@@ -662,18 +698,22 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('removes failed job via menu entry', async () => {
|
||||
const taskRef = { id: 'task-1' }
|
||||
const entries = getMenuEntries(createJobItem({ state: 'failed', taskRef }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed', taskRef }))
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
|
||||
})
|
||||
|
||||
it('ignores failed job delete when taskRef missing', async () => {
|
||||
const entries = getMenuEntries(createJobItem({ state: 'failed' }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed' }))
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.delete).not.toHaveBeenCalled()
|
||||
@@ -681,13 +721,16 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('provides completed menu structure with delete option', async () => {
|
||||
const inspectSpy = vi.fn()
|
||||
const item = createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
const entries = getMenuEntries(item, inspectSpy)
|
||||
const { jobMenuEntries } = mountJobMenu(inspectSpy)
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'inspect-asset',
|
||||
'add-to-current',
|
||||
'download',
|
||||
@@ -700,48 +743,66 @@ describe('useJobMenu', () => {
|
||||
'delete'
|
||||
])
|
||||
|
||||
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(false)
|
||||
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(false)
|
||||
expect(findActionEntry(entries, 'download')?.disabled).toBe(false)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(false)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(false)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
const inspectEntry = findActionEntry(entries, 'inspect-asset')
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
await inspectEntry?.onClick?.()
|
||||
expect(inspectSpy).toHaveBeenCalledWith(item)
|
||||
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
|
||||
})
|
||||
|
||||
it('omits inspect handler when callback missing', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
const inspectEntry = findActionEntry(entries, 'inspect-asset')
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
expect(inspectEntry?.onClick).toBeUndefined()
|
||||
expect(inspectEntry?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('omits delete asset entry when no preview exists', async () => {
|
||||
const entries = getMenuEntries(
|
||||
createJobItem({ state: 'completed', taskRef: {} })
|
||||
)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
|
||||
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(true)
|
||||
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(true)
|
||||
expect(findActionEntry(entries, 'download')?.disabled).toBe(true)
|
||||
expect(entries.some((entry) => entry.key === 'delete')).toBe(false)
|
||||
await nextTick()
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(true)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(true)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
true
|
||||
)
|
||||
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns failed menu entries with error actions', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'Some error' } as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
@@ -753,9 +814,11 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('returns active job entries with cancel option', async () => {
|
||||
const entries = getMenuEntries(createJobItem({ state: 'running' }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'running' }))
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
@@ -765,16 +828,18 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('provides pending job entries and triggers cancel action', async () => {
|
||||
const entries = getMenuEntries(createJobItem({ state: 'pending' }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
'd2',
|
||||
'cancel-job'
|
||||
])
|
||||
const cancelEntry = findActionEntry(entries, 'cancel-job')
|
||||
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
|
||||
await cancelEntry?.onClick?.()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
@@ -782,6 +847,10 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('returns empty menu when no job selected', async () => {
|
||||
expect(getMenuEntries(null)).toEqual([])
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(null)
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,16 +20,30 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { MenuEntry } from '@/types/menuTypes'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
export type MenuEntry =
|
||||
| {
|
||||
kind?: 'item'
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
| { kind: 'divider'; key: string }
|
||||
|
||||
/**
|
||||
* Provides job context menu entries and actions.
|
||||
*
|
||||
* @param currentMenuItem Getter for the currently targeted job list item
|
||||
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
|
||||
*/
|
||||
export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
export function useJobMenu(
|
||||
currentMenuItem: () => JobListItem | null = () => null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -39,8 +53,11 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const mediaAssetActions = useMediaAssetActions()
|
||||
|
||||
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
|
||||
item ?? currentMenuItem()
|
||||
|
||||
const openJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = await getJobWorkflow(target.id)
|
||||
if (!data) return
|
||||
@@ -50,13 +67,13 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
}
|
||||
|
||||
const copyJobId = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
await copyToClipboard(target.id)
|
||||
}
|
||||
|
||||
const cancelJob = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
if (target.state === 'running' || target.state === 'initialization') {
|
||||
if (isCloud) {
|
||||
@@ -72,13 +89,13 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
}
|
||||
|
||||
const copyErrorMessage = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
const message = target?.taskRef?.errorMessage
|
||||
if (message) await copyToClipboard(message)
|
||||
}
|
||||
|
||||
const reportError = (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
|
||||
// Use execution_error from list response if available
|
||||
@@ -100,10 +117,10 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
@@ -151,10 +168,10 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
@@ -162,14 +179,14 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const data = await getJobWorkflow(target.id)
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = await getJobWorkflow(item.id)
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${target.id}.json`
|
||||
let filename = `Job ${item.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
@@ -186,10 +203,10 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const task = target.taskRef as TaskItemImpl | undefined
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
@@ -201,7 +218,8 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
}
|
||||
|
||||
const removeFailedJob = async (task?: TaskItemImpl | null) => {
|
||||
const target = task
|
||||
const target =
|
||||
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
|
||||
if (!target) return
|
||||
await queueStore.delete(target)
|
||||
}
|
||||
@@ -219,11 +237,11 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
st('queue.jobMenu.cancelJob', 'Cancel job')
|
||||
)
|
||||
|
||||
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
|
||||
const target = item
|
||||
const state = target?.state
|
||||
const jobMenuEntries = computed<MenuEntry[]>(() => {
|
||||
const item = currentMenuItem()
|
||||
const state = item?.state
|
||||
if (!state) return []
|
||||
const hasPreviewAsset = !!target?.taskRef?.previewOutput
|
||||
const hasPreviewAsset = !!item?.taskRef?.previewOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
@@ -233,7 +251,8 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
disabled: !hasPreviewAsset || !onInspectAsset,
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
if (target) onInspectAsset(target)
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
@@ -245,34 +264,34 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: () => addOutputLoaderNode(target)
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: () => downloadPreviewAsset(target)
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(target)
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: () => exportJobWorkflow(target)
|
||||
onClick: exportJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(target)
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasPreviewAsset
|
||||
@@ -281,7 +300,7 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: () => deleteJobAsset(target)
|
||||
onClick: deleteJobAsset
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -293,33 +312,33 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(target)
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(target)
|
||||
onClick: copyJobId
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyErrorMessage(target)
|
||||
onClick: copyErrorMessage
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: () => reportError(target)
|
||||
onClick: reportError
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: () => removeFailedJob(target?.taskRef)
|
||||
onClick: removeFailedJob
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -328,27 +347,27 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(target)
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(target)
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: () => cancelJob(target)
|
||||
onClick: cancelJob
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
getJobMenuEntries: buildJobMenuEntries,
|
||||
jobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
cancelJob,
|
||||
|
||||
108
src/composables/useDismissableOverlay.test.ts
Normal file
108
src/composables/useDismissableOverlay.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { EffectScope, Ref } from 'vue'
|
||||
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
|
||||
describe('useDismissableOverlay', () => {
|
||||
let scope: EffectScope | undefined
|
||||
let isOpen: Ref<boolean>
|
||||
let overlayEl: HTMLElement
|
||||
let triggerEl: HTMLElement
|
||||
let outsideEl: HTMLElement
|
||||
let dismissCount: number
|
||||
|
||||
const mountComposable = ({
|
||||
dismissOnScroll = false,
|
||||
getTriggerEl
|
||||
}: {
|
||||
dismissOnScroll?: boolean
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
} = {}) => {
|
||||
scope = effectScope()
|
||||
scope.run(() =>
|
||||
useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl: () => overlayEl,
|
||||
getTriggerEl,
|
||||
onDismiss: () => {
|
||||
dismissCount += 1
|
||||
},
|
||||
dismissOnScroll
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isOpen = ref(true)
|
||||
overlayEl = document.createElement('div')
|
||||
triggerEl = document.createElement('button')
|
||||
outsideEl = document.createElement('div')
|
||||
dismissCount = 0
|
||||
document.body.append(overlayEl, triggerEl, outsideEl)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('dismisses on outside pointerdown', () => {
|
||||
mountComposable()
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the overlay', () => {
|
||||
mountComposable()
|
||||
|
||||
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the trigger', () => {
|
||||
mountComposable({
|
||||
getTriggerEl: () => triggerEl
|
||||
})
|
||||
|
||||
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('dismisses on scroll when enabled', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores scroll inside the overlay', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
overlayEl.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('does not dismiss when closed', () => {
|
||||
isOpen.value = false
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
})
|
||||
60
src/composables/useDismissableOverlay.ts
Normal file
60
src/composables/useDismissableOverlay.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface UseDismissableOverlayOptions {
|
||||
isOpen: MaybeRefOrGetter<boolean>
|
||||
getOverlayEl: () => HTMLElement | null
|
||||
onDismiss: () => void
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
dismissOnScroll?: boolean
|
||||
}
|
||||
|
||||
const isNode = (value: EventTarget | null | undefined): value is Node =>
|
||||
value instanceof Node
|
||||
|
||||
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
|
||||
!!element?.contains(target)
|
||||
|
||||
export function useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl,
|
||||
onDismiss,
|
||||
getTriggerEl,
|
||||
dismissOnScroll = false
|
||||
}: UseDismissableOverlayOptions) {
|
||||
const dismissIfOutside = (event: Event) => {
|
||||
if (!toValue(isOpen)) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlay = getOverlayEl()
|
||||
if (!overlay) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isNode(event.target)) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isInside(event.target, overlay) ||
|
||||
isInside(event.target, getTriggerEl?.())
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
|
||||
|
||||
if (dismissOnScroll) {
|
||||
useEventListener(window, 'scroll', dismissIfOutside, {
|
||||
capture: true,
|
||||
passive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
@@ -16,10 +15,6 @@ interface TransformCompatOverlayOptions {
|
||||
// autoZIndex?: boolean
|
||||
}
|
||||
|
||||
export const OverlayAppendToKey: InjectionKey<
|
||||
HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
> = Symbol('OverlayAppendTo')
|
||||
|
||||
/**
|
||||
* Composable that provides props to make PrimeVue overlay components
|
||||
* compatible with CSS-transformed parent elements.
|
||||
@@ -46,10 +41,8 @@ export const OverlayAppendToKey: InjectionKey<
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
|
||||
return computed(() => ({
|
||||
appendTo: injectedAppendTo ?? ('self' as const),
|
||||
appendTo: 'self' as const,
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWaveAudioPlayer } from './useWaveAudioPlayer'
|
||||
|
||||
vi.mock('@vueuse/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>()
|
||||
return {
|
||||
...actual,
|
||||
useMediaControls: () => ({
|
||||
playing: ref(false),
|
||||
currentTime: ref(0),
|
||||
duration: ref(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
const originalAudioContext = globalThis.AudioContext
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.AudioContext = originalAudioContext
|
||||
mockFetchApi.mockReset()
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
apiURL: (route: string) => '/api' + route,
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useWaveAudioPlayer', () => {
|
||||
it('initializes with default bar count', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
expect(bars.value).toHaveLength(40)
|
||||
})
|
||||
|
||||
it('initializes with custom bar count', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src, barCount: 20 })
|
||||
expect(bars.value).toHaveLength(20)
|
||||
})
|
||||
|
||||
it('returns playedBarIndex as -1 when duration is 0', () => {
|
||||
const src = ref('')
|
||||
const { playedBarIndex } = useWaveAudioPlayer({ src })
|
||||
expect(playedBarIndex.value).toBe(-1)
|
||||
})
|
||||
|
||||
it('generates bars with heights between 10 and 70', () => {
|
||||
const src = ref('')
|
||||
const { bars } = useWaveAudioPlayer({ src })
|
||||
for (const bar of bars.value) {
|
||||
expect(bar.height).toBeGreaterThanOrEqual(10)
|
||||
expect(bar.height).toBeLessThanOrEqual(70)
|
||||
}
|
||||
})
|
||||
|
||||
it('starts in paused state', () => {
|
||||
const src = ref('')
|
||||
const { isPlaying } = useWaveAudioPlayer({ src })
|
||||
expect(isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('shows 0:00 for formatted times initially', () => {
|
||||
const src = ref('')
|
||||
const { formattedCurrentTime, formattedDuration } = useWaveAudioPlayer({
|
||||
src
|
||||
})
|
||||
expect(formattedCurrentTime.value).toBe('0:00')
|
||||
expect(formattedDuration.value).toBe('0:00')
|
||||
})
|
||||
|
||||
it('fetches and decodes audio when src changes', async () => {
|
||||
const mockAudioBuffer = {
|
||||
getChannelData: vi.fn(() => new Float32Array(80))
|
||||
}
|
||||
|
||||
const mockDecodeAudioData = vi.fn(() => Promise.resolve(mockAudioBuffer))
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined)
|
||||
globalThis.AudioContext = class {
|
||||
decodeAudioData = mockDecodeAudioData
|
||||
close = mockClose
|
||||
} as unknown as typeof AudioContext
|
||||
|
||||
mockFetchApi.mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
||||
headers: { get: () => 'audio/wav' }
|
||||
})
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading } = useWaveAudioPlayer({ src, barCount: 10 })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/view?filename=audio.wav&type=output'
|
||||
)
|
||||
expect(mockDecodeAudioData).toHaveBeenCalled()
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
|
||||
mockFetchApi.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading, audioSrc } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 10
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(10)
|
||||
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
expect(mockFetchApi).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,205 +0,0 @@
|
||||
import { useMediaControls, whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { formatTime } from '@/utils/formatUtil'
|
||||
|
||||
interface WaveformBar {
|
||||
height: number
|
||||
}
|
||||
|
||||
interface UseWaveAudioPlayerOptions {
|
||||
src: Ref<string>
|
||||
barCount?: number
|
||||
}
|
||||
|
||||
export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
const { src, barCount = 40 } = options
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const waveformRef = ref<HTMLElement>()
|
||||
const blobUrl = ref<string>()
|
||||
const loading = ref(false)
|
||||
let decodeRequestId = 0
|
||||
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
|
||||
|
||||
const { playing, currentTime, duration, volume, muted } =
|
||||
useMediaControls(audioRef)
|
||||
|
||||
const playedBarIndex = computed(() => {
|
||||
if (duration.value === 0) return -1
|
||||
return Math.floor((currentTime.value / duration.value) * barCount) - 1
|
||||
})
|
||||
|
||||
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(duration.value))
|
||||
|
||||
const audioSrc = computed(() =>
|
||||
src.value ? (blobUrl.value ?? src.value) : ''
|
||||
)
|
||||
|
||||
function generatePlaceholderBars(): WaveformBar[] {
|
||||
return Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * 60 + 10
|
||||
}))
|
||||
}
|
||||
|
||||
function generateBarsFromBuffer(buffer: AudioBuffer) {
|
||||
const channelData = buffer.getChannelData(0)
|
||||
if (channelData.length === 0) {
|
||||
bars.value = generatePlaceholderBars()
|
||||
return
|
||||
}
|
||||
|
||||
const averages: number[] = []
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = Math.floor((i * channelData.length) / barCount)
|
||||
const end = Math.max(
|
||||
start + 1,
|
||||
Math.floor(((i + 1) * channelData.length) / barCount)
|
||||
)
|
||||
let sum = 0
|
||||
for (let j = start; j < end && j < channelData.length; j++) {
|
||||
sum += Math.abs(channelData[j])
|
||||
}
|
||||
averages.push(sum / (end - start))
|
||||
}
|
||||
|
||||
const peak = Math.max(...averages) || 1
|
||||
bars.value = averages.map((avg) => ({
|
||||
height: Math.max(8, (avg / peak) * 100)
|
||||
}))
|
||||
}
|
||||
|
||||
async function decodeAudioSource(url: string) {
|
||||
const requestId = ++decodeRequestId
|
||||
loading.value = true
|
||||
let ctx: AudioContext | undefined
|
||||
try {
|
||||
const apiBase = api.apiURL('/')
|
||||
const route = url.includes(apiBase)
|
||||
? url.slice(url.indexOf(apiBase) + api.apiURL('').length)
|
||||
: url
|
||||
const response = await api.fetchApi(route)
|
||||
if (requestId !== decodeRequestId) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch audio (${response.status})`)
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
|
||||
if (requestId !== decodeRequestId) return
|
||||
|
||||
const blob = new Blob([arrayBuffer.slice(0)], {
|
||||
type: response.headers.get('content-type') ?? 'audio/wav'
|
||||
})
|
||||
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = URL.createObjectURL(blob)
|
||||
|
||||
ctx = new AudioContext()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
if (requestId !== decodeRequestId) return
|
||||
generateBarsFromBuffer(audioBuffer)
|
||||
} catch {
|
||||
if (requestId === decodeRequestId) {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
bars.value = generatePlaceholderBars()
|
||||
}
|
||||
} finally {
|
||||
await ctx?.close()
|
||||
if (requestId === decodeRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const progressRatio = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return (currentTime.value / duration.value) * 100
|
||||
})
|
||||
|
||||
function togglePlayPause() {
|
||||
playing.value = !playing.value
|
||||
}
|
||||
|
||||
function seekToStart() {
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
function seekToEnd() {
|
||||
currentTime.value = duration.value
|
||||
playing.value = false
|
||||
}
|
||||
|
||||
function seekToRatio(ratio: number) {
|
||||
const clamped = Math.max(0, Math.min(1, ratio))
|
||||
currentTime.value = clamped * duration.value
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
muted.value = !muted.value
|
||||
}
|
||||
|
||||
const volumeIcon = computed(() => {
|
||||
if (muted.value || volume.value === 0) return 'icon-[lucide--volume-x]'
|
||||
if (volume.value < 0.5) return 'icon-[lucide--volume-1]'
|
||||
return 'icon-[lucide--volume-2]'
|
||||
})
|
||||
|
||||
function handleWaveformClick(event: MouseEvent) {
|
||||
if (!waveformRef.value || duration.value === 0) return
|
||||
const rect = waveformRef.value.getBoundingClientRect()
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width)
|
||||
)
|
||||
currentTime.value = ratio * duration.value
|
||||
|
||||
if (!playing.value) {
|
||||
playing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
whenever(
|
||||
src,
|
||||
(url) => {
|
||||
playing.value = false
|
||||
currentTime.value = 0
|
||||
void decodeAudioSource(url)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
decodeRequestId += 1
|
||||
audioRef.value?.pause()
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying: playing,
|
||||
playedBarIndex,
|
||||
progressRatio,
|
||||
formattedCurrentTime,
|
||||
formattedDuration,
|
||||
togglePlayPause,
|
||||
seekToStart,
|
||||
seekToEnd,
|
||||
volume,
|
||||
volumeIcon,
|
||||
toggleMute,
|
||||
seekToRatio,
|
||||
handleWaveformClick
|
||||
}
|
||||
}
|
||||
@@ -138,18 +138,15 @@ describe(createPromotedWidgetView, () => {
|
||||
expect(view.name).toBe('myWidget')
|
||||
})
|
||||
|
||||
test('name uses identityName when provided, label uses displayName', () => {
|
||||
test('name uses displayName when provided', () => {
|
||||
const [subgraphNode] = setupSubgraph()
|
||||
const view = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
'1',
|
||||
'myWidget',
|
||||
'Custom Label',
|
||||
undefined,
|
||||
'my_slot'
|
||||
'Custom Label'
|
||||
)
|
||||
expect(view.name).toBe('my_slot')
|
||||
expect(view.label).toBe('Custom Label')
|
||||
expect(view.name).toBe('Custom Label')
|
||||
})
|
||||
|
||||
test('node getter returns the subgraphNode', () => {
|
||||
@@ -337,11 +334,11 @@ describe(createPromotedWidgetView, () => {
|
||||
innerNode.addWidget('text', 'myWidget', 'val', () => {})
|
||||
const bareId = String(innerNode.id)
|
||||
|
||||
// No displayName → label is undefined (rendering uses widget.label ?? widget.name)
|
||||
// No displayName → falls back to widgetName
|
||||
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
|
||||
expect(view1.label).toBeUndefined()
|
||||
expect(view1.label).toBe('myWidget')
|
||||
|
||||
// With displayName → label falls back to displayName
|
||||
// With displayName → falls back to displayName
|
||||
const view2 = createPromotedWidgetView(
|
||||
subgraphNode,
|
||||
bareId,
|
||||
@@ -1015,9 +1012,7 @@ describe('SubgraphNode.widgets getter', () => {
|
||||
|
||||
const afterRename = promotedWidgets(subgraphNode)[0]
|
||||
if (!afterRename) throw new Error('Expected linked promoted view')
|
||||
// .name stays as identity (subgraph input name), .label updates for display
|
||||
expect(afterRename.name).toBe('seed')
|
||||
expect(afterRename.label).toBe('seed_renamed')
|
||||
expect(afterRename.name).toBe('seed_renamed')
|
||||
})
|
||||
|
||||
test('caches view objects across getter calls (stable references)', () => {
|
||||
|
||||
@@ -27,12 +27,6 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
|
||||
export type { PromotedWidgetView } from './promotedWidgetTypes'
|
||||
export { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
interface SubgraphSlotRef {
|
||||
name: string
|
||||
label?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
|
||||
if (value === undefined) return true
|
||||
if (typeof value === 'string') return true
|
||||
@@ -56,16 +50,14 @@ export function createPromotedWidgetView(
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
displayName?: string,
|
||||
disambiguatingSourceNodeId?: string,
|
||||
identityName?: string
|
||||
disambiguatingSourceNodeId?: string
|
||||
): IPromotedWidgetView {
|
||||
return new PromotedWidgetView(
|
||||
subgraphNode,
|
||||
nodeId,
|
||||
widgetName,
|
||||
displayName,
|
||||
disambiguatingSourceNodeId,
|
||||
identityName
|
||||
disambiguatingSourceNodeId
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,17 +83,12 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
|
||||
private cachedDeepestFrame = -1
|
||||
|
||||
/** Cached reference to the bound subgraph slot, set at construction. */
|
||||
private _boundSlot?: SubgraphSlotRef
|
||||
private _boundSlotVersion = -1
|
||||
|
||||
constructor(
|
||||
private readonly subgraphNode: SubgraphNode,
|
||||
nodeId: string,
|
||||
widgetName: string,
|
||||
private readonly displayName?: string,
|
||||
readonly disambiguatingSourceNodeId?: string,
|
||||
private readonly identityName?: string
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
) {
|
||||
this.sourceNodeId = nodeId
|
||||
this.sourceWidgetName = widgetName
|
||||
@@ -113,7 +100,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.identityName ?? this.sourceWidgetName
|
||||
return this.displayName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
get y(): number {
|
||||
@@ -201,58 +188,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
get label(): string | undefined {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) return slot.label ?? slot.displayName ?? slot.name
|
||||
// Fall back to persisted widget state (survives save/reload before
|
||||
// the slot binding is established) then to construction displayName.
|
||||
const state = this.getWidgetState()
|
||||
return state?.label ?? this.displayName
|
||||
return state?.label ?? this.displayName ?? this.sourceWidgetName
|
||||
}
|
||||
|
||||
set label(value: string | undefined) {
|
||||
const slot = this.getBoundSubgraphSlot()
|
||||
if (slot) slot.label = value || undefined
|
||||
// Also persist to widget state store for save/reload resilience
|
||||
const state = this.getWidgetState()
|
||||
if (state) state.label = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached bound subgraph slot reference, refreshing only when
|
||||
* the subgraph node's input list has changed (length mismatch).
|
||||
*
|
||||
* Note: Using length as the cache key works because the returned reference
|
||||
* is the same mutable slot object. When slot properties (label, name) change,
|
||||
* the caller reads fresh values from that reference. The cache only needs
|
||||
* to invalidate when slots are added or removed, which changes length.
|
||||
*/
|
||||
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
const version = this.subgraphNode.inputs?.length ?? 0
|
||||
if (this._boundSlotVersion === version) return this._boundSlot
|
||||
|
||||
this._boundSlot = this.findBoundSubgraphSlot()
|
||||
this._boundSlotVersion = version
|
||||
return this._boundSlot
|
||||
}
|
||||
|
||||
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
|
||||
for (const input of this.subgraphNode.inputs ?? []) {
|
||||
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
|
||||
if (!slot) continue
|
||||
|
||||
const w = input._widget
|
||||
if (
|
||||
w &&
|
||||
isPromotedWidgetView(w) &&
|
||||
w.sourceNodeId === this.sourceNodeId &&
|
||||
w.sourceWidgetName === this.sourceWidgetName
|
||||
) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
get hidden(): boolean {
|
||||
return this.resolveDeepest()?.widget.hidden ?? false
|
||||
}
|
||||
@@ -294,27 +238,21 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const originalComputedHeight = projected.computedHeight
|
||||
const originalComputedDisabled = projected.computedDisabled
|
||||
|
||||
const originalLabel = projected.label
|
||||
|
||||
projected.y = this.y
|
||||
projected.computedHeight = this.computedHeight
|
||||
projected.computedDisabled = this.computedDisabled
|
||||
projected.value = this.value
|
||||
projected.label = this.label
|
||||
|
||||
try {
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
} finally {
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
projected.label = originalLabel
|
||||
}
|
||||
projected.drawWidget(ctx, {
|
||||
width: widgetWidth,
|
||||
showText: !lowQuality,
|
||||
suppressPromotedOutline: true,
|
||||
previewImages: resolved.node.imgs
|
||||
})
|
||||
|
||||
projected.y = originalY
|
||||
projected.computedHeight = originalComputedHeight
|
||||
projected.computedDisabled = originalComputedDisabled
|
||||
}
|
||||
|
||||
onPointerDown(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app } from '../../scripts/app'
|
||||
import { ComfyApp } from '../../scripts/app'
|
||||
import { $el, ComfyDialog } from '../../scripts/ui'
|
||||
|
||||
class ClipspaceDialog extends ComfyDialog {
|
||||
export class ClipspaceDialog extends ComfyDialog {
|
||||
static items: Array<
|
||||
HTMLButtonElement & {
|
||||
contextPredicate?: () => boolean
|
||||
|
||||
@@ -30,7 +30,6 @@ describe('PrimitiveFloat widget type bridging', () => {
|
||||
})
|
||||
|
||||
Object.defineProperty(widget.options, 'gradient_stops', {
|
||||
enumerable: true,
|
||||
get: () => properties.gradient_stops,
|
||||
set: (v) => {
|
||||
properties.gradient_stops = v
|
||||
@@ -83,20 +82,6 @@ describe('PrimitiveFloat widget type bridging', () => {
|
||||
expect(widget.options.gradient_stops).toBe(stops)
|
||||
})
|
||||
|
||||
it('gradient_stops survives object spread', () => {
|
||||
const { properties, widget } = createMockNodeAndWidget()
|
||||
applyFloatPropertyBridges(properties, widget)
|
||||
|
||||
const stops = [
|
||||
{ offset: 0, color: [0, 255, 255] },
|
||||
{ offset: 1, color: [255, 0, 0] }
|
||||
]
|
||||
properties.gradient_stops = stops
|
||||
|
||||
const spread = { ...widget.options }
|
||||
expect(spread.gradient_stops).toBe(stops)
|
||||
})
|
||||
|
||||
it('writes gradient_stops back to properties', () => {
|
||||
const { properties, widget } = createMockNodeAndWidget()
|
||||
applyFloatPropertyBridges(properties, widget)
|
||||
|
||||
@@ -169,7 +169,6 @@ function onCustomFloatCreated(this: LGraphNode) {
|
||||
})
|
||||
|
||||
Object.defineProperty(valueWidget.options, 'gradient_stops', {
|
||||
enumerable: true,
|
||||
get: () => this.properties.gradient_stops,
|
||||
set: (v) => {
|
||||
this.properties.gradient_stops = v
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LiteGraph,
|
||||
LLink,
|
||||
Reroute
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
@@ -18,13 +17,7 @@ import {
|
||||
createTestSubgraphData,
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
import { subgraphTest } from './subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import {
|
||||
duplicateLinksRoot,
|
||||
duplicateLinksSlotShift,
|
||||
duplicateLinksSubgraph
|
||||
} from './__fixtures__/duplicateLinks'
|
||||
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
|
||||
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
|
||||
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
|
||||
@@ -100,42 +93,6 @@ describe('LGraph', () => {
|
||||
const fromOldSchema = new LGraph(oldSchemaGraph)
|
||||
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
|
||||
})
|
||||
subgraphTest('should snap slots to same y-level', ({ emptySubgraph }) => {
|
||||
const node = new LGraphNode('testname')
|
||||
node.addInput('test', 'IMAGE')
|
||||
emptySubgraph.add(node)
|
||||
|
||||
emptySubgraph.inputNode.pos = [0, 0]
|
||||
// Reroute needs offset of ~20y to align with first slot
|
||||
const reroute = new Reroute(1, emptySubgraph, [0, 20])
|
||||
|
||||
node.snapToGrid(10)
|
||||
reroute.snapToGrid(10)
|
||||
emptySubgraph.inputNode.snapToGrid(10)
|
||||
|
||||
node.arrange()
|
||||
emptySubgraph.inputNode.arrange()
|
||||
|
||||
const yPos = node.getInputPos(0)[1]
|
||||
expect(reroute.pos[1]).toBe(yPos)
|
||||
expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos)
|
||||
|
||||
// Assign non-equal positions and repeat
|
||||
emptySubgraph.inputNode.pos = [0, 43]
|
||||
node.pos = [0, 50]
|
||||
reroute.pos = [0, 63]
|
||||
|
||||
node.snapToGrid(10)
|
||||
reroute.snapToGrid(10)
|
||||
emptySubgraph.inputNode.snapToGrid(10)
|
||||
|
||||
node.arrange()
|
||||
emptySubgraph.inputNode.arrange()
|
||||
|
||||
const yPos2 = node.getInputPos(0)[1]
|
||||
expect(reroute.pos[1]).toBe(yPos2)
|
||||
expect(emptySubgraph.inputNode.emptySlot.pos[1]).toBe(yPos2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Floating Links / Reroutes', () => {
|
||||
@@ -603,39 +560,31 @@ describe('_removeDuplicateLinks', () => {
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
}
|
||||
|
||||
function createConnectedGraph() {
|
||||
it('removes orphaned duplicate links from _links and output.links', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
return { graph, source, target }
|
||||
}
|
||||
expect(graph._links.size).toBe(1)
|
||||
|
||||
function injectDuplicateLink(
|
||||
graph: LGraph,
|
||||
source: LGraphNode,
|
||||
target: LGraphNode
|
||||
) {
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
}
|
||||
|
||||
it('removes orphaned duplicate links from _links and output.links', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
|
||||
for (let i = 0; i < 3; i++) injectDuplicateLink(graph, source, target)
|
||||
const existingLink = graph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
|
||||
expect(graph._links.size).toBe(4)
|
||||
expect(source.outputs[0].links).toHaveLength(4)
|
||||
@@ -648,10 +597,27 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('keeps the link referenced by input.link', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const keptLinkId = target.inputs[0].link!
|
||||
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
@@ -662,8 +628,18 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('keeps the valid link when input.link is at a shifted slot index', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
// Connect source:0 -> target:0, establishing input.link on target
|
||||
source.connect(0, target, 0)
|
||||
const validLinkId = target.inputs[0].link!
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
|
||||
// Simulate widget-to-input conversion shifting the slot: insert a new
|
||||
// input BEFORE the connected one, moving it from index 0 to index 1.
|
||||
@@ -671,13 +647,26 @@ describe('_removeDuplicateLinks', () => {
|
||||
const connectedInput = target.inputs[0]
|
||||
target.inputs[0] = target.inputs[1]
|
||||
target.inputs[1] = connectedInput
|
||||
// Now target.inputs[1].link === validLinkId, but target.inputs[0].link is null
|
||||
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
// Add a duplicate link with the same connection tuple (target_slot=0
|
||||
// in the LLink, matching the original slot before the shift).
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
|
||||
expect(graph._links.size).toBe(2)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
// The valid link (referenced by an actual input) must survive
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
@@ -685,22 +674,50 @@ describe('_removeDuplicateLinks', () => {
|
||||
})
|
||||
|
||||
it('repairs input.link when it points to a removed duplicate', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
LiteGraph.registerNodeType('test/DupTestNode', TestNode)
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
|
||||
// Create a duplicate link
|
||||
const dupLink = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
'number',
|
||||
source.id,
|
||||
0,
|
||||
target.id,
|
||||
0
|
||||
)
|
||||
graph._links.set(dupLink.id, dupLink)
|
||||
source.outputs[0].links!.push(dupLink.id)
|
||||
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
// Point input.link to the duplicate (simulating corrupted state)
|
||||
target.inputs[0].link = dupLink.id
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
// input.link must point to whichever link survived
|
||||
const survivingId = graph._links.keys().next().value!
|
||||
expect(target.inputs[0].link).toBe(survivingId)
|
||||
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op when no duplicates exist', () => {
|
||||
const { graph } = createConnectedGraph()
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
|
||||
const source = LiteGraph.createNode('test/DupTestNode', 'Source')!
|
||||
const target = LiteGraph.createNode('test/DupTestNode', 'Target')!
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
const linksBefore = graph._links.size
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
@@ -721,56 +738,29 @@ describe('_removeDuplicateLinks', () => {
|
||||
subgraph.add(target)
|
||||
|
||||
source.connect(0, target, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
for (let i = 0; i < 3; i++) injectDuplicateLink(subgraph, source, target)
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dup = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
}
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
// Serialize and reconfigure - should clean up during configure
|
||||
const serialized = subgraph.asSerialisable()
|
||||
subgraph.configure(serialized as never)
|
||||
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
})
|
||||
|
||||
it('removes duplicate links via root graph configure()', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksRoot)
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
const survivingLink = graph._links.values().next().value!
|
||||
const targetNode = graph.getNodeById(survivingLink.target_id)!
|
||||
expect(targetNode.inputs[0].link).toBe(survivingLink.id)
|
||||
const sourceNode = graph.getNodeById(survivingLink.origin_id)!
|
||||
expect(sourceNode.outputs[0].links).toEqual([survivingLink.id])
|
||||
})
|
||||
|
||||
it('preserves link integrity after configure() with slot-shifted duplicates', () => {
|
||||
registerTestNodes()
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksSlotShift)
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
|
||||
const link = graph._links.values().next().value!
|
||||
const target = graph.getNodeById(link.target_id)!
|
||||
const linkedInput = target.inputs.find((inp) => inp.link === link.id)
|
||||
expect(linkedInput).toBeDefined()
|
||||
|
||||
const source = graph.getNodeById(link.origin_id)!
|
||||
expect(source.outputs[link.origin_slot].links).toContain(link.id)
|
||||
})
|
||||
|
||||
it('deduplicates links inside subgraph definitions during root configure()', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(duplicateLinksSubgraph)
|
||||
|
||||
const subgraph = graph.subgraphs.values().next().value!
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
const link = subgraph._links.values().next().value!
|
||||
const target = subgraph.getNodeById(link.target_id)!
|
||||
expect(target.inputs[0].link).toBe(link.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Unpacking', () => {
|
||||
@@ -800,21 +790,6 @@ describe('Subgraph Unpacking', () => {
|
||||
return rootGraph.createSubgraph(createTestSubgraphData())
|
||||
}
|
||||
|
||||
function duplicateExistingLink(graph: LGraph, source: LGraphNode) {
|
||||
const existingLink = graph._links.values().next().value!
|
||||
const dup = new LLink(
|
||||
++graph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
}
|
||||
|
||||
it('deduplicates links when unpacking subgraph with duplicate links', () => {
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
@@ -825,9 +800,24 @@ describe('Subgraph Unpacking', () => {
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a legitimate link
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph, sourceNode)
|
||||
// Manually add duplicate links (simulating the bug)
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
@@ -849,8 +839,21 @@ describe('Subgraph Unpacking', () => {
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Connect source output 0 → target input 0
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
duplicateExistingLink(subgraph, sourceNode)
|
||||
|
||||
// Add duplicate links to the same connection
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
@@ -13,13 +13,6 @@ import { usePromotionStore } from '@/stores/promotionStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import {
|
||||
groupLinksByTuple,
|
||||
purgeOrphanedLinks,
|
||||
repairInputLinks,
|
||||
selectSurvivorLink
|
||||
} from './linkDeduplication'
|
||||
|
||||
import type { DragAndScaleState } from './DragAndScale'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
import { LGraphGroup } from './LGraphGroup'
|
||||
@@ -175,6 +168,11 @@ export class LGraph
|
||||
static STATUS_STOPPED = 1
|
||||
static STATUS_RUNNING = 2
|
||||
|
||||
/** Generates a unique string key for a link's connection tuple. */
|
||||
static _linkTupleKey(link: LLink): string {
|
||||
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
|
||||
}
|
||||
|
||||
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
|
||||
static readonly ConfigureProperties = new Set([
|
||||
'nodes',
|
||||
@@ -1628,21 +1626,68 @@ export class LGraph
|
||||
* (origin_id, origin_slot, target_id, target_slot). Keeps the link
|
||||
* referenced by input.link and removes orphaned duplicates from
|
||||
* output.links and the graph's _links map.
|
||||
*
|
||||
* Three phases: group links by tuple, select the survivor, purge duplicates.
|
||||
*/
|
||||
_removeDuplicateLinks(): void {
|
||||
const groups = groupLinksByTuple(this._links)
|
||||
// Group all link IDs by their connection tuple.
|
||||
const groups = new Map<string, LinkId[]>()
|
||||
for (const [id, link] of this._links) {
|
||||
const key = LGraph._linkTupleKey(link)
|
||||
let group = groups.get(key)
|
||||
if (!group) {
|
||||
group = []
|
||||
groups.set(key, group)
|
||||
}
|
||||
group.push(id)
|
||||
}
|
||||
|
||||
for (const ids of groups.values()) {
|
||||
for (const [, ids] of groups) {
|
||||
if (ids.length <= 1) continue
|
||||
|
||||
const sampleLink = this._links.get(ids[0])!
|
||||
const node = this.getNodeById(sampleLink.target_id)
|
||||
const keepId = selectSurvivorLink(ids, node)
|
||||
|
||||
purgeOrphanedLinks(ids, keepId, this._links, (id) => this.getNodeById(id))
|
||||
repairInputLinks(ids, keepId, node)
|
||||
// Find which link ID is actually referenced by any input on the target
|
||||
// node. Cannot rely on target_slot index because widget-to-input
|
||||
// conversions during configure() can shift slot indices.
|
||||
let keepId: LinkId | undefined
|
||||
if (node) {
|
||||
for (const input of node.inputs ?? []) {
|
||||
const match = ids.find((id) => input.link === id)
|
||||
if (match != null) {
|
||||
keepId = match
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
keepId ??= ids[0]
|
||||
|
||||
for (const id of ids) {
|
||||
if (id === keepId) continue
|
||||
|
||||
const link = this._links.get(id)
|
||||
if (!link) continue
|
||||
|
||||
// Remove from origin node's output.links array
|
||||
const originNode = this.getNodeById(link.origin_id)
|
||||
if (originNode) {
|
||||
const output = originNode.outputs?.[link.origin_slot]
|
||||
if (output?.links) {
|
||||
const idx = output.links.indexOf(id)
|
||||
if (idx !== -1) output.links.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
this._links.delete(id)
|
||||
}
|
||||
|
||||
// Ensure input.link points to the surviving link
|
||||
if (node) {
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (ids.includes(input.link as LinkId) && input.link !== keepId) {
|
||||
input.link = keepId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { createMockCanvas2DContext } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
layoutStore: {
|
||||
querySlotAtPoint: vi.fn(),
|
||||
queryRerouteAtPoint: vi.fn(),
|
||||
getNodeLayoutRef: vi.fn(() => ({ value: null })),
|
||||
getSlotLayout: vi.fn(),
|
||||
setSource: vi.fn(),
|
||||
batchUpdateNodeBounds: vi.fn(),
|
||||
getCurrentSource: vi.fn(() => 'test'),
|
||||
getCurrentActor: vi.fn(() => 'test'),
|
||||
applyOperation: vi.fn(),
|
||||
pendingSlotSync: false
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockCtx(): CanvasRenderingContext2D {
|
||||
return createMockCanvas2DContext({
|
||||
translate: vi.fn(),
|
||||
scale: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn().mockReturnValue({ width: 50 }),
|
||||
closePath: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
clip: vi.fn(),
|
||||
setTransform: vi.fn(),
|
||||
roundRect: vi.fn(),
|
||||
getTransform: vi
|
||||
.fn()
|
||||
.mockReturnValue({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }),
|
||||
createLinearGradient: vi.fn().mockReturnValue({
|
||||
addColorStop: vi.fn()
|
||||
}),
|
||||
bezierCurveTo: vi.fn(),
|
||||
quadraticCurveTo: vi.fn(),
|
||||
isPointInStroke: vi.fn().mockReturnValue(false),
|
||||
globalAlpha: 1,
|
||||
textAlign: 'left' as CanvasTextAlign,
|
||||
textBaseline: 'alphabetic' as CanvasTextBaseline,
|
||||
shadowColor: '',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
imageSmoothingEnabled: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a link between two nodes by directly mutating graph state,
|
||||
* bypassing the layout store integration in connect().
|
||||
*/
|
||||
function createTestLink(
|
||||
graph: LGraph,
|
||||
sourceNode: LGraphNode,
|
||||
outputSlot: number,
|
||||
targetNode: LGraphNode,
|
||||
inputSlot: number
|
||||
): LLink {
|
||||
const linkId = ++graph.state.lastLinkId
|
||||
const link = new LLink(
|
||||
linkId,
|
||||
sourceNode.outputs[outputSlot].type,
|
||||
sourceNode.id,
|
||||
outputSlot,
|
||||
targetNode.id,
|
||||
inputSlot
|
||||
)
|
||||
graph._links.set(linkId, link)
|
||||
sourceNode.outputs[outputSlot].links ??= []
|
||||
sourceNode.outputs[outputSlot].links!.push(linkId)
|
||||
targetNode.inputs[inputSlot].link = linkId
|
||||
return link
|
||||
}
|
||||
|
||||
describe('drawConnections widget-input slot positioning', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let canvasElement: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia())
|
||||
|
||||
canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
canvasElement.height = 600
|
||||
canvasElement.getContext = vi.fn().mockReturnValue(createMockCtx())
|
||||
canvasElement.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 800,
|
||||
height: 600
|
||||
})
|
||||
|
||||
graph = new LGraph()
|
||||
canvas = new LGraphCanvas(canvasElement, graph, {
|
||||
skip_render: true
|
||||
})
|
||||
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.vueNodesMode = false
|
||||
})
|
||||
|
||||
it('arranges widget-input slots before rendering links', () => {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.pos = [0, 100]
|
||||
sourceNode.size = [150, 60]
|
||||
sourceNode.addOutput('out', 'STRING')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.pos = [300, 100]
|
||||
targetNode.size = [200, 120]
|
||||
const widget = targetNode.addWidget('text', 'value', '', null)
|
||||
const input = targetNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(targetNode)
|
||||
|
||||
createTestLink(graph, sourceNode, 0, targetNode, 0)
|
||||
|
||||
// Before drawConnections, input.pos should not be set
|
||||
expect(input.pos).toBeUndefined()
|
||||
|
||||
canvas.drawConnections(createMockCtx())
|
||||
|
||||
// After drawConnections, input.pos should be set to the widget row
|
||||
expect(input.pos).toBeDefined()
|
||||
expect(input.pos![1]).toBeGreaterThan(0)
|
||||
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
expect(input.pos![1]).toBe(widget.y + offset)
|
||||
})
|
||||
|
||||
it('does not re-arrange nodes whose widget-input slots already have positions', () => {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.pos = [0, 100]
|
||||
sourceNode.size = [150, 60]
|
||||
sourceNode.addOutput('out', 'STRING')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.pos = [300, 100]
|
||||
targetNode.size = [200, 120]
|
||||
targetNode.addWidget('text', 'value', '', null)
|
||||
const input = targetNode.addInput('value', 'STRING')
|
||||
input.widget = { name: 'value' }
|
||||
graph.add(targetNode)
|
||||
|
||||
createTestLink(graph, sourceNode, 0, targetNode, 0)
|
||||
|
||||
// Pre-arrange so input.pos is already set
|
||||
targetNode._setConcreteSlots()
|
||||
targetNode.arrange()
|
||||
expect(input.pos).toBeDefined()
|
||||
|
||||
const arrangeSpy = vi.spyOn(targetNode, 'arrange')
|
||||
|
||||
canvas.drawConnections(createMockCtx())
|
||||
|
||||
expect(arrangeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('positions widget-input slots when display name differs from slot.widget.name', () => {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.pos = [0, 100]
|
||||
sourceNode.size = [150, 60]
|
||||
sourceNode.addOutput('out', 'STRING')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.pos = [300, 100]
|
||||
targetNode.size = [200, 120]
|
||||
|
||||
// Widget has a display name that differs from the slot's widget.name
|
||||
// (simulates a renamed subgraph label)
|
||||
const widget = targetNode.addWidget('text', 'renamed_label', '', null)
|
||||
const input = targetNode.addInput('renamed_label', 'STRING')
|
||||
input.widget = { name: 'original_name' }
|
||||
|
||||
// Bind the widget as the slot's _widget (preferred over name-map lookup)
|
||||
input._widget = widget
|
||||
|
||||
graph.add(targetNode)
|
||||
createTestLink(graph, sourceNode, 0, targetNode, 0)
|
||||
|
||||
canvas.drawConnections(createMockCtx())
|
||||
|
||||
expect(input.pos).toBeDefined()
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
expect(input.pos![1]).toBe(widget.y + offset)
|
||||
})
|
||||
})
|
||||
@@ -5882,8 +5882,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
drawSnapGuide(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
item: Positionable,
|
||||
shape = RenderShape.ROUND,
|
||||
{ offsetToSlot }: { offsetToSlot?: boolean } = {}
|
||||
shape = RenderShape.ROUND
|
||||
) {
|
||||
const snapGuide = temp
|
||||
snapGuide.set(item.boundingRect)
|
||||
@@ -5891,10 +5890,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Not all items have pos equal to top-left of bounds
|
||||
const { pos } = item
|
||||
const offsetX = pos[0] - snapGuide[0]
|
||||
const offsetY =
|
||||
pos[1] -
|
||||
snapGuide[1] -
|
||||
(offsetToSlot ? LiteGraph.NODE_SLOT_HEIGHT * 0.7 : 0)
|
||||
const offsetY = pos[1] - snapGuide[1]
|
||||
|
||||
// Normalise boundingRect to pos to snap
|
||||
snapGuide[0] += offsetX
|
||||
@@ -5954,19 +5950,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctx.globalAlpha = this.editor_alpha
|
||||
// for every node
|
||||
const nodes = graph._nodes
|
||||
|
||||
// Ensure widget-input slot positions are computed before rendering links.
|
||||
// arrange() sets input.pos for widget-backed slots, but is normally called
|
||||
// in drawNode (foreground canvas). drawConnections runs on the background
|
||||
// canvas, which may render before drawNode has executed for this frame.
|
||||
// The dirty flag avoids a per-frame O(N) scan of all inputs.
|
||||
for (const node of nodes) {
|
||||
if (node.flags.collapsed || !node._widgetSlotsDirty) continue
|
||||
|
||||
node._setConcreteSlots()
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
// for every input (we render just inputs because it is easier as every slot can only have one input)
|
||||
const { inputs } = node
|
||||
@@ -6084,9 +6067,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.isDragging &&
|
||||
this.selectedItems.has(reroute)
|
||||
) {
|
||||
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE, {
|
||||
offsetToSlot: true
|
||||
})
|
||||
this.drawSnapGuide(ctx, reroute, RenderShape.CIRCLE)
|
||||
}
|
||||
reroute.draw(ctx, this._pattern)
|
||||
|
||||
|
||||
@@ -295,12 +295,6 @@ export class LGraphNode
|
||||
*/
|
||||
freeWidgetSpace?: number
|
||||
|
||||
/**
|
||||
* Set to true when widget-backed input slot positions need recalculation.
|
||||
* Cleared after arrange() runs. Avoids per-frame O(N) scans in drawConnections.
|
||||
*/
|
||||
_widgetSlotsDirty = false
|
||||
|
||||
locked?: boolean
|
||||
|
||||
/** Execution order, automatically computed during run @see {@link LGraph.computeExecutionOrder} */
|
||||
@@ -1998,7 +1992,6 @@ export class LGraphNode
|
||||
this.widgets ||= []
|
||||
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
|
||||
this.widgets.push(widget)
|
||||
this._widgetSlotsDirty = true
|
||||
|
||||
// Only register with store if node has a valid ID (is already in a graph).
|
||||
// If the node isn't in a graph yet (id === -1), registration happens
|
||||
@@ -2038,11 +2031,9 @@ export class LGraphNode
|
||||
if (input._widget === widget) {
|
||||
input._widget = undefined
|
||||
input.widget = undefined
|
||||
input.pos = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
this._widgetSlotsDirty = true
|
||||
|
||||
widget.onRemove?.()
|
||||
this.widgets.splice(widgetIndex, 1)
|
||||
@@ -4215,29 +4206,40 @@ export class LGraphNode
|
||||
* Arranges the layout of the node's widget input slots.
|
||||
*/
|
||||
private _arrangeWidgetInputSlots(): void {
|
||||
if (!this.widgets?.length) return
|
||||
if (!this.widgets) return
|
||||
|
||||
// Build a name→widget map for fast lookup.
|
||||
const widgetByName = new Map<string, IBaseWidget>()
|
||||
for (const w of this.widgets) widgetByName.set(w.name, w)
|
||||
const slotByWidgetName = new Map<
|
||||
string,
|
||||
INodeInputSlot & { index: number }
|
||||
>()
|
||||
|
||||
// Set widget-backed slot positions from widget Y coordinates.
|
||||
// In Vue mode, promoted widget inputs are not rendered as <InputSlot>
|
||||
// components (NodeSlots filters them out), so they have no DOM-registered
|
||||
// position. input.pos serves as the fallback for getSlotPosition().
|
||||
for (const [i, slot] of this._concreteInputs.entries()) {
|
||||
for (const [i, slot] of this.inputs.entries()) {
|
||||
if (!isWidgetInputSlot(slot)) continue
|
||||
|
||||
// Prefer the slot's direct _widget binding (1:1 for promoted inputs).
|
||||
// Fall back to name-map lookup for regular nodes without _widget set.
|
||||
// Note: the name-map is ambiguous if two promoted inputs share a label;
|
||||
// _widget avoids this since it is a direct reference.
|
||||
const widget = slot._widget ?? widgetByName.get(slot.widget.name)
|
||||
if (!widget) continue
|
||||
slotByWidgetName.set(slot.widget.name, { ...slot, index: i })
|
||||
}
|
||||
if (!slotByWidgetName.size) return
|
||||
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.pos = [offset, widget.y + offset]
|
||||
this._measureSlot(slot, i, true)
|
||||
// Only set custom pos if not using Vue positioning
|
||||
// Vue positioning calculates widget slot positions dynamically
|
||||
if (!LiteGraph.vueNodesMode) {
|
||||
for (const widget of this.widgets) {
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
const actualSlot = this._concreteInputs[slot.index]
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
actualSlot.pos = [offset, widget.y + offset]
|
||||
this._measureSlot(actualSlot, slot.index, true)
|
||||
}
|
||||
} else {
|
||||
// For Vue positioning, just measure the slots without setting pos
|
||||
for (const widget of this.widgets) {
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
this._measureSlot(this._concreteInputs[slot.index], slot.index, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4267,7 +4269,6 @@ export class LGraphNode
|
||||
: 0
|
||||
this._arrangeWidgets(widgetStartY)
|
||||
this._arrangeWidgetInputSlots()
|
||||
this._widgetSlotsDirty = false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
ReadOnlyRect,
|
||||
ReadonlyLinkNetwork
|
||||
} from './interfaces'
|
||||
import { LiteGraph } from './litegraph'
|
||||
import { distance, isPointInRect } from './measure'
|
||||
import type { Serialisable, SerialisableReroute } from './types/serialisation'
|
||||
|
||||
@@ -429,10 +428,9 @@ export class Reroute
|
||||
snapToGrid(snapTo: number): boolean {
|
||||
if (!snapTo) return false
|
||||
|
||||
const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7
|
||||
const { pos } = this
|
||||
pos[0] = snapTo * Math.round(pos[0] / snapTo)
|
||||
pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY
|
||||
pos[1] = snapTo * Math.round(pos[1] / snapTo)
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
|
||||
/**
|
||||
* Root graph with two nodes (Source, Target) connected by one valid link
|
||||
* plus two duplicate links sharing the same connection tuple.
|
||||
* Tests that configure() deduplicates to a single link.
|
||||
*/
|
||||
export const duplicateLinksRoot: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000001',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 3,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: null }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [1, 2, 3] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: 1 }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [] }],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Root graph with slot-shifted duplicates. Target node has an extra input
|
||||
* (simulating widget-to-input conversion) that shifts the connected input
|
||||
* from slot 0 to slot 1. Link 1 is valid (referenced by input.link),
|
||||
* link 2 is a duplicate with the original (pre-shift) target_slot.
|
||||
*/
|
||||
export const duplicateLinksSlotShift: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000002',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 2,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'input_0', type: 'number', link: null }],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [1, 2] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/DupTestNode',
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: 'extra_widget', type: 'number', link: null },
|
||||
{ name: 'input_0', type: 'number', link: 1 }
|
||||
],
|
||||
outputs: [{ name: 'output_0', type: 'number', links: [] }],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Root graph containing a SubgraphNode whose subgraph definition has
|
||||
* duplicate links. Tests that configure() deduplicates links inside
|
||||
* subgraph definitions during root-level configure.
|
||||
*/
|
||||
export const duplicateLinksSubgraph: SerialisableGraph = {
|
||||
id: 'dd000000-0000-4000-8000-000000000003',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 1,
|
||||
lastLinkId: 0,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'dd111111-1111-4111-8111-111111111111',
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
id: 'dd111111-1111-4111-8111-111111111111',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: {
|
||||
lastNodeId: 2,
|
||||
lastLinkId: 3,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
name: 'Subgraph With Duplicates',
|
||||
config: {},
|
||||
inputNode: { id: -10, bounding: [0, 100, 120, 60] },
|
||||
outputNode: { id: -20, bounding: [500, 100, 120, 60] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [
|
||||
{
|
||||
id: 1,
|
||||
type: 'test/Source',
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [{ name: 'out', type: 'number', links: [1, 2, 3] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'test/Target',
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [{ name: 'in', type: 'number', link: 1 }],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
groups: [],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origin_id: 1,
|
||||
origin_slot: 0,
|
||||
target_id: 2,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
}
|
||||
],
|
||||
extra: {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user