Compare commits

..

11 Commits

Author SHA1 Message Date
Benjamin Lu
16e54b7b01 fix: drop unused menu type exports 2026-03-21 12:00:09 -07:00
Benjamin Lu
d07c8750e6 refactor: consolidate job and asset menus 2026-03-21 11:59:23 -07:00
Alexander Brown
02322a13d7 Merge branch 'main' into codex/trigger-owned-context-menus 2026-03-20 11:31:16 -07:00
Benjamin Lu
bb4abb599e test: use real i18n in menu tests 2026-03-19 21:42:17 -07:00
Benjamin Lu
39fcc230e3 test: fix job history menu item selector 2026-03-19 20:20:31 -07:00
Benjamin Lu
e8e18f0213 fix: restore reka menu item semantics 2026-03-19 20:01:30 -07:00
Benjamin Lu
8bc72145b3 merge: resolve origin/main into codex/trigger-owned-context-menus 2026-03-19 19:31:23 -07:00
Benjamin Lu
69b3930dd6 fix: keep sidebar menus usable 2026-03-19 19:24:21 -07:00
Benjamin Lu
ac06a08d92 Merge branch 'main' into codex/trigger-owned-context-menus 2026-03-19 11:51:59 -07:00
Benjamin Lu
ef546e3da8 fix: address context menu review feedback 2026-03-19 11:49:45 -07:00
Benjamin Lu
3af52bd4e8 refactor: use trigger-owned context menus 2026-03-19 03:27:01 -07:00
99 changed files with 2431 additions and 4608 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -3,6 +3,9 @@ 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) {}
@@ -13,6 +16,26 @@ 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,
@@ -35,6 +58,7 @@ export class QueueHelper {
})
})
await this.page.route('**/api/queue', this.queueRouteHandler)
await this.installJobsRoute()
}
/**
@@ -43,6 +67,30 @@ 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] = {
@@ -61,6 +109,44 @@ 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)
}
/**
@@ -75,5 +161,9 @@ 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
}
}
}

View File

@@ -64,7 +64,6 @@ export const TestIds = {
},
builder: {
ioItem: 'builder-io-item',
ioItemTitle: 'builder-io-item-title',
widgetActionsMenu: 'widget-actions-menu'
},
breadcrumb: {

View File

@@ -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')

View File

@@ -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)
})
})

View File

@@ -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)

View File

@@ -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])
)
})
})

View File

@@ -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"]')

View File

@@ -0,0 +1,38 @@
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('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()
})
})

View File

@@ -0,0 +1,53 @@
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('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()
})
})

View File

@@ -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: {

View File

@@ -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']
},

View File

@@ -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

View File

@@ -36,6 +36,5 @@
"targetName": "e2e"
}
}
],
"analytics": false
]
}

View File

@@ -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:",

View File

@@ -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:",

View File

@@ -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}]");

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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')

View File

@@ -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>

View File

@@ -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(

View File

@@ -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 }"

View File

@@ -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>

View File

@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
const mountComponent = ({ closeOnScroll = false } = {}) =>
mount(ContextMenu, {
attachTo: document.body,
props: {
closeOnScroll,
contentClass: 'context-menu-content'
},
slots: {
default: '<button class="context-trigger" type="button">Trigger</button>',
content:
'<div class="context-menu-content-inner" role="menuitem">Action</div>'
}
})
async function openMenu() {
const trigger = document.body.querySelector('.context-trigger')
if (!(trigger instanceof HTMLElement)) {
throw new Error('Context trigger element not found')
}
trigger.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
button: 2
})
)
await waitForMenuUpdate()
}
const isMenuVisible = () =>
document.body.querySelector('.context-menu-content-inner') !== null
const waitForMenuUpdate = async () => {
await nextTick()
await nextTick()
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('ContextMenu', () => {
it('opens from the slotted context-menu trigger', async () => {
const wrapper = mountComponent()
await openMenu()
expect(isMenuVisible()).toBe(true)
expect(
document.body.querySelectorAll('[role="menuitem"]').length
).toBeGreaterThan(0)
wrapper.unmount()
})
it('closes on scroll when enabled', async () => {
const wrapper = mountComponent({ closeOnScroll: true })
await openMenu()
window.dispatchEvent(new Event('scroll'))
await waitForMenuUpdate()
expect(isMenuVisible()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import {
ContextMenuContent,
ContextMenuItem,
injectContextMenuRootContext,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { defineComponent } from 'vue'
defineOptions({
inheritAttrs: false
})
const {
contentClass,
collisionPadding = 8,
closeOnScroll = false
} = defineProps<{
contentClass?: string
collisionPadding?: number
closeOnScroll?: boolean
}>()
const ContextMenuContentProvider = defineComponent({
name: 'ContextMenuContentProvider',
props: {
closeOnScroll: {
type: Boolean,
default: false
}
},
setup(providerProps, { slots }) {
const rootContext = injectContextMenuRootContext()
function closeMenu() {
rootContext.onOpenChange(false)
}
useEventListener(
window,
'scroll',
() => {
if (providerProps.closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
return () =>
slots.default?.({
close: closeMenu,
itemComponent: ContextMenuItem,
separatorComponent: ContextMenuSeparator
})
}
})
</script>
<template>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<slot />
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
:collision-padding="collisionPadding"
v-bind="$attrs"
:class="contentClass"
>
<ContextMenuContentProvider :close-on-scroll="closeOnScroll">
<template #default="{ close, itemComponent, separatorComponent }">
<slot
name="content"
:close="close"
:item-component="itemComponent"
:separator-component="separatorComponent"
/>
</template>
</ContextMenuContentProvider>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
@@ -18,7 +21,23 @@ defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const open = defineModel<boolean>('open', { default: false })
const {
entries,
icon,
to,
itemClass: itemProp,
contentClass: contentProp,
buttonSize,
buttonClass,
align,
showArrow = true,
side = 'bottom',
sideOffset = 5,
collisionPadding = 10,
closeOnScroll = false
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -26,6 +45,12 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
align?: 'start' | 'center' | 'end'
showArrow?: boolean
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
collisionPadding?: number
closeOnScroll?: boolean
}>()
const itemClass = computed(() =>
@@ -41,10 +66,25 @@ const contentClass = computed(() =>
contentProp
)
)
function closeMenu() {
open.value = false
}
useEventListener(
window,
'scroll',
() => {
if (closeOnScroll) {
closeMenu()
}
},
{ capture: true, passive: true }
)
</script>
<template>
<DropdownMenuRoot>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
@@ -55,22 +95,39 @@ const contentClass = computed(() =>
<DropdownMenuPortal :to>
<DropdownMenuContent
side="bottom"
:side-offset="5"
:collision-padding="10"
:align
:side
:side-offset="sideOffset"
:collision-padding="collisionPadding"
v-bind="$attrs"
:class="contentClass"
>
<slot :item-class>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
<slot
name="content"
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<slot
:close="closeMenu"
:item-class="itemClass"
:item-component="DropdownMenuItem"
:separator-component="DropdownMenuSeparator"
>
<DropdownItem
v-for="(item, index) in entries ?? []"
:key="toValue(item.label) ?? index"
:item-class
:content-class
:item
/>
</slot>
</slot>
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
<DropdownMenuArrow
v-if="showArrow"
class="fill-base-background stroke-border-subtle"
/>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>

View File

@@ -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'])

View File

@@ -0,0 +1,70 @@
<template>
<div :class="panelClass">
<template v-for="entry in entries" :key="entry.key">
<component
:is="separatorComponent"
v-if="entry.kind === 'divider'"
:class="separatorWrapperClass"
>
<div :class="separatorClass" />
</component>
<component
:is="itemComponent"
v-else
as-child
:disabled="entry.disabled"
:text-value="entry.label"
@select="emit('action', entry)"
>
<Button
:variant="buttonVariant"
:size="buttonSize"
:class="buttonClass"
:disabled="entry.disabled"
>
<i v-if="entry.icon" :class="cn(entry.icon, iconClass)" />
<span :class="labelClass">{{ entry.label }}</span>
</Button>
</component>
</template>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import type { ButtonVariants } from '@/components/ui/button/button.variants'
import Button from '@/components/ui/button/Button.vue'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { cn } from '@/utils/tailwindUtil'
const {
entries,
itemComponent,
separatorComponent,
panelClass,
separatorWrapperClass,
separatorClass,
buttonClass,
iconClass,
labelClass,
buttonVariant = 'secondary',
buttonSize = 'sm'
} = defineProps<{
entries: MenuEntry[]
itemComponent: Component
separatorComponent: Component
panelClass: string
separatorWrapperClass: string
separatorClass: string
buttonClass: string
iconClass: string
labelClass?: string
buttonVariant?: ButtonVariants['variant']
buttonSize?: ButtonVariants['size']
}>()
const emit = defineEmits<{
action: [entry: MenuActionEntry]
}>()
</script>

View File

@@ -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')
})
})

View File

@@ -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>

View File

@@ -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>'
})
]
}

View File

@@ -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>

View File

@@ -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'

View File

@@ -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>

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { JobListItem } from '@/composables/queue/useJobList'
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({ jobMenuEntries: [] })
useJobMenu: () => ({ getJobMenuEntries: () => [] })
}))
vi.mock('@/composables/useErrorHandling', () => ({
@@ -30,10 +30,6 @@ const JobAssetsListStub = {
template: '<div class="job-assets-list-stub" />'
}
const JobContextMenuStub = {
template: '<div />'
}
const createJob = (): JobListItem => ({
id: 'job-1',
title: 'Job 1',
@@ -56,8 +52,7 @@ const mountComponent = () =>
stubs: {
QueueOverlayHeader: QueueOverlayHeaderStub,
JobFiltersBar: JobFiltersBarStub,
JobAssetsList: JobAssetsListStub,
JobContextMenu: JobContextMenuStub
JobAssetsList: JobAssetsListStub
}
}
})

View File

@@ -23,36 +23,28 @@
<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 { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
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'
@@ -78,13 +70,10 @@ 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 { jobMenuEntries } = useJobMenu(
() => currentMenuItem.value,
(item) => emit('viewItem', item)
const { getJobMenuEntries } = useJobMenu(undefined, (item) =>
emit('viewItem', item)
)
const onCancelItemEvent = (item: JobListItem) => {
@@ -95,14 +84,9 @@ const onDeleteItemEvent = (item: JobListItem) => {
emit('deleteItem', item)
}
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()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -3,11 +3,16 @@ 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'
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: {
name: 'JobDetailsPopover',
template: '<div class="job-details-popover-stub" />'
}
}))
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -32,43 +37,34 @@ vi.mock('vue-i18n', () => {
}
})
const createResultItem = (
const createPreviewOutput = (
filename: string,
mediaType: string = 'images'
): ResultItemImpl => {
const item = new ResultItemImpl({
): NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']> =>
({
filename,
subfolder: '',
type: 'output',
nodeId: 'node-1',
mediaType
})
Object.defineProperty(item, 'url', {
get: () => `/api/view/${filename}`
})
return item
}
mediaType,
isImage: mediaType === 'images',
isVideo: mediaType === 'video',
isAudio: mediaType === 'audio',
is3D: mediaType === 'model',
url: `/api/view/${filename}`
}) as NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
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 createTaskRef = (
preview?: NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
): JobListItem['taskRef'] =>
({
workflowId: 'workflow-1',
previewOutput: preview
}) as JobListItem['taskRef']
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
id: 'job-1',
title: 'Job 1',
meta: 'meta',
state: 'completed',
taskRef: createTaskRef(createResultItem('job-1.png')),
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
...overrides
})
@@ -82,7 +78,10 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
]
return mount(JobAssetsList, {
props: { displayedJobGroups },
props: {
displayedJobGroups,
getMenuEntries: () => []
},
global: {
stubs: {
teleport: true,
@@ -147,7 +146,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
})
const wrapper = mountJobAssetsList([job])
@@ -164,7 +163,7 @@ describe('JobAssetsList', () => {
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
})
const wrapper = mountJobAssetsList([job])
@@ -179,7 +178,7 @@ describe('JobAssetsList', () => {
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
taskRef: createTaskRef(createResultItem('job-1.png'))
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
})
const wrapper = mountJobAssetsList([job])

View File

@@ -15,64 +15,98 @@
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<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
<ContextMenu
content-class="z-1700 bg-transparent p-0 font-inter shadow-lg"
>
<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)"
>
<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>
<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
>
<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"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
@update:open="onActionsMenuOpenChange(job.id, $event)"
>
<template #button>
<Button
class="job-actions-menu-trigger"
variant="secondary"
size="icon"
:aria-label="t('g.more')"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template
#content="{ close, itemComponent, separatorComponent }"
>
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event, close)"
/>
</template>
</DropdownMenu>
</template>
</AssetsListItem>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getMenuEntries(job)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="jobMenuPanelProps"
@action="onMenuAction($event, close)"
/>
</template>
</AssetsListItem>
</ContextMenu>
</div>
</div>
</div>
@@ -80,7 +114,7 @@
<Teleport to="body">
<div
v-if="activeDetails && popoverPosition"
class="job-details-popover fixed z-50"
class="job-details-popover fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`
@@ -100,9 +134,13 @@
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import MenuPanel from '@/components/common/MenuPanel.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 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'
@@ -110,17 +148,32 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const { displayedJobGroups, getMenuEntries } = defineProps<{
displayedJobGroups: JobGroup[]
getMenuEntries: (item: JobListItem) => MenuEntry[]
}>()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void
(e: 'menu', item: JobListItem, ev: MouseEvent): void
(e: 'menu-action', entry: MenuActionEntry): void
(e: 'viewItem', item: JobListItem): void
}>()
const jobMenuPanelProps = {
panelClass:
'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',
separatorWrapperClass: 'px-2 py-1',
separatorClass: 'h-px bg-interface-stroke',
buttonVariant: 'textonly',
buttonClass:
'w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover',
iconClass: 'block size-4 shrink-0 leading-none text-text-secondary'
} as const
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 {
@@ -188,6 +241,26 @@ 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, close: () => void) {
close()
emit('menu-action', entry)
}
function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}

View File

@@ -1,195 +0,0 @@
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()
})
})

View File

@@ -1,118 +0,0 @@
<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>

View File

@@ -9,7 +9,7 @@
<Teleport to="body">
<div
v-if="!isPreviewVisible && showDetails && popoverPosition"
class="fixed z-50"
class="fixed z-1700"
: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-50"
class="fixed z-1700"
:style="{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`

View File

@@ -8,16 +8,38 @@
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
@click="emit('select-asset', item.asset)"
@context-menu="emit('context-menu', $event, item.asset)"
@zoom="emit('zoom', item.asset)"
@output-count-click="emit('output-count-click', item.asset)"
/>
<ContextMenu
close-on-scroll
content-class="z-1700 bg-transparent p-0 shadow-lg"
>
<MediaAssetCard
:asset="item.asset"
:selected="isSelected(item.asset.id)"
:show-output-count="showOutputCount(item.asset)"
:output-count="getOutputCount(item.asset)"
:show-delete-button
:selected-assets
:is-bulk-mode
@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)"
/>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event, close)"
/>
</template>
</ContextMenu>
</template>
</VirtualGrid>
</div>
@@ -26,24 +48,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
const {
assets,
isSelected,
showOutputCount,
getOutputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = 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 { 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 }
@@ -54,6 +108,22 @@ 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, close: () => void) {
close()
await entry.onClick?.()
}
const gridStyle = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
@@ -7,9 +8,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import AssetsSidebarListView from './AssetsSidebarListView.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
useMediaAssetMenu: () => ({
getMenuEntries: () => []
})
}))
@@ -19,7 +20,18 @@ vi.mock('@/stores/assetsStore', () => ({
})
}))
const VirtualGridStub = defineComponent({
const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
messages: {
en: {}
}
})
const VirtualGridStub = {
name: 'VirtualGrid',
props: {
items: {
@@ -29,7 +41,41 @@ const VirtualGridStub = defineComponent({
},
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 /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
}
const DropdownMenuStub = {
name: 'DropdownMenu',
props: {
open: {
type: Boolean,
default: false
}
},
template:
'<div class="dropdown-menu-stub"><slot name="button" /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
}
const ButtonComponentStub = {
name: 'AppButton',
template: '<button class="button-stub" type="button"><slot /></button>'
}
const MenuPanelStub = {
name: 'MenuPanel',
template: '<div class="menu-panel-stub" />'
}
const buildAsset = (id: string, name: string): AssetItem =>
({
@@ -53,12 +99,35 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
toggleStack: async () => {}
},
global: {
plugins: [i18n],
stubs: {
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,
DropdownMenu: DropdownMenuStub,
MenuPanel: MenuPanelStub,
VirtualGrid: VirtualGridStub
}
}
})
describe('AssetsSidebarListView', () => {
it('marks mp4 assets as video previews', () => {
const videoAsset = {
@@ -131,4 +200,46 @@ describe('AssetsSidebarListView', () => {
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
})
it('keeps row actions mounted while the dropdown is open', 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()
})
})

View File

@@ -16,49 +16,85 @@
>
<i class="pi pi-trash text-xs" />
</LoadingOverlay>
<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)"
<ContextMenu
close-on-scroll
content-class="z-1700 bg-transparent p-0 shadow-lg"
>
<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)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
<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)"
>
<template v-if="shouldShowActionsMenu(item.asset.id)" #actions>
<DropdownMenu
:open="openActionsAssetId === item.asset.id"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
close-on-scroll
@update:open="onActionsMenuOpenChange(item.asset.id, $event)"
>
<template #button>
<Button
variant="secondary"
size="icon"
:aria-label="t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template
#content="{ close, itemComponent, separatorComponent }"
>
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event, close)"
/>
</template>
</DropdownMenu>
</template>
</AssetsListItem>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries(item.asset)"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event, close)"
/>
</template>
</AssetsListItem>
</ContextMenu>
</div>
</template>
</VirtualGrid>
@@ -69,16 +105,23 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContextMenu from '@/components/common/ContextMenu.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.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 { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
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,
@@ -92,13 +135,19 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
toggleStack,
showDeleteButton,
selectedAssets,
isBulkMode
} = 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()
@@ -106,12 +155,27 @@ 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',
@@ -128,6 +192,17 @@ 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'
}
@@ -180,6 +255,28 @@ 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, close: () => void) {
close()
await entry.onClick?.()
}
function onAssetEnter(assetId: string) {
hoveredAssetId.value = assetId
}

View File

@@ -94,10 +94,18 @@
: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
@@ -106,8 +114,16 @@
: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"
@@ -174,24 +190,6 @@
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">
@@ -200,14 +198,12 @@ import {
useDebounceFn,
useElementHover,
useResizeObserver,
useStorage,
useTimeoutFn
useStorage
} from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import {
computed,
defineAsyncComponent,
nextTick,
onMounted,
onUnmounted,
ref,
@@ -224,9 +220,7 @@ 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'
@@ -236,7 +230,6 @@ 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'
@@ -267,9 +260,6 @@ 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(() => {
@@ -277,14 +267,6 @@ 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
@@ -502,26 +484,6 @@ 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()

View File

@@ -5,6 +5,49 @@ import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
default: {
name: 'JobDetailsPopover',
template: '<div class="job-details-popover-stub" />'
}
}))
vi.mock('@/components/queue/JobHistoryActionsMenu.vue', () => ({
default: {
name: 'JobHistoryActionsMenu',
template: '<div />'
}
}))
vi.mock('@/components/queue/job/JobFilterActions.vue', () => ({
default: {
name: 'JobFilterActions',
template: '<div />'
}
}))
vi.mock('@/components/queue/job/JobFilterTabs.vue', () => ({
default: {
name: 'JobFilterTabs',
template: '<div />'
}
}))
vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
}
}))
vi.mock('@/components/sidebar/tabs/queue/MediaLightbox.vue', () => ({
default: {
name: 'MediaLightbox',
template: '<div />'
}
}))
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -52,7 +95,7 @@ vi.mock('@/composables/queue/useJobList', async () => {
vi.mock('@/composables/queue/useJobMenu', () => ({
useJobMenu: () => ({
jobMenuEntries: [],
getJobMenuEntries: () => [],
cancelJob: vi.fn()
})
}))
@@ -130,7 +173,6 @@ function mountComponent() {
JobFilterTabs: true,
JobFilterActions: true,
JobHistoryActionsMenu: true,
JobContextMenu: true,
ResultGallery: true,
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub

View File

@@ -48,15 +48,11 @@
<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"
@@ -67,15 +63,14 @@
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue'
import { computed, defineAsyncComponent } 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 { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuActionEntry } from '@/types/menuTypes'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
@@ -182,13 +177,7 @@ const onInspectAsset = (item: JobListItem) => {
void onViewItem(item)
}
const currentMenuItem = ref<JobListItem | null>(null)
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
const { jobMenuEntries, cancelJob } = useJobMenu(
() => currentMenuItem.value,
onInspectAsset
)
const { getJobMenuEntries, cancelJob } = useJobMenu(undefined, onInspectAsset)
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await cancelJob(item)
@@ -199,14 +188,9 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
await queueStore.delete(item.taskRef)
})
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()
})
const onJobMenuAction = wrapWithErrorHandlingAsync(
async (entry: MenuActionEntry) => {
if (entry.onClick) await entry.onClick()
}
)
</script>

View File

@@ -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>

View File

@@ -19,13 +19,10 @@ 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:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
outline:
'border border-solid border-border-subtle bg-transparent text-base-foreground hover:bg-secondary-background-hover'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -54,11 +51,9 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'link',
'base',
'overlay-white',
'gradient',
'outline'
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import type { MenuEntry } from '@/composables/queue/useJobMenu'
import type { MenuEntry } from '@/types/menuTypes'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false

View File

@@ -20,20 +20,10 @@ 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.
*
@@ -117,10 +107,10 @@ export function useJobMenu(
// 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 () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const addOutputLoaderNode = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
@@ -168,10 +158,10 @@ export function useJobMenu(
/**
* Trigger a download of the job's previewable output asset.
*/
const downloadPreviewAsset = () => {
const item = currentMenuItem()
if (!item) return
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
const downloadPreviewAsset = (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
if (!result) return
downloadFile(result.url)
}
@@ -179,14 +169,14 @@ export function useJobMenu(
/**
* Export the workflow JSON attached to the job.
*/
const exportJobWorkflow = async () => {
const item = currentMenuItem()
if (!item) return
const data = await getJobWorkflow(item.id)
const exportJobWorkflow = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const data = await getJobWorkflow(target.id)
if (!data) return
const settingStore = useSettingStore()
let filename = `Job ${item.id}.json`
let filename = `Job ${target.id}.json`
if (settingStore.get('Comfy.PromptFilename')) {
const input = await useDialogService().prompt({
@@ -203,10 +193,10 @@ export function useJobMenu(
downloadBlob(filename, blob)
}
const deleteJobAsset = async () => {
const item = currentMenuItem()
if (!item) return
const task = item.taskRef as TaskItemImpl | undefined
const deleteJobAsset = async (item?: JobListItem | null) => {
const target = resolveItem(item)
if (!target) return
const task = target.taskRef as TaskItemImpl | undefined
const preview = task?.previewOutput
if (!task || !preview) return
@@ -237,11 +227,11 @@ export function useJobMenu(
st('queue.jobMenu.cancelJob', 'Cancel job')
)
const jobMenuEntries = computed<MenuEntry[]>(() => {
const item = currentMenuItem()
const state = item?.state
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
const target = resolveItem(item)
const state = target?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const hasPreviewAsset = !!target?.taskRef?.previewOutput
if (state === 'completed') {
return [
{
@@ -251,8 +241,7 @@ export function useJobMenu(
disabled: !hasPreviewAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()
if (item) onInspectAsset(item)
if (target) onInspectAsset(target)
}
: undefined
},
@@ -264,34 +253,34 @@ export function useJobMenu(
),
icon: 'icon-[comfy--node]',
disabled: !hasPreviewAsset,
onClick: addOutputLoaderNode
onClick: () => addOutputLoaderNode(target)
},
{
key: 'download',
label: st('queue.jobMenu.download', 'Download'),
icon: 'icon-[lucide--download]',
disabled: !hasPreviewAsset,
onClick: downloadPreviewAsset
onClick: () => downloadPreviewAsset(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{
key: 'export-workflow',
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
icon: 'icon-[comfy--file-output]',
onClick: exportJobWorkflow
onClick: () => exportJobWorkflow(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd3' },
...(hasPreviewAsset
@@ -300,7 +289,7 @@ export function useJobMenu(
key: 'delete',
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
icon: 'icon-[lucide--trash-2]',
onClick: deleteJobAsset
onClick: () => deleteJobAsset(target)
}
]
: [])
@@ -312,33 +301,33 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowFailedLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{
key: 'copy-error',
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
icon: 'icon-[lucide--copy]',
onClick: copyErrorMessage
onClick: () => copyErrorMessage(target)
},
{
key: 'report-error',
label: st('queue.jobMenu.reportError', 'Report error'),
icon: 'icon-[lucide--message-circle-warning]',
onClick: reportError
onClick: () => reportError(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'delete',
label: st('queue.jobMenu.removeJob', 'Remove job'),
icon: 'icon-[lucide--circle-minus]',
onClick: removeFailedJob
onClick: () => removeFailedJob(target?.taskRef)
}
]
}
@@ -347,27 +336,30 @@ export function useJobMenu(
key: 'open-workflow',
label: jobMenuOpenWorkflowLabel.value,
icon: 'icon-[comfy--workflow]',
onClick: openJobWorkflow
onClick: () => openJobWorkflow(target)
},
{ kind: 'divider', key: 'd1' },
{
key: 'copy-id',
label: jobMenuCopyJobIdLabel.value,
icon: 'icon-[lucide--copy]',
onClick: copyJobId
onClick: () => copyJobId(target)
},
{ kind: 'divider', key: 'd2' },
{
key: 'cancel-job',
label: jobMenuCancelLabel.value,
icon: 'icon-[lucide--x]',
onClick: cancelJob
onClick: () => cancelJob(target)
}
]
})
}
const jobMenuEntries = computed<MenuEntry[]>(() => buildJobMenuEntries())
return {
jobMenuEntries,
getJobMenuEntries: buildJobMenuEntries,
openJobWorkflow,
copyJobId,
cancelJob,

View File

@@ -1,108 +0,0 @@
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)
})
})

View File

@@ -1,60 +0,0 @@
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
})
}
}

View File

@@ -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
}))
}

View File

@@ -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()
})
})

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -18,11 +18,6 @@ import {
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
import {
duplicateLinksRoot,
duplicateLinksSlotShift,
duplicateLinksSubgraph
} from './__fixtures__/duplicateLinks'
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
import { nestedSubgraphProxyWidgets } from './__fixtures__/nestedSubgraphProxyWidgets'
import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
@@ -565,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)
@@ -610,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()
@@ -624,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.
@@ -633,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)
@@ -647,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()
@@ -683,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', () => {
@@ -762,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()
@@ -787,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] })
@@ -811,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)

View File

@@ -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
}
}
}
}
}

View File

@@ -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: {}
}
]
}
}

View File

@@ -1,82 +0,0 @@
import type { LGraphNode, NodeId } from './LGraphNode'
import type { LLink, LinkId } from './LLink'
/** Generates a unique string key for a link's connection tuple. */
function linkTupleKey(link: LLink): string {
return `${link.origin_id}\0${link.origin_slot}\0${link.target_id}\0${link.target_slot}`
}
/** Groups all link IDs by their connection tuple key. */
export function groupLinksByTuple(
links: Map<LinkId, LLink>
): Map<string, LinkId[]> {
const groups = new Map<string, LinkId[]>()
for (const [id, link] of links) {
const key = linkTupleKey(link)
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(id)
}
return groups
}
/**
* Finds the link ID actually referenced by an input on the target node.
* Cannot rely on target_slot index because widget-to-input conversions
* during configure() can shift slot indices.
*/
export function selectSurvivorLink(
ids: LinkId[],
node: LGraphNode | null
): LinkId {
if (!node) return ids[0]
for (const input of node.inputs ?? []) {
if (!input) continue
const match = ids.find((id) => input.link === id)
if (match != null) return match
}
return ids[0]
}
/** Removes duplicate links from origin outputs and the graph's link map. */
export function purgeOrphanedLinks(
ids: LinkId[],
keepId: LinkId,
links: Map<LinkId, LLink>,
getNodeById: (id: NodeId) => LGraphNode | null
): void {
for (const id of ids) {
if (id === keepId) continue
const link = links.get(id)
if (!link) continue
const originNode = getNodeById(link.origin_id)
const output = originNode?.outputs?.[link.origin_slot]
if (output?.links) {
for (let i = output.links.length - 1; i >= 0; i--) {
if (output.links[i] === id) output.links.splice(i, 1)
}
}
links.delete(id)
}
}
/** Ensures input.link on the target node points to the surviving link. */
export function repairInputLinks(
ids: LinkId[],
keepId: LinkId,
node: LGraphNode | null
): void {
if (!node) return
const duplicateIds = new Set(ids)
for (const input of node.inputs ?? []) {
if (input?.link == null || input.link === keepId) continue
if (duplicateIds.has(input.link)) {
input.link = keepId
}
}
}

View File

@@ -343,13 +343,9 @@
"frameNodes": "Frame Nodes",
"listening": "Listening...",
"ready": "Ready",
"play": "Play",
"pause": "Pause",
"playPause": "Play/Pause",
"playRecording": "Play Recording",
"playing": "Playing",
"skipToStart": "Skip to Start",
"skipToEnd": "Skip to End",
"stopPlayback": "Stop Playback",
"playbackSpeed": "Playback Speed",
"volume": "Volume",
@@ -3232,14 +3228,6 @@
"desc": " More flexible workflows, powerful new widgets, built for extensibility",
"tryItOut": "Try it out"
},
"appBuilder": {
"vueNodeSwitch": {
"title": "Switched over to Nodes 2.0",
"content": "For the best experience, App builder uses Nodes 2.0. You can switch back after building the app from the main menu.",
"dontShowAgain": "Don't show again",
"dismiss": "Dismiss"
}
},
"vueNodesMigration": {
"message": "Prefer the legacy design?",
"button": "Switch back"

View File

@@ -23,9 +23,6 @@
:data-selected="selected"
:draggable="true"
@click.stop="$emit('click')"
@contextmenu.prevent.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
@dragstart="dragStart"
>
<!-- Top Area: Media Preview -->
@@ -69,16 +66,35 @@
>
<i class="icon-[lucide--zoom-in] size-4" />
</Button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop="
asset ? emit('context-menu', $event, asset) : undefined
"
<DropdownMenu
v-if="asset"
v-model:open="isActionsMenuOpen"
:show-arrow="false"
content-class="z-1700 bg-transparent p-0 shadow-lg"
:side-offset="4"
:collision-padding="8"
close-on-scroll
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
<template #button>
<Button
variant="overlay-white"
size="icon"
:aria-label="$t('mediaAsset.actions.moreOptions')"
@click.stop
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
<template #content="{ close, itemComponent, separatorComponent }">
<MenuPanel
:entries="getAssetMenuEntries()"
:item-component="itemComponent"
:separator-component="separatorComponent"
v-bind="mediaAssetMenuPanelProps"
@action="void onAssetMenuAction($event, close)"
/>
</template>
</DropdownMenu>
</IconGroup>
</div>
</div>
@@ -139,9 +155,12 @@ import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
import MenuPanel from '@/components/common/MenuPanel.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAssetsStore } from '@/stores/assetsStore'
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
import {
formatDuration,
formatSize,
@@ -154,11 +173,13 @@ import { cn } from '@/utils/tailwindUtil'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { useMediaAssetMenu } from '../composables/useMediaAssetMenu'
import type { AssetItem } from '../schemas/assetSchema'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
import { mediaAssetMenuPanelProps } from './mediaAssetMenuPanelConfig'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
@@ -177,12 +198,24 @@ function getTopComponent(kind: PreviewKind) {
return mediaComponents.top[kind] || mediaComponents.top.other
}
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
const {
asset,
loading,
selected,
showOutputCount,
outputCount,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset?: AssetItem
loading?: boolean
selected?: boolean
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const assetsStore = useAssetsStore()
@@ -196,13 +229,19 @@ const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
'output-count-click': []
'context-menu': [event: MouseEvent, asset: AssetItem]
'asset-deleted': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
'bulk-add-to-workflow': [assets: AssetItem[]]
'bulk-open-workflow': [assets: AssetItem[]]
'bulk-export-workflow': [assets: AssetItem[]]
}>()
const cardContainerRef = ref<HTMLElement>()
const isVideoPlaying = ref(false)
const showVideoControls = ref(false)
const isActionsMenuOpen = ref(false)
// Store actual image dimensions
const imageDimensions = ref<{ width: number; height: number } | undefined>()
@@ -210,6 +249,15 @@ const imageDimensions = ref<{ width: number; height: number } | undefined>()
const isHovered = useElementHover(cardContainerRef)
const actions = useMediaAssetActions()
const { getMenuEntries } = useMediaAssetMenu({
inspectAsset: (target) => emit('zoom', target),
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)
})
// Get asset type from tags
const assetType = computed(() => {
@@ -290,7 +338,12 @@ const metaInfo = computed(() => {
const showActionsOverlay = computed(() => {
if (loading || !asset || isDeleting.value) return false
return isHovered.value || selected || isVideoPlaying.value
return (
isHovered.value ||
selected ||
isVideoPlaying.value ||
isActionsMenuOpen.value
)
})
const handleZoomClick = () => {
@@ -306,6 +359,27 @@ const handleImageLoaded = (width: number, height: number) => {
const handleOutputCountClick = () => {
emit('output-count-click')
}
function getAssetMenuEntries(): MenuEntry[] {
if (!asset) {
return []
}
return getMenuEntries({
asset,
assetType: assetType.value,
fileKind: fileKind.value,
showDeleteButton,
selectedAssets,
isBulkMode
})
}
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
close()
await entry.onClick?.()
}
function dragStart(e: DragEvent) {
if (!asset?.preview_url) return

View File

@@ -1,143 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key
})
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
supportsWorkflowMetadata: () => true
}))
vi.mock('@/utils/formatUtil', () => ({
isPreviewableMediaType: () => true
}))
vi.mock('@/utils/loaderNodeUtil', () => ({
detectNodeTypeFromFilename: () => ({ nodeType: 'LoadImage' })
}))
const mediaAssetActions = {
addWorkflow: vi.fn(),
downloadAsset: vi.fn(),
openWorkflow: vi.fn(),
exportWorkflow: vi.fn(),
copyJobId: vi.fn(),
deleteAssets: vi.fn().mockResolvedValue(false)
}
vi.mock('../composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => mediaAssetActions
}))
const contextMenuStub = defineComponent({
name: 'ContextMenu',
props: {
pt: {
type: Object,
default: undefined
}
},
emits: ['hide'],
data() {
return {
visible: false
}
},
methods: {
show() {
this.visible = true
},
hide() {
this.visible = false
this.$emit('hide')
}
},
template: `
<div
v-if="visible"
class="context-menu-stub"
v-bind="pt?.root"
/>
`
})
const asset: AssetItem = {
id: 'asset-1',
name: 'image.png',
tags: [],
user_metadata: {}
}
const buttonStub = {
template: '<div class="button-stub"><slot /></div>'
}
type MediaAssetContextMenuExposed = ComponentPublicInstance & {
show: (event: MouseEvent) => void
}
const mountComponent = () =>
mount(MediaAssetContextMenu, {
attachTo: document.body,
props: {
asset,
assetType: 'output',
fileKind: 'image'
},
global: {
stubs: {
ContextMenu: contextMenuStub,
Button: buttonStub
}
}
})
async function showMenu(
wrapper: ReturnType<typeof mountComponent>
): Promise<HTMLElement> {
const exposed = wrapper.vm as MediaAssetContextMenuExposed
const event = new MouseEvent('contextmenu', { bubbles: true })
exposed.show(event)
await nextTick()
return wrapper.get('.context-menu-stub').element as HTMLElement
}
afterEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
describe('MediaAssetContextMenu', () => {
it('dismisses outside pointerdown using the rendered root id', async () => {
const wrapper = mountComponent()
const outside = document.createElement('div')
document.body.append(outside)
const menu = await showMenu(wrapper)
const menuId = menu.id
expect(menuId).not.toBe('')
expect(document.getElementById(menuId)).toBe(menu)
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
await nextTick()
expect(wrapper.find('.context-menu-stub').exists()).toBe(false)
expect(wrapper.emitted('hide')).toEqual([[]])
wrapper.unmount()
})
})

View File

@@ -1,286 +0,0 @@
<template>
<ContextMenu
ref="contextMenu"
:model="contextMenuItems"
:pt="{
root: {
id: contextMenuId,
class: cn(
'rounded-lg',
'bg-secondary-background text-base-foreground',
'shadow-lg'
)
}
}"
@hide="onMenuHide"
>
<template #item="{ item, props }">
<Button
variant="secondary"
class="w-full justify-start"
v-bind="props.action"
>
<i v-if="item.icon" :class="item.icon" class="size-4" />
<span>{{
typeof item.label === 'function' ? item.label() : (item.label ?? '')
}}</span>
</Button>
</template>
</ContextMenu>
</template>
<script setup lang="ts">
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, ref, useId } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { cn } from '@/utils/tailwindUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
const {
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
} = defineProps<{
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}>()
const emit = defineEmits<{
zoom: []
hide: []
'asset-deleted': []
'bulk-download': [assets: AssetItem[]]
'bulk-delete': [assets: AssetItem[]]
'bulk-add-to-workflow': [assets: AssetItem[]]
'bulk-open-workflow': [assets: AssetItem[]]
'bulk-export-workflow': [assets: AssetItem[]]
}>()
type ContextMenuHandle = {
show: (event: MouseEvent) => void
hide: () => void
}
const contextMenu = ref<ContextMenuHandle | null>(null)
const contextMenuId = useId()
const isVisible = ref(false)
const actions = useMediaAssetActions()
const { t } = useI18n()
useDismissableOverlay({
isOpen: isVisible,
getOverlayEl: () => document.getElementById(contextMenuId),
onDismiss: hide,
dismissOnScroll: true
})
const showAddToWorkflow = computed(() => {
// Output assets can always be added
if (assetType === 'output') return true
// Input assets: check if file type is supported by loader nodes
if (assetType === 'input' && asset?.name) {
const { nodeType } = detectNodeTypeFromFilename(asset.name)
return nodeType !== null
}
return false
})
const showWorkflowActions = computed(() => {
// Output assets always have workflow metadata
if (assetType === 'output') return true
// Input assets: only formats that support workflow metadata
if (assetType === 'input' && asset?.name) {
return supportsWorkflowMetadata(asset.name)
}
return false
})
const showCopyJobId = computed(() => {
return assetType !== 'input'
})
const shouldShowDeleteButton = computed(() => {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType === 'output' || (assetType === 'input' && isCloud)
return propAllows && typeAllows
})
// Context menu items
const contextMenuItems = computed<MenuItem[]>(() => {
if (!asset) return []
const items: MenuItem[] = []
// Check if current asset is part of the selection
const isCurrentAssetSelected = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
// Bulk mode: Show selected count and bulk actions only if current asset is selected
if (
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isCurrentAssetSelected
) {
// Header item showing selected count
items.push({
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
})
// Bulk Add to Workflow
items.push({
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
icon: 'icon-[comfy--node]',
command: () => emit('bulk-add-to-workflow', selectedAssets)
})
// Bulk Open Workflow
items.push({
label: t('mediaAsset.selection.openWorkflowAll'),
icon: 'icon-[comfy--workflow]',
command: () => emit('bulk-open-workflow', selectedAssets)
})
// Bulk Export Workflow
items.push({
label: t('mediaAsset.selection.exportWorkflowAll'),
icon: 'icon-[lucide--file-output]',
command: () => emit('bulk-export-workflow', selectedAssets)
})
// Bulk Download
items.push({
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
command: () => emit('bulk-download', selectedAssets)
})
// Bulk Delete (if allowed)
if (shouldShowDeleteButton.value) {
items.push({
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
command: () => emit('bulk-delete', selectedAssets)
})
}
return items
}
// Individual mode: Show all menu options
// Inspect
if (isPreviewableMediaType(fileKind)) {
items.push({
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
command: () => emit('zoom')
})
}
// Add to workflow (conditional)
if (showAddToWorkflow.value) {
items.push({
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
icon: 'icon-[comfy--node]',
command: () => actions.addWorkflow(asset)
})
}
// Download
items.push({
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
command: () => actions.downloadAsset(asset)
})
// Separator before workflow actions (only if there are workflow actions)
if (showWorkflowActions.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.openWorkflow'),
icon: 'icon-[comfy--workflow]',
command: () => actions.openWorkflow(asset)
})
items.push({
label: t('mediaAsset.actions.exportWorkflow'),
icon: 'icon-[lucide--file-output]',
command: () => actions.exportWorkflow(asset)
})
}
// Copy job ID
if (showCopyJobId.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.copyJobId'),
icon: 'icon-[lucide--copy]',
command: async () => {
await actions.copyJobId(asset)
}
})
}
// Delete
if (shouldShowDeleteButton.value) {
items.push({ separator: true })
items.push({
label: t('mediaAsset.actions.delete'),
icon: 'icon-[lucide--trash-2]',
command: async () => {
if (asset) {
const confirmed = await actions.deleteAssets(asset)
if (confirmed) {
emit('asset-deleted')
}
}
}
})
}
return items
})
function onMenuHide() {
isVisible.value = false
emit('hide')
}
function show(event: MouseEvent) {
isVisible.value = true
contextMenu.value?.show(event)
}
function hide() {
isVisible.value = false
contextMenu.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -8,20 +8,16 @@
$t('assetBrowser.media.audioPlaceholder')
}}</span>
</div>
<div class="absolute bottom-0 left-0 w-full p-2">
<WaveAudioPlayer
:src="asset.src"
:bar-count="40"
:height="32"
align="bottom"
/>
</div>
<audio
controls
class="absolute bottom-0 left-0 w-full p-2"
:src="asset.src"
@click.stop
/>
</div>
</template>
<script setup lang="ts">
import WaveAudioPlayer from '@/components/common/WaveAudioPlayer.vue'
import type { AssetMeta } from '../schemas/mediaAssetSchema'
const { asset } = defineProps<{

View File

@@ -0,0 +1,8 @@
export const mediaAssetMenuPanelProps = {
panelClass:
'media-asset-menu-panel flex min-w-56 flex-col rounded-lg border border-border-subtle bg-secondary-background p-2 text-base-foreground',
separatorWrapperClass: 'm-1',
separatorClass: 'h-px bg-border-subtle',
buttonClass: 'w-full justify-start',
iconClass: 'size-4'
} as const

View File

@@ -0,0 +1,260 @@
import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import type { MenuEntry } from '@/types/menuTypes'
import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { useMediaAssetActions } from './useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
type MediaAssetMenuContext = {
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
selectedAssets?: AssetItem[]
isBulkMode?: boolean
}
type MediaAssetMenuHandlers = {
inspectAsset?: (asset: AssetItem) => void | Promise<void>
assetDeleted?: (asset: AssetItem) => void | Promise<void>
bulkDownload?: (assets: AssetItem[]) => void | Promise<void>
bulkDelete?: (assets: AssetItem[]) => void | Promise<void>
bulkAddToWorkflow?: (assets: AssetItem[]) => void | Promise<void>
bulkOpenWorkflow?: (assets: AssetItem[]) => void | Promise<void>
bulkExportWorkflow?: (assets: AssetItem[]) => void | Promise<void>
}
function canAddToWorkflow(
candidate: AssetItem,
assetType: AssetContext['type']
): boolean {
if (assetType === 'output') return true
if (assetType === 'input' && candidate.name) {
return detectNodeTypeFromFilename(candidate.name).nodeType !== null
}
return false
}
function canShowWorkflowActions(
candidate: AssetItem,
assetType: AssetContext['type']
): boolean {
if (assetType === 'output') return true
if (assetType === 'input' && candidate.name) {
return supportsWorkflowMetadata(candidate.name)
}
return false
}
function canDeleteAsset(
assetType: AssetContext['type'],
showDeleteButton?: boolean
): boolean {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType === 'output' || (assetType === 'input' && isCloud)
return propAllows && typeAllows
}
export function useMediaAssetMenu(handlers: MediaAssetMenuHandlers = {}) {
const { t } = useI18n()
const actions = useMediaAssetActions()
async function deleteAsset(asset: AssetItem) {
const deleted = await actions.deleteAssets(asset)
if (deleted) {
await handlers.assetDeleted?.(asset)
}
}
async function deleteSelectedAssets(selectedAssets: AssetItem[]) {
if (handlers.bulkDelete) {
await handlers.bulkDelete(selectedAssets)
return
}
await actions.deleteAssets(selectedAssets)
}
function getMenuEntries({
asset,
assetType,
fileKind,
showDeleteButton,
selectedAssets,
isBulkMode
}: MediaAssetMenuContext): MenuEntry[] {
const isSelectedAsset = selectedAssets?.some(
(selectedAsset) => selectedAsset.id === asset.id
)
const showBulkActions =
isBulkMode &&
selectedAssets &&
selectedAssets.length > 0 &&
isSelectedAsset
if (showBulkActions) {
const allSelectedCanAddToWorkflow = selectedAssets.every(
(selectedAsset) => canAddToWorkflow(selectedAsset, assetType)
)
const allSelectedSupportWorkflowActions = selectedAssets.every(
(selectedAsset) => canShowWorkflowActions(selectedAsset, assetType)
)
const bulkDeleteEnabled = canDeleteAsset(assetType, showDeleteButton)
return [
{
key: 'bulk-selection-header',
label: t('mediaAsset.selection.multipleSelectedAssets'),
disabled: true
},
...(allSelectedCanAddToWorkflow
? [
{
key: 'bulk-add-to-workflow',
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
icon: 'icon-[comfy--node]',
onClick: () => {
if (handlers.bulkAddToWorkflow) {
return handlers.bulkAddToWorkflow(selectedAssets)
}
return actions.addMultipleToWorkflow(selectedAssets)
}
} satisfies MenuEntry
]
: []),
...(allSelectedSupportWorkflowActions
? [
{
key: 'bulk-open-workflow',
label: t('mediaAsset.selection.openWorkflowAll'),
icon: 'icon-[comfy--workflow]',
onClick: () => {
if (handlers.bulkOpenWorkflow) {
return handlers.bulkOpenWorkflow(selectedAssets)
}
return actions.openMultipleWorkflows(selectedAssets)
}
} satisfies MenuEntry,
{
key: 'bulk-export-workflow',
label: t('mediaAsset.selection.exportWorkflowAll'),
icon: 'icon-[lucide--file-output]',
onClick: () => {
if (handlers.bulkExportWorkflow) {
return handlers.bulkExportWorkflow(selectedAssets)
}
return actions.exportMultipleWorkflows(selectedAssets)
}
} satisfies MenuEntry
]
: []),
{
key: 'bulk-download',
label: t('mediaAsset.selection.downloadSelectedAll'),
icon: 'icon-[lucide--download]',
onClick: () => {
if (handlers.bulkDownload) {
return handlers.bulkDownload(selectedAssets)
}
return actions.downloadMultipleAssets(selectedAssets)
}
},
...(bulkDeleteEnabled
? [
{
key: 'bulk-delete',
label: t('mediaAsset.selection.deleteSelectedAll'),
icon: 'icon-[lucide--trash-2]',
onClick: async () => {
await deleteSelectedAssets(selectedAssets)
}
} satisfies MenuEntry
]
: [])
]
}
const entries: MenuEntry[] = []
const showWorkflowActions = canShowWorkflowActions(asset, assetType)
const deleteEnabled = canDeleteAsset(assetType, showDeleteButton)
if (isPreviewableMediaType(fileKind)) {
entries.push({
key: 'inspect',
label: t('mediaAsset.actions.inspect'),
icon: 'icon-[lucide--zoom-in]',
onClick: () => handlers.inspectAsset?.(asset)
})
}
if (canAddToWorkflow(asset, assetType)) {
entries.push({
key: 'add-to-workflow',
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
icon: 'icon-[comfy--node]',
onClick: () => actions.addWorkflow(asset)
})
}
entries.push({
key: 'download',
label: t('mediaAsset.actions.download'),
icon: 'icon-[lucide--download]',
onClick: () => actions.downloadAsset(asset)
})
if (showWorkflowActions) {
entries.push({ kind: 'divider', key: 'workflow-divider' })
entries.push({
key: 'open-workflow',
label: t('mediaAsset.actions.openWorkflow'),
icon: 'icon-[comfy--workflow]',
onClick: () => actions.openWorkflow(asset)
})
entries.push({
key: 'export-workflow',
label: t('mediaAsset.actions.exportWorkflow'),
icon: 'icon-[lucide--file-output]',
onClick: () => actions.exportWorkflow(asset)
})
}
if (assetType !== 'input') {
entries.push({ kind: 'divider', key: 'copy-job-id-divider' })
entries.push({
key: 'copy-job-id',
label: t('mediaAsset.actions.copyJobId'),
icon: 'icon-[lucide--copy]',
onClick: async () => {
await actions.copyJobId(asset)
}
})
}
if (deleteEnabled) {
entries.push({ kind: 'divider', key: 'delete-divider' })
entries.push({
key: 'delete',
label: t('mediaAsset.actions.delete'),
icon: 'icon-[lucide--trash-2]',
onClick: async () => deleteAsset(asset)
})
}
return entries
}
return { getMenuEntries }
}

View File

@@ -1198,12 +1198,6 @@ export const CORE_SETTINGS: SettingParams[] = [
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.AppBuilder.VueNodeSwitchDismissed',
name: 'App Builder Vue Node switch dismissed',
type: 'hidden',
defaultValue: false
},
{
id: 'Comfy.VueNodes.AutoScaleLayout',
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],

View File

@@ -1,5 +1,3 @@
import { useSettingStore } from '@/platform/settings/settingStore'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
/**
@@ -11,13 +9,7 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
featureId: 'node-search',
typeformId: 'goZLqjKL',
triggerThreshold: 3,
delayMs: 5000,
isFeatureActive: () => {
const settingStore = useSettingStore()
return (
settingStore.get('Comfy.NodeSearchBoxImpl') !== 'litegraph (legacy)'
)
}
delayMs: 5000
}
}

View File

@@ -181,17 +181,6 @@ describe('useSurveyEligibility', () => {
expect(isEligible.value).toBe(false)
})
it('is not eligible when isFeatureActive returns false', () => {
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility({
...defaultConfig,
isFeatureActive: () => false
})
expect(isEligible.value).toBe(false)
})
})
describe('actions', () => {

View File

@@ -13,7 +13,6 @@ export interface FeatureSurveyConfig {
triggerThreshold?: number
delayMs?: number
enabled?: boolean
isFeatureActive?: () => boolean
}
interface SurveyState {
@@ -62,13 +61,8 @@ export function useSurveyEligibility(
const hasOptedOut = computed(() => state.value.optedOut)
const isFeatureActive = computed(
() => resolvedConfig.value.isFeatureActive?.() ?? true
)
const isEligible = computed(() => {
if (!isSurveyEnabled.value) return false
if (!isFeatureActive.value) return false
if (!isNightlyLocalhost.value) return false
if (!hasReachedThreshold.value) return false
if (hasSeenSurvey.value) return false

View File

@@ -134,7 +134,7 @@ describe('ReleaseNotificationToast', () => {
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
expect(wrapper.find('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
})
it('displays release version', () => {

View File

@@ -1,17 +1,40 @@
<template>
<div v-if="shouldShow" class="release-toast-popup">
<NotificationPopup
icon="icon-[lucide--rocket]"
:title="$t('releaseToast.newVersionAvailable')"
:subtitle="latestRelease?.version"
:position
<div
class="flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<div
class="pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
v-html="formattedContent"
></div>
<!-- Main content -->
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
<!-- Header section with icon and text -->
<div class="flex items-center gap-4">
<div
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
>
<i class="icon-[lucide--rocket] size-4 text-white" />
</div>
<div class="flex flex-col gap-1">
<div
class="text-sm leading-[1.429] font-normal text-base-foreground"
>
{{ $t('releaseToast.newVersionAvailable') }}
</div>
<div
class="text-sm leading-[1.21] font-normal text-muted-foreground"
>
{{ latestRelease?.version }}
</div>
</div>
</div>
<template #footer-start>
<!-- Description section -->
<div
class="min-h-0 flex-1 overflow-y-auto pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
v-html="formattedContent"
></div>
</div>
<!-- Footer section -->
<div class="flex items-center justify-between px-4 pb-4">
<a
class="flex items-center gap-2 py-1 text-sm font-normal text-muted-foreground hover:text-base-foreground"
:href="changelogUrl"
@@ -22,27 +45,22 @@
<i class="icon-[lucide--external-link] size-4"></i>
{{ $t('releaseToast.whatsNew') }}
</a>
</template>
<template #footer-end>
<Button
variant="link"
size="unset"
class="h-6 px-0 text-sm font-normal"
@click="handleSkip"
>
{{ $t('releaseToast.skip') }}
</Button>
<Button
variant="secondary"
size="lg"
class="font-normal"
@click="handleUpdate"
>
{{ $t('releaseToast.update') }}
</Button>
</template>
</NotificationPopup>
<div class="flex items-center gap-4">
<button
class="h-6 cursor-pointer border-none bg-transparent px-0 text-sm font-normal text-muted-foreground hover:text-base-foreground"
@click="handleSkip"
>
{{ $t('releaseToast.skip') }}
</button>
<button
class="h-10 cursor-pointer rounded-lg border-none bg-secondary-background px-4 text-sm font-normal text-base-foreground hover:bg-secondary-background-hover"
@click="handleUpdate"
>
{{ $t('releaseToast.update') }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -51,8 +69,6 @@ import { default as DOMPurify } from 'dompurify'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NotificationPopup from '@/components/common/NotificationPopup.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useExternalLink } from '@/composables/useExternalLink'
import { useCommandStore } from '@/stores/commandStore'
@@ -63,10 +79,6 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { position = 'bottom-left' } = defineProps<{
position?: 'bottom-left' | 'bottom-right'
}>()
const { buildDocsUrl } = useExternalLink()
const { toastErrorHandler } = useErrorHandling()
const releaseStore = useReleaseStore()
@@ -206,3 +218,23 @@ defineExpose({
handleUpdate
})
</script>
<style scoped>
/* Toast popup - positioning handled by parent */
.release-toast-popup {
position: absolute;
bottom: 1rem;
z-index: 1000;
pointer-events: auto;
}
/* Sidebar positioning classes applied by parent - matching help center */
.release-toast-popup.sidebar-left,
.release-toast-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.release-toast-popup.sidebar-right {
right: 1rem;
}
</style>

View File

@@ -21,7 +21,6 @@ import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import {
@@ -404,7 +403,6 @@ export const useWorkflowService = () => {
// Determine the initial app mode for fresh loads from serialized state.
// null means linearMode was never explicitly set (not builder-saved).
const freshLoadMode = linearModeToAppMode(workflowData.extra?.linearMode)
useAppModeStore().loadSelections(workflowData.extra?.linearData)
function trackIfEnteringApp(workflow: ComfyWorkflow) {
if (!wasAppMode && workflow.initialMode === 'app') {

View File

@@ -9,7 +9,6 @@ defineOptions({ inheritAttrs: false })
const { src } = defineProps<{
src: string
mobile?: boolean
label?: string
}>()
const imageRef = useTemplateRef('imageRef')
@@ -49,8 +48,5 @@ const height = ref('')
}
"
/>
<span class="self-center md:z-10">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, useAttrs } from 'vue'
import { defineAsyncComponent, useAttrs } from 'vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
@@ -19,56 +19,40 @@ const { output } = defineProps<{
}>()
const attrs = useAttrs()
const mediaType = computed(() => getMediaType(output))
const outputLabel = computed(
() => output.display_name?.trim() || output.filename
)
</script>
<template>
<template v-if="mediaType === 'images' || mediaType === 'video'">
<ImagePreview
v-if="mediaType === 'images'"
:class="attrs.class as string"
:mobile
:src="output.url"
:label="outputLabel"
/>
<VideoPreview
v-else
:src="output.url"
:label="outputLabel"
:class="
cn(
'flex-1 object-contain md:p-3 md:contain-size',
attrs.class as string
)
"
/>
</template>
<template v-else>
<audio
v-if="mediaType === 'audio'"
:class="cn('m-auto w-full', attrs.class as string)"
controls
:src="output.url"
/>
<article
v-else-if="mediaType === 'text'"
:class="
cn(
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
attrs.class as string
)
"
v-text="output.content"
/>
<Preview3d
v-else-if="mediaType === '3d'"
:class="attrs.class as string"
:model-url="output.url"
/>
<span v-if="outputLabel" class="self-center text-sm">
{{ outputLabel }}
</span>
</template>
<ImagePreview
v-if="getMediaType(output) === 'images'"
:class="attrs.class as string"
:mobile
:src="output.url"
/>
<VideoPreview
v-else-if="getMediaType(output) === 'video'"
:src="output.url"
:class="
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
"
/>
<audio
v-else-if="getMediaType(output) === 'audio'"
:class="cn('m-auto w-full', attrs.class as string)"
controls
:src="output.url"
/>
<article
v-else-if="getMediaType(output) === 'text'"
:class="
cn(
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
attrs.class as string
)
"
v-text="output.content"
/>
<Preview3d
v-else-if="getMediaType(output) === '3d'"
:class="attrs.class as string"
:model-url="output.url"
/>
</template>

View File

@@ -3,7 +3,6 @@ import { ref, useTemplateRef } from 'vue'
const { src } = defineProps<{
src: string
label?: string
}>()
const videoRef = useTemplateRef('videoRef')
@@ -24,8 +23,5 @@ const height = ref('')
}
"
/>
<span class="z-10 self-center">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>
<span class="z-10 self-center" v-text="`${width} x ${height}`" />
</template>

View File

@@ -96,7 +96,7 @@ import { useAudioService } from '@/services/audioService'
import { useAudioPlayback } from '../composables/audio/useAudioPlayback'
import { useAudioRecorder } from '../composables/audio/useAudioRecorder'
import { useAudioWaveform } from '../composables/audio/useAudioWaveform'
import { formatTime } from '@/utils/formatUtil'
import { formatTime } from '../utils/audioUtils'
const { t } = useI18n()

View File

@@ -55,33 +55,6 @@ vi.mock(
})
)
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
mockMediaAssets: {
media: ref([]),
loading: ref(false),
error: ref(null),
fetchMediaList: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue([]),
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
},
mockResolveOutputAssetItems: vi.fn()
}
})
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => mockMediaAssets
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -511,229 +484,6 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
})
})
describe('WidgetSelectDropdown multi-output jobs', () => {
interface MultiOutputInstance extends ComponentPublicInstance {
outputItems: FormDropdownItem[]
}
function makeMultiOutputAsset(
jobId: string,
name: string,
nodeId: string,
outputCount: number
) {
return {
id: jobId,
name,
preview_url: `/api/view?filename=${name}&type=output`,
tags: ['output'],
user_metadata: {
jobId,
nodeId,
subfolder: '',
outputCount,
allOutputs: [
{
filename: name,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
]
}
}
}
function mountMultiOutput(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<MultiOutputInstance> {
return mount(WidgetSelectDropdown, {
props: { widget, modelValue, assetKind: 'image' as const },
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
}) as unknown as VueWrapper<MultiOutputInstance>
}
const defaultWidget = () =>
createMockWidget<string | undefined>({
value: 'output_001.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
beforeEach(() => {
mockMediaAssets.media.value = []
mockResolveOutputAssetItems.mockReset()
})
it('shows all outputs after resolving multi-output jobs', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'job-1-5-output_001.png',
name: 'output_001.png',
preview_url: '/api/view?filename=output_001.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_002.png',
name: 'output_002.png',
preview_url: '/api/view?filename=output_002.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_003.png',
name: 'output_003.png',
preview_url: '/api/view?filename=output_003.png&type=output',
tags: ['output']
}
])
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(3)
})
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
'output_001.png [output]',
'output_002.png [output]',
'output_003.png [output]'
])
})
it('shows preview output when job has only one output', () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
]
const widget = createMockWidget<string | undefined>({
value: 'single.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
const wrapper = mountMultiOutput(widget, 'single.png')
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
})
it('resolves two multi-output jobs independently', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
]
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
if (meta.jobId === 'job-A') {
return [
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
]
}
return [
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
]
})
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(4)
})
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toContain('a1.png [output]')
expect(names).toContain('a2.png [output]')
expect(names).toContain('b1.png [output]')
expect(names).toContain('b2.png [output]')
})
it('resolves outputs when allOutputs already contains all items', async () => {
mockMediaAssets.media.value = [
{
id: 'job-complete',
name: 'preview.png',
preview_url: '/api/view?filename=preview.png&type=output',
tags: ['output'],
user_metadata: {
jobId: 'job-complete',
nodeId: '1',
subfolder: '',
outputCount: 2,
allOutputs: [
{
filename: 'out1.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
{
filename: 'out2.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
]
}
}
]
mockResolveOutputAssetItems.mockResolvedValue([
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
])
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(2)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-complete' }),
expect.any(Object)
)
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
})
it('falls back to preview when resolver rejects', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
]
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to resolve multi-output job',
'job-fail',
expect.any(Error)
)
})
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
consoleWarnSpy.mockRestore()
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
import { computed, provide, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
@@ -31,9 +31,6 @@ import type {
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -156,82 +153,24 @@ function assetKindToMediaType(kind: AssetKind): string {
return kind === 'mesh' ? '3D' : kind
}
/**
* Per-job cache of resolved outputs for multi-output jobs.
* Keyed by jobId, populated lazily via resolveOutputAssetItems which
* fetches full outputs through getJobDetail (itself LRU-cached).
*/
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
const pendingJobIds = new Set<string>()
watch(
() => outputMediaAssets.media.value,
(assets, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
pendingJobIds.clear()
for (const asset of assets) {
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
if (
outputCount <= 1 ||
resolvedByJobId.value.has(meta.jobId) ||
pendingJobIds.has(meta.jobId)
)
continue
pendingJobIds.add(meta.jobId)
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
.then((resolved) => {
if (cancelled || !resolved.length) return
const next = new Map(resolvedByJobId.value)
next.set(meta.jobId, resolved)
resolvedByJobId.value = next
})
.catch((error) => {
console.warn('Failed to resolve multi-output job', meta.jobId, error)
})
.finally(() => {
pendingJobIds.delete(meta.jobId)
})
}
},
{ immediate: true }
)
const outputItems = computed<FormDropdownItem[]>(() => {
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
return []
const targetMediaType = assetKindToMediaType(props.assetKind!)
const seen = new Set<string>()
const items: FormDropdownItem[] = []
const outputFiles = outputMediaAssets.media.value.filter(
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
)
const assets = outputMediaAssets.media.value.flatMap((asset) => {
const meta = getOutputAssetMetadata(asset.user_metadata)
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
return resolved ?? [asset]
})
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
seen.add(asset.id)
return outputFiles.map((asset) => {
const annotatedPath = `${asset.name} [output]`
items.push({
return {
id: `output-${annotatedPath}`,
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
name: annotatedPath,
label: getDisplayLabel(annotatedPath)
})
}
return items
}
})
})
/**

View File

@@ -156,7 +156,7 @@ import { downloadFile } from '@/base/common/downloadUtil'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { formatTime } from '@/utils/formatUtil'
import { formatTime } from '../../utils/audioUtils'
const { t } = useI18n()
const toast = useToast()

View File

@@ -4,7 +4,6 @@ import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type {
@@ -51,7 +50,6 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -211,7 +209,6 @@ function handleSelection(item: FormDropdownItem, index: number) {
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -4,7 +4,6 @@ import { ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import type {
FilterOption,
OwnershipFilterOption,
@@ -16,7 +15,6 @@ import FormSearchInput from '../FormSearchInput.vue'
import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
defineProps<{
sortOptions: SortOption[]
@@ -134,7 +132,6 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -197,7 +194,6 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="ownershipPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
@@ -260,7 +256,6 @@ function toggleBaseModelSelection(item: FilterOption) {
ref="baseModelPopoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {

View File

@@ -1,6 +1,17 @@
import type { ResultItemType } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
/**
* Format time in MM:SS format
*/
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')}`
}
export function getResourceURL(
subfolder: string,
filename: string,

View File

@@ -415,7 +415,6 @@ const zSettings = z.object({
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
'Comfy.Canvas.MouseWheelScroll': z.string(),
'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy.AppBuilder.VueNodeSwitchDismissed': z.boolean(),
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy.Queue.QPOV2': z.boolean(),

View File

@@ -110,7 +110,7 @@ interface Load3DNode extends LGraphNode {
const viewerInstances = new Map<NodeId, ReturnType<UseLoad3dViewerFn>>()
class Load3dService {
export class Load3dService {
private static instance: Load3dService
private constructor() {}

View File

@@ -47,24 +47,6 @@ vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
}))
const mockSettings = vi.hoisted(() => {
const store: Record<string, unknown> = {}
return {
store,
get: vi.fn((key: string) => store[key] ?? false),
set: vi.fn(async (key: string, value: unknown) => {
store[key] = value
}),
reset() {
for (const key of Object.keys(store)) delete store[key]
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettings
}))
import { useAppModeStore } from './appModeStore'
function createBuilderWorkflow(
@@ -90,7 +72,6 @@ describe('appModeStore', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.mocked(app.rootGraph).extra = {}
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
workflowStore = useWorkflowStore()
store = useAppModeStore()
@@ -216,12 +197,14 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
store.loadSelections({
inputs: [
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
[99, 'width']
]
})
],
[]
)
await nextTick()
expect(store.selectedInputs).toEqual([[1, 'prompt']])
})
@@ -232,12 +215,14 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
store.loadSelections({
inputs: [
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
[1, 'deleted_widget']
]
})
],
[]
)
await nextTick()
expect(store.selectedInputs).toEqual([
[1, 'prompt'],
@@ -251,7 +236,8 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
store.loadSelections({ outputs: [1, 99] })
workflowStore.activeWorkflow = workflowWithLinearData([], [1, 99])
await nextTick()
expect(store.selectedOutputs).toEqual([1])
})
@@ -261,11 +247,8 @@ describe('appModeStore', () => {
// Initially nodes are not resolvable — pruning removes them
mockResolveNode.mockReturnValue(undefined)
const inputs: [number, string][] = [[1, 'seed']]
workflowStore.activeWorkflow = workflowWithLinearData(inputs, [1])
store.loadSelections({ inputs })
workflowStore.activeWorkflow = workflowWithLinearData([[1, 'seed']], [1])
await nextTick()
expect(store.selectedInputs).toEqual([])
expect(store.selectedOutputs).toEqual([])
@@ -285,7 +268,8 @@ describe('appModeStore', () => {
it('hasOutputs is false when all output nodes are deleted', async () => {
mockResolveNode.mockReturnValue(undefined)
store.loadSelections({ outputs: [10, 20] })
workflowStore.activeWorkflow = workflowWithLinearData([], [10, 20])
await nextTick()
expect(store.selectedOutputs).toEqual([])
expect(store.hasOutputs).toBe(false)
@@ -345,69 +329,4 @@ describe('appModeStore', () => {
})
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(mockSettings.set).toHaveBeenCalledWith(
'Comfy.VueNodes.Enabled',
true
)
})
it('does not enable Vue nodes when already enabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = true
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(mockSettings.set).not.toHaveBeenCalledWith(
'Comfy.VueNodes.Enabled',
expect.anything()
)
})
it('shows popup when Vue nodes are switched on and not dismissed', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(store.showVueNodeSwitchPopup).toBe(true)
})
it('does not show popup when previously dismissed', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = true
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(store.showVueNodeSwitchPopup).toBe(false)
})
it('does not enable Vue nodes when entering builder:arrange', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('app')
store.selectedOutputs.push(1)
store.enterBuilder()
await nextTick()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:arrange')
expect(mockSettings.set).not.toHaveBeenCalledWith(
'Comfy.VueNodes.Enabled',
expect.anything()
)
})
})
})

View File

@@ -1,12 +1,11 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { reactive, computed, watch } from 'vue'
import { useEventListener } from '@vueuse/core'
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -22,16 +21,13 @@ export function nodeTypeValidForApp(type: string) {
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const showVueNodeSwitchPopup = ref(false)
const selectedInputs = ref<[NodeId, string][]>([])
const selectedOutputs = ref<NodeId[]>([])
const hasOutputs = computed(() => !!selectedOutputs.value.length)
const selectedInputs = reactive<[NodeId, string][]>([])
const selectedOutputs = reactive<NodeId[]>([])
const hasOutputs = computed(() => !!selectedOutputs.length)
const hasNodes = computed(() => {
// Nodes are not reactive, so trigger recomputation when workflow changes
void workflowStore.activeWorkflow
@@ -58,8 +54,8 @@ export const useAppModeStore = defineStore('appMode', () => {
function loadSelections(data: Partial<LinearData> | undefined) {
const { inputs, outputs } = pruneLinearData(data)
selectedInputs.value = inputs
selectedOutputs.value = outputs
selectedInputs.splice(0, selectedInputs.length, ...inputs)
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
}
function resetSelectedToWorkflow() {
@@ -69,6 +65,20 @@ export const useAppModeStore = defineStore('appMode', () => {
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
}
watch(
() => workflowStore.activeWorkflow,
(newWorkflow) => {
if (newWorkflow) {
loadSelections(
newWorkflow.changeTracker?.activeState?.extra?.linearData
)
} else {
loadSelections(undefined)
}
},
{ immediate: true }
)
useEventListener(
() => app.rootGraph?.events,
'configured',
@@ -78,7 +88,7 @@ export const useAppModeStore = defineStore('appMode', () => {
watch(
() =>
isBuilderMode.value
? { inputs: selectedInputs.value, outputs: selectedOutputs.value }
? { inputs: selectedInputs, outputs: selectedOutputs }
: null,
(data) => {
if (!data || ChangeTracker.isLoadingGraph) return
@@ -93,33 +103,17 @@ export const useAppModeStore = defineStore('appMode', () => {
{ deep: true }
)
let unwatchReadOnly: (() => void) | undefined
function enforceReadOnly(inSelect: boolean) {
let unwatch: () => void | undefined
watch(isSelectMode, (inSelect) => {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
unwatchReadOnly?.()
unwatch?.()
if (inSelect)
unwatchReadOnly = watch(
unwatch = watch(
() => state.readOnly,
() => (state.readOnly = true)
)
}
function autoEnableVueNodes(inSelect: boolean) {
if (!inSelect) return
if (!settingStore.get('Comfy.VueNodes.Enabled')) {
void settingStore.set('Comfy.VueNodes.Enabled', true)
if (!settingStore.get('Comfy.AppBuilder.VueNodeSwitchDismissed')) {
showVueNodeSwitchPopup.value = true
}
}
}
watch(isSelectMode, (inSelect) => {
enforceReadOnly(inSelect)
autoEnableVueNodes(inSelect)
})
function enterBuilder() {
@@ -150,10 +144,10 @@ export const useAppModeStore = defineStore('appMode', () => {
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = selectedInputs.value.findIndex(
const index = selectedInputs.findIndex(
([id, name]) => storeId == id && storeName === name
)
if (index !== -1) selectedInputs.value.splice(index, 1)
if (index !== -1) selectedInputs.splice(index, 1)
}
return {
@@ -161,12 +155,10 @@ export const useAppModeStore = defineStore('appMode', () => {
exitBuilder,
hasNodes,
hasOutputs,
loadSelections,
pruneLinearData,
removeSelectedInput,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs,
showVueNodeSwitchPopup
selectedOutputs
}
})

15
src/types/menuTypes.ts Normal file
View File

@@ -0,0 +1,15 @@
export type MenuActionEntry = {
kind?: 'item'
key: string
label: string
icon?: string
disabled?: boolean
onClick?: () => void | Promise<void>
}
type MenuDividerEntry = {
kind: 'divider'
key: string
}
export type MenuEntry = MenuActionEntry | MenuDividerEntry

View File

@@ -73,7 +73,6 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
'moonvalley-marey': '#DAD9C5',
openai: '#B6B6B6',
pixverse: ['#B465E6', '#E8632A'],
'quiver-ai': '#B6B6B6',
recraft: '#B6B6B6',
reve: '#B6B6B6',
rodin: '#F7F7F7',

View File

@@ -1,49 +0,0 @@
import type { RenderResult } from '@testing-library/vue'
import { render } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { ComponentMountingOptions } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
function createDefaultPlugins() {
return [
PrimeVue,
createTestingPinia({ stubActions: false }),
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
}
function renderWithDefaults<C>(
component: C,
options?: ComponentMountingOptions<C> & { setupUser?: boolean }
): RenderResult & { user: ReturnType<typeof userEvent.setup> | undefined } {
const { setupUser = true, global: globalOptions, ...rest } = options ?? {}
const user = setupUser ? userEvent.setup() : undefined
const result = render(
component as Parameters<typeof render>[0],
{
global: {
plugins: [...createDefaultPlugins(), ...(globalOptions?.plugins ?? [])],
stubs: globalOptions?.stubs,
directives: globalOptions?.directives
},
...rest
} as Parameters<typeof render>[1]
)
return {
...result,
user
}
}
export { renderWithDefaults as render }
export { screen } from '@testing-library/vue'

View File

@@ -30,11 +30,7 @@
"@tests-ui/*": ["./tests-ui/*"]
},
"typeRoots": ["src/types", "node_modules/@types", "./node_modules"],
"types": [
"vitest/globals",
"@webgpu/types",
"@testing-library/jest-dom/vitest"
],
"types": ["vitest/globals", "@webgpu/types"],
"outDir": "./dist",
"rootDir": "./"
},

View File

@@ -1,4 +1,3 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import 'vue'