mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-22 21:37:34 +00:00
Compare commits
16 Commits
codex/trig
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d785a49320 | ||
|
|
c90a5402b4 | ||
|
|
7501a3eefc | ||
|
|
f15476e33f | ||
|
|
114eeb3d3d | ||
|
|
cb3a88a9e2 | ||
|
|
08845025c0 | ||
|
|
9d9b3784a0 | ||
|
|
18023c0ed1 | ||
|
|
cc05ad2d34 | ||
|
|
b0f8b4c56a | ||
|
|
b3f01ac565 | ||
|
|
941620f485 | ||
|
|
7ea5ea581b | ||
|
|
d92b9912a2 | ||
|
|
57c21d9467 |
@@ -51,6 +51,9 @@
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Model-to-node mappings (cloud team)
|
||||
/src/platform/assets/mappings/ @deepme987
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
|
||||
@@ -68,6 +68,41 @@ 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"]')
|
||||
@@ -125,4 +160,42 @@ export class AppModeHelper {
|
||||
await dialogInput.waitFor({ state: 'hidden' })
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem via the popover menu "Rename" action.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInputViaMenu(title: string, newName: string) {
|
||||
const menu = this.getBuilderInputItemMenu(title)
|
||||
await menu.click()
|
||||
await this.page.getByText('Rename', { exact: true }).click()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a builder IoItem by double-clicking its title to trigger
|
||||
* inline editing.
|
||||
* @param title The current widget title shown in the IoItem.
|
||||
* @param newName The new name to assign.
|
||||
*/
|
||||
async renameBuilderInput(title: string, newName: string) {
|
||||
const titleEl = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.filter({ hasText: title })
|
||||
await titleEl.dblclick()
|
||||
|
||||
const input = this.page
|
||||
.getByTestId(TestIds.builder.ioItemTitle)
|
||||
.getByRole('textbox')
|
||||
await input.fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ import type { Page, Route } from '@playwright/test'
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
private jobsRouteHandler: ((route: Route) => void) | null = null
|
||||
private queueJobs: Array<Record<string, unknown>> = []
|
||||
private historyJobs: Array<Record<string, unknown>> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -16,26 +13,6 @@ export class QueueHelper {
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
const baseTime = Date.now()
|
||||
this.queueJobs = [
|
||||
...Array.from({ length: running }, (_, i) => ({
|
||||
id: `running-${i}`,
|
||||
status: 'in_progress',
|
||||
create_time: baseTime - i * 1000,
|
||||
execution_start_time: baseTime - 5000 - i * 1000,
|
||||
execution_end_time: null,
|
||||
priority: i + 1
|
||||
})),
|
||||
...Array.from({ length: pending }, (_, i) => ({
|
||||
id: `pending-${i}`,
|
||||
status: 'pending',
|
||||
create_time: baseTime - (running + i) * 1000,
|
||||
execution_start_time: null,
|
||||
execution_end_time: null,
|
||||
priority: running + i + 1
|
||||
}))
|
||||
]
|
||||
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -58,7 +35,6 @@ export class QueueHelper {
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
await this.installJobsRoute()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,30 +43,6 @@ export class QueueHelper {
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const baseTime = Date.now()
|
||||
this.historyJobs = jobs.map((job, index) => {
|
||||
const completed = job.status === 'success'
|
||||
|
||||
return {
|
||||
id: job.promptId,
|
||||
status: completed ? 'completed' : 'failed',
|
||||
create_time: baseTime - index * 1000,
|
||||
execution_start_time: baseTime - 5000 - index * 1000,
|
||||
execution_end_time: baseTime - index * 1000,
|
||||
outputs_count: completed ? 1 : 0,
|
||||
workflow_id: `workflow-${job.promptId}`,
|
||||
preview_output: completed
|
||||
? {
|
||||
filename: `${job.promptId}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
@@ -109,44 +61,6 @@ export class QueueHelper {
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
await this.installJobsRoute()
|
||||
}
|
||||
|
||||
private async installJobsRoute() {
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses =
|
||||
url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.filter((status) => status.length > 0) ?? []
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0)
|
||||
const limit = Number(url.searchParams.get('limit') ?? 200)
|
||||
const jobs = [...this.queueJobs, ...this.historyJobs].filter(
|
||||
(job) => statuses.length === 0 || statuses.includes(String(job.status))
|
||||
)
|
||||
const paginatedJobs = jobs.slice(offset, offset + limit)
|
||||
|
||||
void route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: paginatedJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: jobs.length,
|
||||
has_more: offset + paginatedJobs.length < jobs.length
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route('**/api/jobs**', this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,9 +75,5 @@ export class QueueHelper {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute('**/api/jobs**', this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export const TestIds = {
|
||||
},
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
},
|
||||
breadcrumb: {
|
||||
|
||||
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
168
browser_tests/tests/appModeDropdownClipping.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -82,7 +82,9 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test('Rename from builder input-select sidebar', async ({ comfyPage }) => {
|
||||
test('Rename from builder input-select sidebar via menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
@@ -91,11 +93,11 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
|
||||
const menu = appMode.getBuilderInputItemMenu('seed')
|
||||
await expect(menu).toBeVisible({ timeout: 5000 })
|
||||
await appMode.renameWidget(menu, 'Builder Input Seed')
|
||||
await appMode.renameBuilderInputViaMenu('seed', 'Builder Input Seed')
|
||||
|
||||
// Verify in app mode after save/reload
|
||||
await appMode.exitBuilder()
|
||||
const workflowName = `${new Date().getTime()} builder-input`
|
||||
const workflowName = `${new Date().getTime()} builder-input-menu`
|
||||
await saveAndReopenInAppMode(comfyPage, workflowName)
|
||||
|
||||
await expect(appMode.linearWidgets).toBeVisible({ timeout: 5000 })
|
||||
@@ -104,6 +106,24 @@ 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)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
@@ -11,58 +9,10 @@ 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 enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
@@ -70,7 +20,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Run button visible in linear mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-run-button"]')
|
||||
@@ -78,7 +28,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Workflow info section visible', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-workflow-info"]')
|
||||
@@ -86,13 +36,13 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Returns to graph mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await enterGraphMode(comfyPage)
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
|
||||
await expect(comfyPage.canvas).toBeVisible({ timeout: 5000 })
|
||||
await expect(
|
||||
@@ -101,7 +51,7 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Canvas not visible in app mode', async ({ comfyPage }) => {
|
||||
await enterAppMode(comfyPage)
|
||||
await comfyPage.appMode.enterAppModeWithInputs([])
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="linear-widgets"]')
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Assets Sidebar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.queue.mockHistory([
|
||||
{ promptId: 'history-asset-1', status: 'success' }
|
||||
])
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: /^Assets/ }).click()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('right-click menu can inspect an asset', async ({ comfyPage }) => {
|
||||
const assetCard = comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
|
||||
await assetCard.click({ button: 'right' })
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByLabel('Close')).toBeVisible()
|
||||
})
|
||||
|
||||
test('actions menu closes on scroll', async ({ comfyPage }) => {
|
||||
const assetCard = comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
|
||||
await assetCard.hover()
|
||||
await assetCard.getByRole('button', { name: /more options/i }).click()
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
await expect(menuPanel).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Job History Sidebar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.queue.mockQueueState()
|
||||
await comfyPage.queue.mockHistory([
|
||||
{ promptId: 'history-job-1', status: 'success' }
|
||||
])
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('right-click menu can inspect a completed job asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
|
||||
await jobRow.click({ button: 'right' })
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.job-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await menuPanel.getByRole('menuitem', { name: /inspect asset/i }).click()
|
||||
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByLabel('Close')).toBeVisible()
|
||||
})
|
||||
|
||||
test('hover popover and actions menu stay clickable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
await jobRow.hover()
|
||||
|
||||
const popover = comfyPage.page.locator('.job-details-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
await popover.getByRole('button', { name: /^copy$/i }).click()
|
||||
|
||||
await jobRow.hover()
|
||||
const moreButton = jobRow.locator('.job-actions-menu-trigger')
|
||||
await expect(moreButton).toBeVisible()
|
||||
await moreButton.click()
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.job-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
const box = await menuPanel.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error('Job actions menu did not render a bounding box')
|
||||
}
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width / 2,
|
||||
box.y + Math.min(box.height / 2, 24)
|
||||
)
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await menuPanel.getByRole('menuitem', { name: /copy job id/i }).click()
|
||||
await expect(menuPanel).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -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 { promptRenameWidget } from '@/utils/widgetUtil'
|
||||
import { renameWidget } 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,
|
||||
rename: () => promptRenameWidget(widget, node, t)
|
||||
canRename: true
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -74,6 +74,16 @@ 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] {
|
||||
@@ -234,7 +244,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
widgetName,
|
||||
label,
|
||||
subLabel,
|
||||
rename
|
||||
canRename
|
||||
} in inputsWithState"
|
||||
:key="`${nodeId}: ${widgetName}`"
|
||||
:class="
|
||||
@@ -242,7 +252,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
"
|
||||
:title="label ?? widgetName"
|
||||
:sub-title="subLabel"
|
||||
:rename
|
||||
:can-rename="canRename"
|
||||
:remove="
|
||||
() =>
|
||||
remove(
|
||||
@@ -250,6 +260,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
([id, name]) => nodeId == id && widgetName === name
|
||||
)
|
||||
"
|
||||
@rename="inlineRenameInput(nodeId, widgetName, $event)"
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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'
|
||||
@@ -44,6 +45,7 @@ const appModeStore = useAppModeStore()
|
||||
const maskEditor = useMaskEditor()
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
|
||||
useEventListener(
|
||||
|
||||
@@ -2,31 +2,43 @@
|
||||
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 { rename, remove } = defineProps<{
|
||||
const { title, canRename, remove } = defineProps<{
|
||||
title: string
|
||||
subTitle?: string
|
||||
rename?: () => void
|
||||
canRename?: boolean
|
||||
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 (rename)
|
||||
if (canRename)
|
||||
items.push({
|
||||
label: t('g.rename'),
|
||||
command: rename,
|
||||
command: () => setTimeout(() => (isEditing.value = true)),
|
||||
icon: 'icon-[lucide--pencil]'
|
||||
})
|
||||
if (remove)
|
||||
@@ -43,13 +55,24 @@ 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 min-w-0 flex-col gap-1">
|
||||
<div
|
||||
v-tooltip.left="{ value: titleTooltip, showDelay: 300 }"
|
||||
class="drag-handle truncate text-sm"
|
||||
<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'
|
||||
)
|
||||
"
|
||||
data-testid="builder-io-item-title"
|
||||
@mouseenter="titleTooltip = isTruncated($event) ? title : null"
|
||||
v-text="title"
|
||||
label-class="drag-handle"
|
||||
label-type="div"
|
||||
@dblclick="canRename && (isEditing = true)"
|
||||
@edit="onEditComplete"
|
||||
@cancel="isEditing = false"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.left="{ value: subTitleTooltip, showDelay: 300 }"
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
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:
|
||||
'<div class="scroll-container" style="max-height: 80px; overflow: auto"><button class="context-trigger" type="button">Trigger</button><div style="height: 200px" /></div>',
|
||||
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 descendant scroll when enabled', async () => {
|
||||
const wrapper = mountComponent({ closeOnScroll: true })
|
||||
await openMenu()
|
||||
|
||||
const scrollContainer = document.body.querySelector('.scroll-container')
|
||||
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error('Scroll container not found')
|
||||
}
|
||||
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await waitForMenuUpdate()
|
||||
|
||||
expect(isMenuVisible()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
<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>
|
||||
@@ -1,75 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
|
||||
const mountComponent = ({ closeOnScroll = false } = {}) =>
|
||||
mount(DropdownMenu, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
closeOnScroll,
|
||||
contentClass: 'dropdown-menu-content'
|
||||
},
|
||||
slots: {
|
||||
button:
|
||||
'<div class="scroll-container" style="max-height: 80px; overflow: auto"><button class="dropdown-trigger" type="button">Trigger</button><div style="height: 200px" /></div>',
|
||||
content:
|
||||
'<div class="dropdown-menu-content-inner" role="menuitem">Action</div>'
|
||||
}
|
||||
})
|
||||
|
||||
async function openMenu() {
|
||||
const trigger = document.body.querySelector('.dropdown-trigger')
|
||||
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error('Dropdown trigger element not found')
|
||||
}
|
||||
|
||||
trigger.click()
|
||||
await waitForMenuUpdate()
|
||||
}
|
||||
|
||||
const isMenuVisible = () =>
|
||||
document.body.querySelector('.dropdown-menu-content-inner') !== null
|
||||
|
||||
const waitForMenuUpdate = async () => {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('DropdownMenu', () => {
|
||||
it('opens from the slotted trigger button', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await openMenu()
|
||||
|
||||
expect(isMenuVisible()).toBe(true)
|
||||
expect(
|
||||
document.body.querySelectorAll('[role="menuitem"]').length
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes on descendant scroll when enabled', async () => {
|
||||
const wrapper = mountComponent({ closeOnScroll: true })
|
||||
await openMenu()
|
||||
|
||||
const scrollContainer = document.body.querySelector('.scroll-container')
|
||||
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error('Scroll container not found')
|
||||
}
|
||||
|
||||
scrollContainer.dispatchEvent(new Event('scroll'))
|
||||
await waitForMenuUpdate()
|
||||
|
||||
expect(isMenuVisible()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,10 @@
|
||||
<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'
|
||||
@@ -21,23 +18,7 @@ defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
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<{
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -45,12 +26,6 @@ const {
|
||||
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(() =>
|
||||
@@ -66,25 +41,10 @@ const contentClass = computed(() =>
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
|
||||
function closeMenu() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
() => {
|
||||
if (closeOnScroll) {
|
||||
closeMenu()
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -95,39 +55,22 @@ useEventListener(
|
||||
|
||||
<DropdownMenuPortal :to>
|
||||
<DropdownMenuContent
|
||||
:align
|
||||
:side
|
||||
:side-offset="sideOffset"
|
||||
:collision-padding="collisionPadding"
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
>
|
||||
<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 :item-class>
|
||||
<DropdownItem
|
||||
v-for="(item, index) in entries ?? []"
|
||||
:key="toValue(item.label) ?? index"
|
||||
:item-class
|
||||
:content-class
|
||||
:item
|
||||
/>
|
||||
</slot>
|
||||
<DropdownMenuArrow
|
||||
v-if="showArrow"
|
||||
class="fill-base-background stroke-border-subtle"
|
||||
/>
|
||||
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="editable-text">
|
||||
<span v-if="!isEditing">
|
||||
<component :is="labelType" v-if="!isEditing" :class="labelClass">
|
||||
{{ modelValue }}
|
||||
</span>
|
||||
</component>
|
||||
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||
<InputText
|
||||
v-else
|
||||
@@ -35,11 +35,15 @@ import { nextTick, ref, watch } from 'vue'
|
||||
const {
|
||||
modelValue,
|
||||
isEditing = false,
|
||||
inputAttrs = {}
|
||||
inputAttrs = {},
|
||||
labelClass = '',
|
||||
labelType = 'span'
|
||||
} = defineProps<{
|
||||
modelValue: string
|
||||
isEditing?: boolean
|
||||
inputAttrs?: Record<string, string>
|
||||
labelClass?: string
|
||||
labelType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['edit', 'cancel'])
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<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>
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ getJobMenuEntries: () => [] })
|
||||
useJobMenu: () => ({ jobMenuEntries: [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
@@ -30,6 +30,10 @@ const JobAssetsListStub = {
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
}
|
||||
|
||||
const JobContextMenuStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const createJob = (): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
@@ -52,7 +56,8 @@ const mountComponent = () =>
|
||||
stubs: {
|
||||
QueueOverlayHeader: QueueOverlayHeaderStub,
|
||||
JobFiltersBar: JobFiltersBarStub,
|
||||
JobAssetsList: JobAssetsListStub
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
JobContextMenu: JobContextMenuStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,28 +23,36 @@
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:get-menu-entries="getJobMenuEntries"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
@delete-item="onDeleteItemEvent"
|
||||
@menu-action="onJobMenuAction"
|
||||
@view-item="$emit('viewItem', $event)"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
JobSortMode,
|
||||
JobTab
|
||||
} from '@/composables/queue/useJobList'
|
||||
import type { MenuActionEntry } from '@/types/menuTypes'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
import JobAssetsList from './job/JobAssetsList.vue'
|
||||
import JobFiltersBar from './job/JobFiltersBar.vue'
|
||||
|
||||
@@ -70,9 +78,14 @@ const emit = defineEmits<{
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { getJobMenuEntries } = useJobMenu((item) => emit('viewItem', item))
|
||||
const { jobMenuEntries } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
(item) => emit('viewItem', item)
|
||||
)
|
||||
|
||||
const onCancelItemEvent = (item: JobListItem) => {
|
||||
emit('cancelItem', item)
|
||||
@@ -82,9 +95,14 @@ const onDeleteItemEvent = (item: JobListItem) => {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(
|
||||
async (entry: MenuActionEntry) => {
|
||||
if (entry.onClick) await entry.onClick()
|
||||
}
|
||||
)
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3,20 +3,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
type JobPreviewOutput = NonNullable<
|
||||
NonNullable<JobListItem['taskRef']>['previewOutput']
|
||||
>
|
||||
|
||||
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
|
||||
default: {
|
||||
name: 'JobDetailsPopover',
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
@@ -41,32 +32,43 @@ vi.mock('vue-i18n', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const createPreviewOutput = (
|
||||
const createResultItem = (
|
||||
filename: string,
|
||||
mediaType: string = 'images'
|
||||
): JobPreviewOutput =>
|
||||
({
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
filename,
|
||||
mediaType,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video',
|
||||
isAudio: mediaType === 'audio',
|
||||
is3D: mediaType === 'model',
|
||||
url: `/api/view/${filename}`
|
||||
}) as JobPreviewOutput
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType
|
||||
})
|
||||
Object.defineProperty(item, 'url', {
|
||||
get: () => `/api/view/${filename}`
|
||||
})
|
||||
return item
|
||||
}
|
||||
|
||||
const createTaskRef = (preview?: JobPreviewOutput): JobListItem['taskRef'] =>
|
||||
({
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: preview
|
||||
}) as JobListItem['taskRef']
|
||||
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
const job: ApiJobListItem = {
|
||||
id: `task-${Math.random().toString(36).slice(2)}`,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
workflow_id: 'workflow-1',
|
||||
priority: 0
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
|
||||
taskRef: createTaskRef(createResultItem('job-1.png')),
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -80,10 +82,7 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
]
|
||||
|
||||
return mount(JobAssetsList, {
|
||||
props: {
|
||||
displayedJobGroups,
|
||||
getMenuEntries: () => []
|
||||
},
|
||||
props: { displayedJobGroups },
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
@@ -148,7 +147,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
@@ -165,7 +164,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
@@ -180,7 +179,7 @@ describe('JobAssetsList', () => {
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
|
||||
@@ -15,98 +15,64 @@
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
>
|
||||
<ContextMenu
|
||||
close-on-scroll
|
||||
content-class="z-1700 bg-transparent p-0 font-inter shadow-lg"
|
||||
<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
|
||||
>
|
||||
<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"
|
||||
close-on-scroll
|
||||
@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="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getMenuEntries(job)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="jobMenuPanelProps"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getMenuEntries(job)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="jobMenuPanelProps"
|
||||
@action="onMenuAction($event)"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +80,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeDetails && popoverPosition"
|
||||
class="job-details-popover fixed z-1700"
|
||||
class="job-details-popover fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
@@ -134,13 +100,9 @@
|
||||
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'
|
||||
@@ -148,32 +110,17 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
const { displayedJobGroups, getMenuEntries } = defineProps<{
|
||||
displayedJobGroups: JobGroup[]
|
||||
getMenuEntries: (item: JobListItem) => MenuEntry[]
|
||||
}>()
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu-action', entry: MenuActionEntry): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const 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 {
|
||||
@@ -205,7 +152,7 @@ function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
scheduleDetailsHide(jobId)
|
||||
scheduleDetailsHide(jobId, clearPopoverAnchor)
|
||||
}
|
||||
|
||||
function onJobEnter(job: JobListItem, event: MouseEvent) {
|
||||
@@ -241,25 +188,6 @@ function isFailedDeletable(job: JobListItem) {
|
||||
return job.showClear !== false && job.state === 'failed'
|
||||
}
|
||||
|
||||
function shouldShowActionsMenu(jobId: string) {
|
||||
return hoveredJobId.value === jobId || openActionsJobId.value === jobId
|
||||
}
|
||||
|
||||
function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
openActionsJobId.value = jobId
|
||||
return
|
||||
}
|
||||
|
||||
if (openActionsJobId.value === jobId) {
|
||||
openActionsJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuAction(entry: MenuActionEntry) {
|
||||
emit('menu-action', entry)
|
||||
}
|
||||
|
||||
function getPreviewOutput(job: JobListItem) {
|
||||
return job.taskRef?.previewOutput
|
||||
}
|
||||
@@ -307,7 +235,7 @@ function onPopoverEnter() {
|
||||
}
|
||||
|
||||
function onPopoverLeave() {
|
||||
scheduleDetailsHide(activeDetails.value?.jobId)
|
||||
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
|
||||
195
src/components/queue/job/JobContextMenu.test.ts
Normal file
195
src/components/queue/job/JobContextMenu.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const popoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
emits: ['show', 'hide'],
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
container: null as HTMLElement | null,
|
||||
eventTarget: null as EventTarget | null,
|
||||
target: null as EventTarget | null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
updated() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
methods: {
|
||||
toggle(event: Event, target?: EventTarget | null) {
|
||||
if (this.visible) {
|
||||
this.hide()
|
||||
return
|
||||
}
|
||||
|
||||
this.show(event, target)
|
||||
},
|
||||
show(event: Event, target?: EventTarget | null) {
|
||||
this.visible = true
|
||||
this.eventTarget = event.currentTarget
|
||||
this.target = target ?? event.currentTarget
|
||||
this.$emit('show')
|
||||
},
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.$emit('hide')
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div v-if="visible" ref="container" class="popover-stub">
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const buttonStub = {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="button-stub"
|
||||
:data-disabled="String(disabled)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const createEntries = (): MenuEntry[] => [
|
||||
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
},
|
||||
{ kind: 'divider', key: 'divider-1' }
|
||||
]
|
||||
|
||||
const mountComponent = (entries: MenuEntry[]) =>
|
||||
mount(JobContextMenu, {
|
||||
props: { entries },
|
||||
global: {
|
||||
stubs: {
|
||||
Popover: popoverStub,
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
|
||||
({
|
||||
type,
|
||||
currentTarget,
|
||||
target: currentTarget
|
||||
}) as Event
|
||||
|
||||
const openMenu = async (
|
||||
wrapper: ReturnType<typeof mountComponent>,
|
||||
type: string = 'click'
|
||||
) => {
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
await wrapper.vm.open(createTriggerEvent(type, trigger))
|
||||
await nextTick()
|
||||
return trigger
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('JobContextMenu', () => {
|
||||
it('passes disabled state to action buttons', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
await openMenu(wrapper)
|
||||
|
||||
const buttons = wrapper.findAll('.button-stub')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].attributes('data-disabled')).toBe('false')
|
||||
expect(buttons[1].attributes('data-disabled')).toBe('true')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits action for enabled entries', async () => {
|
||||
const entries = createEntries()
|
||||
const wrapper = mountComponent(entries)
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.findAll('.button-stub')[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit action for disabled entries', async () => {
|
||||
const wrapper = mountComponent([
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
}
|
||||
])
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.get('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides on pointerdown outside the popover', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(trigger, outside)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
118
src/components/queue/job/JobContextMenu.vue
Normal file
118
src/components/queue/job/JobContextMenu.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<Popover
|
||||
ref="jobItemPopoverRef"
|
||||
:dismissable="false"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
@show="isVisible = true"
|
||||
@hide="onHide"
|
||||
>
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<template v-for="entry in entries" :key="entry.key">
|
||||
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
|
||||
<div class="h-px bg-interface-stroke" />
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
class="w-full justify-start bg-transparent"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:aria-label="entry.label"
|
||||
:disabled="entry.disabled"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
<span>{{ entry.label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'action', entry: MenuEntry): void
|
||||
}>()
|
||||
|
||||
type PopoverHandle = {
|
||||
hide: () => void
|
||||
show: (event: Event, target?: EventTarget | null) => void
|
||||
}
|
||||
|
||||
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isVisible = ref(false)
|
||||
const openedByClick = ref(false)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: isVisible,
|
||||
getOverlayEl: () => contentRef.value,
|
||||
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
|
||||
onDismiss: hide
|
||||
})
|
||||
|
||||
async function open(event: Event) {
|
||||
const trigger =
|
||||
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
||||
const isSameClickTrigger =
|
||||
event.type === 'click' && trigger === triggerRef.value && isVisible.value
|
||||
|
||||
if (isSameClickTrigger) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
openedByClick.value = event.type === 'click'
|
||||
triggerRef.value = trigger
|
||||
|
||||
if (isVisible.value) {
|
||||
hide()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
jobItemPopoverRef.value?.show(event, trigger)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
jobItemPopoverRef.value?.hide()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
isVisible.value = false
|
||||
openedByClick.value = false
|
||||
}
|
||||
|
||||
function onEntry(entry: MenuEntry) {
|
||||
if (entry.kind === 'divider' || entry.disabled) return
|
||||
emit('action', entry)
|
||||
}
|
||||
|
||||
defineExpose({ open, hide })
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="!isPreviewVisible && showDetails && popoverPosition"
|
||||
class="fixed z-1700"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
@@ -23,7 +23,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isPreviewVisible && canShowPreview && popoverPosition"
|
||||
class="fixed z-1700"
|
||||
class="fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
|
||||
@@ -8,38 +8,16 @@
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<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="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<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)"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
@@ -48,56 +26,24 @@
|
||||
<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,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'zoom', asset: AssetItem): void
|
||||
(e: 'asset-deleted'): void
|
||||
(e: 'bulk-download', assets: AssetItem[]): void
|
||||
(e: 'bulk-delete', assets: AssetItem[]): void
|
||||
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-open-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-export-workflow', assets: AssetItem[]): void
|
||||
(e: 'output-count-click', asset: AssetItem): void
|
||||
}>()
|
||||
const { 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 }
|
||||
|
||||
@@ -108,21 +54,6 @@ const assetItems = computed<AssetGridItem[]>(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
|
||||
return getMenuEntries({
|
||||
asset,
|
||||
assetType: getAssetType(asset.tags),
|
||||
fileKind: getMediaTypeFromFilename(asset.name),
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
})
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
const gridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
@@ -8,9 +7,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
|
||||
useMediaAssetMenu: () => ({
|
||||
getMenuEntries: () => []
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -20,18 +19,7 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {}
|
||||
}
|
||||
})
|
||||
|
||||
const VirtualGridStub = {
|
||||
const VirtualGridStub = defineComponent({
|
||||
name: 'VirtualGrid',
|
||||
props: {
|
||||
items: {
|
||||
@@ -41,41 +29,7 @@ const VirtualGridStub = {
|
||||
},
|
||||
template:
|
||||
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
|
||||
}
|
||||
|
||||
const AssetsListItemStub = {
|
||||
name: 'AssetsListItem',
|
||||
template:
|
||||
'<div class="assets-list-item-stub"><slot /><slot name="actions" /></div>'
|
||||
}
|
||||
|
||||
const ContextMenuStub = {
|
||||
name: 'ContextMenu',
|
||||
template:
|
||||
'<div class="context-menu-stub"><slot /><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 =>
|
||||
({
|
||||
@@ -99,35 +53,12 @@ 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 = {
|
||||
@@ -200,46 +131,4 @@ describe('AssetsSidebarListView', () => {
|
||||
|
||||
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
|
||||
})
|
||||
|
||||
it('keeps the row actions menu available after the row loses hover', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-open', 'image.png'),
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
|
||||
const assetListItem = wrapper.find('.assets-list-item-stub')
|
||||
|
||||
await assetListItem.trigger('mouseenter')
|
||||
|
||||
const actionsMenu = wrapper.findComponent(DropdownMenuStub)
|
||||
expect(actionsMenu.exists()).toBe(true)
|
||||
|
||||
actionsMenu.vm.$emit('update:open', true)
|
||||
await nextTick()
|
||||
await assetListItem.trigger('mouseleave')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(true)
|
||||
|
||||
wrapper.findComponent(DropdownMenuStub).vm.$emit('update:open', false)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not select the row when clicking the actions trigger', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-actions', 'image.png'),
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
|
||||
const assetListItem = wrapper.find('.assets-list-item-stub')
|
||||
|
||||
await assetListItem.trigger('mouseenter')
|
||||
await wrapper.find('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select-asset')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,83 +16,49 @@
|
||||
>
|
||||
<i class="pi pi-trash text-xs" />
|
||||
</LoadingOverlay>
|
||||
<ContextMenu
|
||||
close-on-scroll
|
||||
content-class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
<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)"
|
||||
>
|
||||
<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="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<template #content="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
<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>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</AssetsListItem>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -103,23 +69,16 @@
|
||||
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,
|
||||
@@ -133,19 +92,13 @@ const {
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
toggleStack
|
||||
} = defineProps<{
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => Promise<void>
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
@@ -153,27 +106,12 @@ const assetsStore = useAssetsStore()
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
||||
(e: 'preview-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'asset-deleted'): void
|
||||
(e: 'bulk-download', assets: AssetItem[]): void
|
||||
(e: 'bulk-delete', assets: AssetItem[]): void
|
||||
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-open-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-export-workflow', assets: AssetItem[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
const openActionsAssetId = ref<string | null>(null)
|
||||
const { getMenuEntries } = useMediaAssetMenu({
|
||||
inspectAsset: (asset) => emit('preview-asset', asset),
|
||||
assetDeleted: () => emit('asset-deleted'),
|
||||
bulkDownload: (assets) => emit('bulk-download', assets),
|
||||
bulkDelete: (assets) => emit('bulk-delete', assets),
|
||||
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
|
||||
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
|
||||
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
|
||||
})
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
@@ -190,17 +128,6 @@ function getAssetMediaType(asset: AssetItem) {
|
||||
return getMediaTypeFromFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
|
||||
return getMenuEntries({
|
||||
asset,
|
||||
assetType: getAssetType(asset.tags),
|
||||
fileKind: getAssetMediaType(asset),
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
})
|
||||
}
|
||||
|
||||
function isVideoAsset(asset: AssetItem): boolean {
|
||||
return getAssetMediaType(asset) === 'video'
|
||||
}
|
||||
@@ -253,27 +180,6 @@ function getAssetCardClass(selected: boolean): string {
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowActionsMenu(assetId: string): boolean {
|
||||
return (
|
||||
hoveredAssetId.value === assetId || openActionsAssetId.value === assetId
|
||||
)
|
||||
}
|
||||
|
||||
function onActionsMenuOpenChange(assetId: string, isOpen: boolean): void {
|
||||
if (isOpen) {
|
||||
openActionsAssetId.value = assetId
|
||||
return
|
||||
}
|
||||
|
||||
if (openActionsAssetId.value === assetId) {
|
||||
openActionsAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
function onAssetEnter(assetId: string) {
|
||||
hoveredAssetId.value = assetId
|
||||
}
|
||||
|
||||
@@ -94,18 +94,10 @@
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<AssetsSidebarGridView
|
||||
@@ -114,16 +106,8 @@
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
@@ -190,6 +174,24 @@
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<MediaAssetContextMenu
|
||||
v-if="contextMenuAsset"
|
||||
ref="contextMenuRef"
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@zoom="handleZoomClick(contextMenuAsset)"
|
||||
@hide="handleContextMenuHide"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -198,12 +200,14 @@ import {
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
@@ -220,7 +224,9 @@ import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
@@ -230,6 +236,7 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -260,6 +267,9 @@ const viewMode = useStorage<'list' | 'grid'>(
|
||||
)
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
@@ -267,6 +277,14 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -484,6 +502,26 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
|
||||
() => {
|
||||
contextMenuAsset.value = null
|
||||
},
|
||||
0,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
cancelCleanup()
|
||||
contextMenuAsset.value = asset
|
||||
void nextTick(() => {
|
||||
contextMenuRef.value?.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
scheduleCleanup()
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
downloadMultipleAssets(assets)
|
||||
clearSelection()
|
||||
|
||||
@@ -1,76 +1,78 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import { computed, defineComponent, ref } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
groupedJobItems: [] as Array<{
|
||||
key: string
|
||||
label: string
|
||||
items: JobListItem[]
|
||||
}>,
|
||||
filteredTasks: [] as JobListItem[],
|
||||
getJobMenuEntries: vi.fn(() => []),
|
||||
cancelJob: vi.fn(),
|
||||
openResultGallery: vi.fn(),
|
||||
showQueueClearHistoryDialog: vi.fn(),
|
||||
commandExecute: vi.fn(),
|
||||
showDialog: vi.fn(),
|
||||
clearInitializationByJobIds: vi.fn(),
|
||||
queueDelete: vi.fn()
|
||||
}))
|
||||
|
||||
const JobAssetsListStub = defineComponent({
|
||||
name: 'JobAssetsList',
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
displayedJobGroups: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
getMenuEntries: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
jobId: { type: String, required: true },
|
||||
workflowId: { type: String, default: undefined }
|
||||
},
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobList', () => ({
|
||||
useJobList: () => ({
|
||||
selectedJobTab: ref('All'),
|
||||
selectedWorkflowFilter: ref('all'),
|
||||
selectedSortMode: ref('mostRecent'),
|
||||
searchQuery: ref(''),
|
||||
hasFailedJobs: ref(false),
|
||||
filteredTasks: computed(() => testState.filteredTasks),
|
||||
groupedJobItems: computed(() => testState.groupedJobItems)
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/queue/useJobList', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const jobHistoryItem = {
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
url: '/api/view/job-1.png'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
useJobList: () => ({
|
||||
selectedJobTab: ref('All'),
|
||||
selectedWorkflowFilter: ref('all'),
|
||||
selectedSortMode: ref('mostRecent'),
|
||||
searchQuery: ref(''),
|
||||
hasFailedJobs: ref(false),
|
||||
filteredTasks: ref([]),
|
||||
groupedJobItems: ref([
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items: [jobHistoryItem]
|
||||
}
|
||||
])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({
|
||||
getJobMenuEntries: testState.getJobMenuEntries,
|
||||
cancelJob: testState.cancelJob
|
||||
jobMenuEntries: [],
|
||||
cancelJob: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useQueueClearHistoryDialog', () => ({
|
||||
useQueueClearHistoryDialog: () => ({
|
||||
showQueueClearHistoryDialog: testState.showQueueClearHistoryDialog
|
||||
showQueueClearHistoryDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/queue/useResultGallery', () => ({
|
||||
useResultGallery: () => ({
|
||||
galleryActiveIndex: ref(-1),
|
||||
galleryItems: ref([]),
|
||||
onViewItem: testState.openResultGallery
|
||||
})
|
||||
}))
|
||||
vi.mock('@/composables/queue/useResultGallery', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useResultGallery: () => ({
|
||||
galleryActiveIndex: ref(-1),
|
||||
galleryItems: ref([]),
|
||||
onViewItem: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
useErrorHandling: () => ({
|
||||
@@ -82,19 +84,19 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
|
||||
vi.mock('@/stores/commandStore', () => ({
|
||||
useCommandStore: () => ({
|
||||
execute: testState.commandExecute
|
||||
execute: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
showDialog: testState.showDialog
|
||||
showDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
clearInitializationByJobIds: testState.clearInitializationByJobIds
|
||||
clearInitializationByJobIds: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -102,7 +104,7 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
useQueueStore: () => ({
|
||||
runningTasks: [],
|
||||
pendingTasks: [],
|
||||
delete: testState.queueDelete
|
||||
delete: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -112,33 +114,11 @@ const i18n = createI18n({
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem =>
|
||||
({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: true,
|
||||
isVideo: false,
|
||||
is3D: false,
|
||||
url: '/api/view/job-1.png'
|
||||
}
|
||||
},
|
||||
...overrides
|
||||
}) as JobListItem
|
||||
|
||||
const setDisplayedJobs = (items: JobListItem[]) => {
|
||||
testState.filteredTasks = items
|
||||
testState.groupedJobItems = [
|
||||
{
|
||||
key: 'group-1',
|
||||
label: 'Group 1',
|
||||
items
|
||||
}
|
||||
]
|
||||
const SidebarTabTemplateStub = {
|
||||
name: 'SidebarTabTemplate',
|
||||
props: ['title'],
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
|
||||
function mountComponent() {
|
||||
@@ -146,106 +126,38 @@ function mountComponent() {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
SidebarTabTemplate: {
|
||||
name: 'SidebarTabTemplate',
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
|
||||
},
|
||||
SidebarTabTemplate: SidebarTabTemplateStub,
|
||||
JobFilterTabs: true,
|
||||
JobFilterActions: true,
|
||||
JobHistoryActionsMenu: true,
|
||||
MediaLightbox: true,
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
teleport: true
|
||||
JobContextMenu: true,
|
||||
ResultGallery: true,
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('JobHistorySidebarTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setDisplayedJobs([buildJob()])
|
||||
})
|
||||
|
||||
it('passes grouped jobs and menu getter to JobAssetsList', () => {
|
||||
it('shows the job details popover for jobs in the history panel', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wrapper = mountComponent()
|
||||
const jobAssetsList = wrapper.findComponent(JobAssetsListStub)
|
||||
const jobRow = wrapper.find('[data-job-id="job-1"]')
|
||||
|
||||
expect(jobAssetsList.props('displayedJobGroups')).toEqual(
|
||||
testState.groupedJobItems
|
||||
)
|
||||
expect(jobAssetsList.props('getMenuEntries')).toBe(
|
||||
testState.getJobMenuEntries
|
||||
)
|
||||
})
|
||||
await jobRow.trigger('mouseenter')
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
it('forwards regular view-item events to the result gallery', async () => {
|
||||
const job = buildJob()
|
||||
setDisplayedJobs([job])
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
|
||||
|
||||
expect(testState.openResultGallery).toHaveBeenCalledWith(job)
|
||||
expect(testState.showDialog).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the 3D viewer dialog for 3D view-item events', async () => {
|
||||
const job = buildJob({
|
||||
taskRef: {
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: {
|
||||
isImage: false,
|
||||
isVideo: false,
|
||||
is3D: true,
|
||||
url: '/api/view/job-1.glb'
|
||||
}
|
||||
} as JobListItem['taskRef']
|
||||
const popover = wrapper.findComponent(JobDetailsPopoverStub)
|
||||
expect(popover.exists()).toBe(true)
|
||||
expect(popover.props()).toMatchObject({
|
||||
jobId: 'job-1',
|
||||
workflowId: 'workflow-1'
|
||||
})
|
||||
setDisplayedJobs([job])
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('view-item', job)
|
||||
|
||||
expect(testState.showDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: 'asset-3d-viewer',
|
||||
title: job.title,
|
||||
props: { modelUrl: '/api/view/job-1.glb' }
|
||||
})
|
||||
)
|
||||
expect(testState.openResultGallery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards cancel-item events to useJobMenu.cancelJob', async () => {
|
||||
const job = buildJob({ state: 'running' })
|
||||
setDisplayedJobs([job])
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('cancel-item', job)
|
||||
|
||||
expect(testState.cancelJob).toHaveBeenCalledWith(job)
|
||||
})
|
||||
|
||||
it('forwards delete-item events to queueStore.delete', async () => {
|
||||
const job = buildJob()
|
||||
const taskRef = job.taskRef
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper.findComponent(JobAssetsListStub).vm.$emit('delete-item', job)
|
||||
|
||||
expect(testState.queueDelete).toHaveBeenCalledWith(taskRef)
|
||||
})
|
||||
|
||||
it('runs menu actions emitted by JobAssetsList', async () => {
|
||||
const onClick = vi.fn()
|
||||
const wrapper = mountComponent()
|
||||
|
||||
wrapper
|
||||
.findComponent(JobAssetsListStub)
|
||||
.vm.$emit('menu-action', { key: 'test', label: 'Test', onClick })
|
||||
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,11 +48,15 @@
|
||||
<template #body>
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:get-menu-entries="getJobMenuEntries"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@menu-action="onJobMenuAction"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
@@ -63,14 +67,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
|
||||
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
import type { MenuActionEntry } from '@/types/menuTypes'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
@@ -177,7 +182,13 @@ const onInspectAsset = (item: JobListItem) => {
|
||||
void onViewItem(item)
|
||||
}
|
||||
|
||||
const { getJobMenuEntries, cancelJob } = useJobMenu(onInspectAsset)
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
const { jobMenuEntries, cancelJob } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
onInspectAsset
|
||||
)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await cancelJob(item)
|
||||
@@ -188,9 +199,14 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await queueStore.delete(item.taskRef)
|
||||
})
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(
|
||||
async (entry: MenuActionEntry) => {
|
||||
if (entry.onClick) await entry.onClick()
|
||||
}
|
||||
)
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -65,7 +65,7 @@ export function useJobDetailsHover<TActive>({
|
||||
}, DETAILS_SHOW_DELAY_MS)
|
||||
}
|
||||
|
||||
function scheduleDetailsHide(jobId?: string) {
|
||||
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
|
||||
if (!jobId) return
|
||||
|
||||
clearShowTimer()
|
||||
@@ -79,7 +79,7 @@ export function useJobDetailsHover<TActive>({
|
||||
const currentActive = activeDetails.value
|
||||
if (currentActive && getActiveId(currentActive) === jobId) {
|
||||
activeDetails.value = null
|
||||
onReset?.()
|
||||
onHide?.()
|
||||
}
|
||||
hideTimer.value = null
|
||||
hideTimerJobId.value = null
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/types/menuTypes'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
@@ -170,13 +172,10 @@ const createJobItem = (
|
||||
computeHours: overrides.computeHours
|
||||
})
|
||||
|
||||
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
|
||||
useJobMenu(onInspectAsset)
|
||||
let currentItem: Ref<JobListItem | null>
|
||||
|
||||
const getMenuEntries = (
|
||||
item: JobListItem | null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) => mountJobMenu(onInspectAsset).getJobMenuEntries(item)
|
||||
const mountJobMenu = (onInspectAsset?: (item: JobListItem) => void) =>
|
||||
useJobMenu(() => currentItem.value, onInspectAsset)
|
||||
|
||||
const findActionEntry = (entries: MenuEntry[], key: string) =>
|
||||
entries.find(
|
||||
@@ -187,6 +186,7 @@ const findActionEntry = (entries: MenuEntry[], key: string) =>
|
||||
describe('useJobMenu', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentItem = ref<JobListItem | null>(null)
|
||||
settingStoreMock.get.mockReturnValue(false)
|
||||
dialogServiceMock.prompt.mockResolvedValue(undefined)
|
||||
litegraphServiceMock.getCanvasCenter.mockReturnValue([100, 200])
|
||||
@@ -212,14 +212,18 @@ describe('useJobMenu', () => {
|
||||
getJobWorkflowMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
const setCurrentItem = (item: JobListItem | null) => {
|
||||
currentItem.value = item
|
||||
}
|
||||
|
||||
it('opens workflow when workflow data exists', async () => {
|
||||
const { openJobWorkflow } = mountJobMenu()
|
||||
const workflow = { nodes: [] }
|
||||
// Mock lazy loading via fetchJobDetail + extractWorkflow
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
const item = createJobItem({ id: '55' })
|
||||
setCurrentItem(createJobItem({ id: '55' }))
|
||||
|
||||
await openJobWorkflow(item)
|
||||
await openJobWorkflow()
|
||||
|
||||
expect(getJobWorkflowMock).toHaveBeenCalledWith('55')
|
||||
expect(workflowStoreMock.createTemporary).toHaveBeenCalledWith(
|
||||
@@ -234,9 +238,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('does nothing when workflow is missing', async () => {
|
||||
const { openJobWorkflow } = mountJobMenu()
|
||||
const item = createJobItem({ taskRef: {} })
|
||||
setCurrentItem(createJobItem({ taskRef: {} }))
|
||||
|
||||
await openJobWorkflow(item)
|
||||
await openJobWorkflow()
|
||||
|
||||
expect(workflowStoreMock.createTemporary).not.toHaveBeenCalled()
|
||||
expect(workflowServiceMock.openWorkflow).not.toHaveBeenCalled()
|
||||
@@ -244,9 +248,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('copies job id to clipboard', async () => {
|
||||
const { copyJobId } = mountJobMenu()
|
||||
const item = createJobItem({ id: 'job-99' })
|
||||
setCurrentItem(createJobItem({ id: 'job-99' }))
|
||||
|
||||
await copyJobId(item)
|
||||
await copyJobId()
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('job-99')
|
||||
})
|
||||
@@ -264,9 +268,9 @@ describe('useJobMenu', () => {
|
||||
['initialization', interruptMock, deleteItemMock]
|
||||
])('cancels %s job via interrupt', async (state) => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
const item = createJobItem({ state: state as JobListItem['state'] })
|
||||
setCurrentItem(createJobItem({ state: state as JobListItem['state'] }))
|
||||
|
||||
await cancelJob(item)
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).toHaveBeenCalledWith('job-1')
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
@@ -275,9 +279,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('cancels pending job via deleteItem', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
const item = createJobItem({ state: 'pending' })
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
await cancelJob(item)
|
||||
await cancelJob()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
expect(queueStoreMock.update).toHaveBeenCalled()
|
||||
@@ -285,9 +289,9 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('still updates queue for uncancellable states', async () => {
|
||||
const { cancelJob } = mountJobMenu()
|
||||
const item = createJobItem({ state: 'completed' })
|
||||
setCurrentItem(createJobItem({ state: 'completed' }))
|
||||
|
||||
await cancelJob(item)
|
||||
await cancelJob()
|
||||
|
||||
expect(interruptMock).not.toHaveBeenCalled()
|
||||
expect(deleteItemMock).not.toHaveBeenCalled()
|
||||
@@ -295,7 +299,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('copies error message from failed job entry', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
@@ -304,7 +309,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'copy-error')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith('Something went wrong')
|
||||
@@ -323,7 +329,8 @@ describe('useJobMenu', () => {
|
||||
current_inputs: {},
|
||||
current_outputs: {}
|
||||
}
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
@@ -334,7 +341,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'report-error')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).toHaveBeenCalledTimes(1)
|
||||
@@ -345,7 +353,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('falls back to simple error dialog when no execution_error', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: {
|
||||
@@ -354,7 +363,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'report-error')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.showExecutionErrorDialog).not.toHaveBeenCalled()
|
||||
@@ -367,16 +377,18 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('ignores error actions when message missing', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: undefined } as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
const copyEntry = findActionEntry(entries, 'copy-error')
|
||||
await nextTick()
|
||||
const copyEntry = findActionEntry(jobMenuEntries.value, 'copy-error')
|
||||
await copyEntry?.onClick?.()
|
||||
const reportEntry = findActionEntry(entries, 'report-error')
|
||||
const reportEntry = findActionEntry(jobMenuEntries.value, 'report-error')
|
||||
await reportEntry?.onClick?.()
|
||||
|
||||
expect(copyToClipboardMock).not.toHaveBeenCalled()
|
||||
@@ -414,6 +426,7 @@ describe('useJobMenu', () => {
|
||||
graph: { setDirtyCanvas: vi.fn() }
|
||||
}
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(node)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const preview = {
|
||||
filename: 'foo.png',
|
||||
subfolder: 'bar',
|
||||
@@ -421,14 +434,15 @@ describe('useJobMenu', () => {
|
||||
url: 'http://asset',
|
||||
...flags
|
||||
}
|
||||
const entries = getMenuEntries(
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: preview }
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalledWith(
|
||||
@@ -443,7 +457,8 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('skips adding node when no loader definition', async () => {
|
||||
delete nodeDefStoreMock.nodeDefsByName.LoadImage
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -457,14 +472,16 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips adding node when preview output lacks media flags', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -477,7 +494,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
@@ -486,7 +504,8 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('skips annotating when litegraph node creation fails', async () => {
|
||||
litegraphServiceMock.addNodeOnGraph.mockReturnValueOnce(null)
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -500,7 +519,8 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).toHaveBeenCalled()
|
||||
@@ -508,21 +528,24 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('ignores add-to-current entry when preview missing entirely', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'add-to-current')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'add-to-current')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(litegraphServiceMock.addNodeOnGraph).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads preview asset when requested', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {
|
||||
@@ -531,21 +554,24 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'download')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
void entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).toHaveBeenCalledWith('https://asset')
|
||||
})
|
||||
|
||||
it('ignores download request when preview missing', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: {} as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'download')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'download')
|
||||
void entry?.onClick?.()
|
||||
|
||||
expect(downloadFileMock).not.toHaveBeenCalled()
|
||||
@@ -554,14 +580,16 @@ describe('useJobMenu', () => {
|
||||
it('exports workflow with default filename when prompting disabled', async () => {
|
||||
const workflow = { foo: 'bar' }
|
||||
getJobWorkflowMock.mockResolvedValue(workflow)
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '7',
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.prompt).not.toHaveBeenCalled()
|
||||
@@ -577,13 +605,15 @@ describe('useJobMenu', () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('custom-name')
|
||||
getJobWorkflowMock.mockResolvedValue({})
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(dialogServiceMock.prompt).toHaveBeenCalledWith({
|
||||
@@ -599,14 +629,16 @@ describe('useJobMenu', () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('existing.json')
|
||||
getJobWorkflowMock.mockResolvedValue({ foo: 'bar' })
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
id: '42',
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(appendJsonExtMock).toHaveBeenCalledWith('existing.json')
|
||||
@@ -618,13 +650,15 @@ describe('useJobMenu', () => {
|
||||
settingStoreMock.get.mockReturnValue(true)
|
||||
dialogServiceMock.prompt.mockResolvedValue('')
|
||||
getJobWorkflowMock.mockResolvedValue({})
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed'
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'export-workflow')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'export-workflow')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
@@ -632,13 +666,13 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('deletes preview asset when confirmed', async () => {
|
||||
mediaAssetActionsMock.deleteAssets.mockResolvedValue(true)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
const preview = { filename: 'foo', subfolder: 'bar', type: 'output' }
|
||||
const taskRef = { previewOutput: preview }
|
||||
const entries = getMenuEntries(
|
||||
createJobItem({ state: 'completed', taskRef })
|
||||
)
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef }))
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(mapTaskOutputToAssetItemMock).toHaveBeenCalledWith(taskRef, preview)
|
||||
@@ -647,14 +681,16 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('does not refresh queue when delete cancelled', async () => {
|
||||
mediaAssetActionsMock.deleteAssets.mockResolvedValue(false)
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.update).not.toHaveBeenCalled()
|
||||
@@ -662,18 +698,22 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('removes failed job via menu entry', async () => {
|
||||
const taskRef = { id: 'task-1' }
|
||||
const entries = getMenuEntries(createJobItem({ state: 'failed', taskRef }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed', taskRef }))
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.delete).toHaveBeenCalledWith(taskRef)
|
||||
})
|
||||
|
||||
it('ignores failed job delete when taskRef missing', async () => {
|
||||
const entries = getMenuEntries(createJobItem({ state: 'failed' }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'failed' }))
|
||||
|
||||
const entry = findActionEntry(entries, 'delete')
|
||||
await nextTick()
|
||||
const entry = findActionEntry(jobMenuEntries.value, 'delete')
|
||||
await entry?.onClick?.()
|
||||
|
||||
expect(queueStoreMock.delete).not.toHaveBeenCalled()
|
||||
@@ -681,13 +721,16 @@ describe('useJobMenu', () => {
|
||||
|
||||
it('provides completed menu structure with delete option', async () => {
|
||||
const inspectSpy = vi.fn()
|
||||
const item = createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
const entries = getMenuEntries(item, inspectSpy)
|
||||
const { jobMenuEntries } = mountJobMenu(inspectSpy)
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'inspect-asset',
|
||||
'add-to-current',
|
||||
'download',
|
||||
@@ -700,48 +743,66 @@ describe('useJobMenu', () => {
|
||||
'delete'
|
||||
])
|
||||
|
||||
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(false)
|
||||
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(false)
|
||||
expect(findActionEntry(entries, 'download')?.disabled).toBe(false)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(false)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(false)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
const inspectEntry = findActionEntry(entries, 'inspect-asset')
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
await inspectEntry?.onClick?.()
|
||||
expect(inspectSpy).toHaveBeenCalledWith(item)
|
||||
expect(inspectSpy).toHaveBeenCalledWith(currentItem.value)
|
||||
})
|
||||
|
||||
it('omits inspect handler when callback missing', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'completed',
|
||||
taskRef: { previewOutput: {} }
|
||||
})
|
||||
)
|
||||
|
||||
const inspectEntry = findActionEntry(entries, 'inspect-asset')
|
||||
await nextTick()
|
||||
const inspectEntry = findActionEntry(jobMenuEntries.value, 'inspect-asset')
|
||||
expect(inspectEntry?.onClick).toBeUndefined()
|
||||
expect(inspectEntry?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('omits delete asset entry when no preview exists', async () => {
|
||||
const entries = getMenuEntries(
|
||||
createJobItem({ state: 'completed', taskRef: {} })
|
||||
)
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'completed', taskRef: {} }))
|
||||
|
||||
expect(findActionEntry(entries, 'inspect-asset')?.disabled).toBe(true)
|
||||
expect(findActionEntry(entries, 'add-to-current')?.disabled).toBe(true)
|
||||
expect(findActionEntry(entries, 'download')?.disabled).toBe(true)
|
||||
expect(entries.some((entry) => entry.key === 'delete')).toBe(false)
|
||||
await nextTick()
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
|
||||
).toBe(true)
|
||||
expect(
|
||||
findActionEntry(jobMenuEntries.value, 'add-to-current')?.disabled
|
||||
).toBe(true)
|
||||
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
|
||||
true
|
||||
)
|
||||
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('returns failed menu entries with error actions', async () => {
|
||||
const entries = getMenuEntries(
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(
|
||||
createJobItem({
|
||||
state: 'failed',
|
||||
taskRef: { errorMessage: 'Some error' } as Partial<TaskItemImpl>
|
||||
})
|
||||
)
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
@@ -753,9 +814,11 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('returns active job entries with cancel option', async () => {
|
||||
const entries = getMenuEntries(createJobItem({ state: 'running' }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'running' }))
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
@@ -765,16 +828,18 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('provides pending job entries and triggers cancel action', async () => {
|
||||
const entries = getMenuEntries(createJobItem({ state: 'pending' }))
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(createJobItem({ state: 'pending' }))
|
||||
|
||||
expect(entries.map((entry) => entry.key)).toEqual([
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value.map((entry) => entry.key)).toEqual([
|
||||
'open-workflow',
|
||||
'd1',
|
||||
'copy-id',
|
||||
'd2',
|
||||
'cancel-job'
|
||||
])
|
||||
const cancelEntry = findActionEntry(entries, 'cancel-job')
|
||||
const cancelEntry = findActionEntry(jobMenuEntries.value, 'cancel-job')
|
||||
await cancelEntry?.onClick?.()
|
||||
|
||||
expect(deleteItemMock).toHaveBeenCalledWith('queue', 'job-1')
|
||||
@@ -782,6 +847,10 @@ describe('useJobMenu', () => {
|
||||
})
|
||||
|
||||
it('returns empty menu when no job selected', async () => {
|
||||
expect(getMenuEntries(null)).toEqual([])
|
||||
const { jobMenuEntries } = mountJobMenu()
|
||||
setCurrentItem(null)
|
||||
|
||||
await nextTick()
|
||||
expect(jobMenuEntries.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,16 +20,30 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { MenuEntry } from '@/types/menuTypes'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
export type MenuEntry =
|
||||
| {
|
||||
kind?: 'item'
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
| { kind: 'divider'; key: string }
|
||||
|
||||
/**
|
||||
* Provides job context menu entries and actions.
|
||||
*
|
||||
* @param currentMenuItem Getter for the currently targeted job list item
|
||||
* @param onInspectAsset Callback to trigger when inspecting a completed job's asset
|
||||
*/
|
||||
export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
export function useJobMenu(
|
||||
currentMenuItem: () => JobListItem | null = () => null,
|
||||
onInspectAsset?: (item: JobListItem) => void
|
||||
) {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const queueStore = useQueueStore()
|
||||
@@ -39,8 +53,11 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const mediaAssetActions = useMediaAssetActions()
|
||||
|
||||
const resolveItem = (item?: JobListItem | null): JobListItem | null =>
|
||||
item ?? currentMenuItem()
|
||||
|
||||
const openJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = await getJobWorkflow(target.id)
|
||||
if (!data) return
|
||||
@@ -50,13 +67,13 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
}
|
||||
|
||||
const copyJobId = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
await copyToClipboard(target.id)
|
||||
}
|
||||
|
||||
const cancelJob = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
if (target.state === 'running' || target.state === 'initialization') {
|
||||
if (isCloud) {
|
||||
@@ -72,13 +89,13 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
}
|
||||
|
||||
const copyErrorMessage = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
const message = target?.taskRef?.errorMessage
|
||||
if (message) await copyToClipboard(message)
|
||||
}
|
||||
|
||||
const reportError = (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
|
||||
// Use execution_error from list response if available
|
||||
@@ -100,10 +117,10 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
@@ -151,10 +168,10 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
@@ -162,14 +179,14 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const data = await getJobWorkflow(target.id)
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = await getJobWorkflow(item.id)
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${target.id}.json`
|
||||
let filename = `Job ${item.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
@@ -186,10 +203,10 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
||||
const target = item
|
||||
if (!target) return
|
||||
const task = target.taskRef as TaskItemImpl | undefined
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
@@ -201,7 +218,8 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
}
|
||||
|
||||
const removeFailedJob = async (task?: TaskItemImpl | null) => {
|
||||
const target = task
|
||||
const target =
|
||||
task ?? (currentMenuItem()?.taskRef as TaskItemImpl | undefined)
|
||||
if (!target) return
|
||||
await queueStore.delete(target)
|
||||
}
|
||||
@@ -219,11 +237,11 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
st('queue.jobMenu.cancelJob', 'Cancel job')
|
||||
)
|
||||
|
||||
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
|
||||
const target = item
|
||||
const state = target?.state
|
||||
const jobMenuEntries = computed<MenuEntry[]>(() => {
|
||||
const item = currentMenuItem()
|
||||
const state = item?.state
|
||||
if (!state) return []
|
||||
const hasPreviewAsset = !!target?.taskRef?.previewOutput
|
||||
const hasPreviewAsset = !!item?.taskRef?.previewOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
@@ -233,7 +251,8 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
disabled: !hasPreviewAsset || !onInspectAsset,
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
if (target) onInspectAsset(target)
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
@@ -245,34 +264,34 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: () => addOutputLoaderNode(target)
|
||||
onClick: addOutputLoaderNode
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: () => downloadPreviewAsset(target)
|
||||
onClick: downloadPreviewAsset
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(target)
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: () => exportJobWorkflow(target)
|
||||
onClick: exportJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(target)
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasPreviewAsset
|
||||
@@ -281,7 +300,7 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: () => deleteJobAsset(target)
|
||||
onClick: deleteJobAsset
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -293,33 +312,33 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(target)
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(target)
|
||||
onClick: copyJobId
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyErrorMessage(target)
|
||||
onClick: copyErrorMessage
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: () => reportError(target)
|
||||
onClick: reportError
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: () => removeFailedJob(target?.taskRef)
|
||||
onClick: removeFailedJob
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -328,27 +347,27 @@ export function useJobMenu(onInspectAsset?: (item: JobListItem) => void) {
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => openJobWorkflow(target)
|
||||
onClick: openJobWorkflow
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: () => copyJobId(target)
|
||||
onClick: copyJobId
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: () => cancelJob(target)
|
||||
onClick: cancelJob
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
getJobMenuEntries: buildJobMenuEntries,
|
||||
jobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
cancelJob,
|
||||
|
||||
108
src/composables/useDismissableOverlay.test.ts
Normal file
108
src/composables/useDismissableOverlay.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { EffectScope, Ref } from 'vue'
|
||||
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
|
||||
describe('useDismissableOverlay', () => {
|
||||
let scope: EffectScope | undefined
|
||||
let isOpen: Ref<boolean>
|
||||
let overlayEl: HTMLElement
|
||||
let triggerEl: HTMLElement
|
||||
let outsideEl: HTMLElement
|
||||
let dismissCount: number
|
||||
|
||||
const mountComposable = ({
|
||||
dismissOnScroll = false,
|
||||
getTriggerEl
|
||||
}: {
|
||||
dismissOnScroll?: boolean
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
} = {}) => {
|
||||
scope = effectScope()
|
||||
scope.run(() =>
|
||||
useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl: () => overlayEl,
|
||||
getTriggerEl,
|
||||
onDismiss: () => {
|
||||
dismissCount += 1
|
||||
},
|
||||
dismissOnScroll
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isOpen = ref(true)
|
||||
overlayEl = document.createElement('div')
|
||||
triggerEl = document.createElement('button')
|
||||
outsideEl = document.createElement('div')
|
||||
dismissCount = 0
|
||||
document.body.append(overlayEl, triggerEl, outsideEl)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('dismisses on outside pointerdown', () => {
|
||||
mountComposable()
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the overlay', () => {
|
||||
mountComposable()
|
||||
|
||||
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the trigger', () => {
|
||||
mountComposable({
|
||||
getTriggerEl: () => triggerEl
|
||||
})
|
||||
|
||||
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('dismisses on scroll when enabled', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores scroll inside the overlay', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
overlayEl.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('does not dismiss when closed', () => {
|
||||
isOpen.value = false
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
})
|
||||
60
src/composables/useDismissableOverlay.ts
Normal file
60
src/composables/useDismissableOverlay.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface UseDismissableOverlayOptions {
|
||||
isOpen: MaybeRefOrGetter<boolean>
|
||||
getOverlayEl: () => HTMLElement | null
|
||||
onDismiss: () => void
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
dismissOnScroll?: boolean
|
||||
}
|
||||
|
||||
const isNode = (value: EventTarget | null | undefined): value is Node =>
|
||||
value instanceof Node
|
||||
|
||||
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
|
||||
!!element?.contains(target)
|
||||
|
||||
export function useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl,
|
||||
onDismiss,
|
||||
getTriggerEl,
|
||||
dismissOnScroll = false
|
||||
}: UseDismissableOverlayOptions) {
|
||||
const dismissIfOutside = (event: Event) => {
|
||||
if (!toValue(isOpen)) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlay = getOverlayEl()
|
||||
if (!overlay) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isNode(event.target)) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isInside(event.target, overlay) ||
|
||||
isInside(event.target, getTriggerEl?.())
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
|
||||
|
||||
if (dismissOnScroll) {
|
||||
useEventListener(window, 'scroll', dismissIfOutside, {
|
||||
capture: true,
|
||||
passive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import { computed } from 'vue'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
@@ -15,6 +16,10 @@ 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.
|
||||
@@ -41,8 +46,10 @@ interface TransformCompatOverlayOptions {
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
|
||||
return computed(() => ({
|
||||
appendTo: 'self' as const,
|
||||
appendTo: injectedAppendTo ?? ('self' as const),
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
: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 -->
|
||||
@@ -66,35 +69,16 @@
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</Button>
|
||||
<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
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
>
|
||||
<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="{ itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries()"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</IconGroup>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,12 +139,9 @@ 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,
|
||||
@@ -173,13 +154,11 @@ 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>
|
||||
|
||||
@@ -198,24 +177,12 @@ function getTopComponent(kind: PreviewKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.other
|
||||
}
|
||||
|
||||
const {
|
||||
asset,
|
||||
loading,
|
||||
selected,
|
||||
showOutputCount,
|
||||
outputCount,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
asset?: AssetItem
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
showOutputCount?: boolean
|
||||
outputCount?: number
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
@@ -229,19 +196,13 @@ const emit = defineEmits<{
|
||||
click: []
|
||||
zoom: [asset: AssetItem]
|
||||
'output-count-click': []
|
||||
'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[]]
|
||||
'context-menu': [event: MouseEvent, asset: 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>()
|
||||
@@ -249,15 +210,6 @@ 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(() => {
|
||||
@@ -338,12 +290,7 @@ const metaInfo = computed(() => {
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
if (loading || !asset || isDeleting.value) return false
|
||||
return (
|
||||
isHovered.value ||
|
||||
selected ||
|
||||
isVideoPlaying.value ||
|
||||
isActionsMenuOpen.value
|
||||
)
|
||||
return isHovered.value || selected || isVideoPlaying.value
|
||||
})
|
||||
|
||||
const handleZoomClick = () => {
|
||||
@@ -359,26 +306,6 @@ 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) {
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
function dragStart(e: DragEvent) {
|
||||
if (!asset?.preview_url) return
|
||||
|
||||
|
||||
143
src/platform/assets/components/MediaAssetContextMenu.test.ts
Normal file
143
src/platform/assets/components/MediaAssetContextMenu.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
286
src/platform/assets/components/MediaAssetContextMenu.vue
Normal file
286
src/platform/assets/components/MediaAssetContextMenu.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<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>
|
||||
@@ -1,8 +0,0 @@
|
||||
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
|
||||
@@ -1,260 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
174
src/platform/assets/mappings/modelNodeMappings.ts
Normal file
174
src/platform/assets/mappings/modelNodeMappings.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Default mappings from model directories to loader nodes.
|
||||
*
|
||||
* Each entry maps a model folder (as it appears in the model browser)
|
||||
* to the node class that loads models from that folder and the
|
||||
* input key where the model name is inserted.
|
||||
*
|
||||
* An empty key ('') means the node auto-loads models without a widget
|
||||
* selector (createModelNodeFromAsset skips widget assignment).
|
||||
*
|
||||
* Hierarchical fallback is handled by the store: "a/b/c" tries
|
||||
* "a/b/c" → "a/b" → "a", so registering a parent directory covers
|
||||
* all its children unless a more specific entry exists.
|
||||
*
|
||||
* Format: [modelDirectory, nodeClass, inputKey]
|
||||
*/
|
||||
export const MODEL_NODE_MAPPINGS: ReadonlyArray<
|
||||
readonly [string, string, string]
|
||||
> = [
|
||||
// ---- ComfyUI core loaders ----
|
||||
['checkpoints', 'CheckpointLoaderSimple', 'ckpt_name'],
|
||||
['checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name'],
|
||||
['loras', 'LoraLoader', 'lora_name'],
|
||||
['loras', 'LoraLoaderModelOnly', 'lora_name'],
|
||||
['vae', 'VAELoader', 'vae_name'],
|
||||
['controlnet', 'ControlNetLoader', 'control_net_name'],
|
||||
['diffusion_models', 'UNETLoader', 'unet_name'],
|
||||
['upscale_models', 'UpscaleModelLoader', 'model_name'],
|
||||
['style_models', 'StyleModelLoader', 'style_model_name'],
|
||||
['gligen', 'GLIGENLoader', 'gligen_name'],
|
||||
['clip_vision', 'CLIPVisionLoader', 'clip_name'],
|
||||
['text_encoders', 'CLIPLoader', 'clip_name'],
|
||||
['audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name'],
|
||||
['model_patches', 'ModelPatchLoader', 'name'],
|
||||
['latent_upscale_models', 'LatentUpscaleModelLoader', 'model_name'],
|
||||
['clip', 'CLIPVisionLoader', 'clip_name'],
|
||||
|
||||
// ---- AnimateDiff (comfyui-animatediff-evolved) ----
|
||||
['animatediff_models', 'ADE_LoadAnimateDiffModel', 'model_name'],
|
||||
['animatediff_motion_lora', 'ADE_AnimateDiffLoRALoader', 'name'],
|
||||
|
||||
// ---- Chatterbox TTS (ComfyUI-Fill-Nodes) ----
|
||||
['chatterbox/chatterbox', 'FL_ChatterboxTTS', ''],
|
||||
['chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', ''],
|
||||
['chatterbox/chatterbox_multilingual', 'FL_ChatterboxMultilingualTTS', ''],
|
||||
['chatterbox/chatterbox_vc', 'FL_ChatterboxVC', ''],
|
||||
|
||||
// ---- SAM / SAM2 (comfyui-segment-anything-2, comfyui-impact-pack) ----
|
||||
['sam2', 'DownloadAndLoadSAM2Model', 'model'],
|
||||
['sams', 'SAMLoader', 'model_name'],
|
||||
|
||||
// ---- SAM3 3D segmentation (comfyui-sam3) ----
|
||||
['sam3', 'LoadSAM3Model', 'model_path'],
|
||||
|
||||
// ---- Ultralytics detection (comfyui-impact-subpack) ----
|
||||
['ultralytics', 'UltralyticsDetectorProvider', 'model_name'],
|
||||
|
||||
// ---- DepthAnything (comfyui-depthanythingv2, comfyui-depthanythingv3) ----
|
||||
['depthanything', 'DownloadAndLoadDepthAnythingV2Model', 'model'],
|
||||
['depthanything3', 'DownloadAndLoadDepthAnythingV3Model', 'model'],
|
||||
|
||||
// ---- IP-Adapter (comfyui_ipadapter_plus) ----
|
||||
['ipadapter', 'IPAdapterModelLoader', 'ipadapter_file'],
|
||||
|
||||
// ---- Segformer (comfyui_layerstyle) ----
|
||||
['segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name'],
|
||||
['segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name'],
|
||||
['segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name'],
|
||||
|
||||
// ---- NLF pose estimation (ComfyUI-WanVideoWrapper) ----
|
||||
['nlf', 'LoadNLFModel', 'nlf_model'],
|
||||
|
||||
// ---- FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast) ----
|
||||
['FlashVSR', 'FlashVSRNode', ''],
|
||||
['FlashVSR-v1.1', 'FlashVSRNode', ''],
|
||||
|
||||
// ---- SEEDVR2 video upscaling (comfyui-seedvr2) ----
|
||||
['SEEDVR2', 'SeedVR2LoadDiTModel', 'model'],
|
||||
|
||||
// ---- Qwen VL vision-language (comfyui-qwen-vl) ----
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-2B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-4B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-8B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Instruct', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-VL-32B-Thinking', 'AILab_QwenVL', 'model_name'],
|
||||
['LLM/Qwen-VL/Qwen3-0.6B', 'AILab_QwenVL_PromptEnhancer', 'model_name'],
|
||||
[
|
||||
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
],
|
||||
['LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint'],
|
||||
|
||||
// ---- Qwen3 TTS (ComfyUI-FunBox) ----
|
||||
['qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice'],
|
||||
|
||||
// ---- LivePortrait (comfyui-liveportrait) ----
|
||||
['liveportrait', 'DownloadAndLoadLivePortraitModels', ''],
|
||||
|
||||
// ---- MimicMotion (ComfyUI-MimicMotionWrapper) ----
|
||||
['mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model'],
|
||||
['dwpose', 'MimicMotionGetPoses', ''],
|
||||
|
||||
// ---- Face parsing (comfyui_face_parsing) ----
|
||||
['face_parsing', 'FaceParsingModelLoader(FaceParsing)', ''],
|
||||
|
||||
// ---- Kolors (ComfyUI-KolorsWrapper) ----
|
||||
['diffusers', 'DownloadAndLoadKolorsModel', 'model'],
|
||||
|
||||
// ---- RIFE video frame interpolation (ComfyUI-RIFE) ----
|
||||
['rife', 'RIFE VFI', 'ckpt_name'],
|
||||
|
||||
// ---- UltraShape 3D model generation ----
|
||||
['UltraShape', 'UltraShapeLoadModel', 'checkpoint'],
|
||||
|
||||
// ---- SHaRP depth estimation ----
|
||||
['sharp', 'LoadSharpModel', 'checkpoint_path'],
|
||||
|
||||
// ---- ONNX upscale models ----
|
||||
['onnx', 'UpscaleModelLoader', 'model_name'],
|
||||
|
||||
// ---- Detection models (vitpose, yolo) ----
|
||||
['detection', 'OnnxDetectionModelLoader', 'yolo_model'],
|
||||
|
||||
// ---- HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper) ----
|
||||
[
|
||||
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
],
|
||||
[
|
||||
'LLM/llava-llama-3-8b-v1_1-transformers',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
],
|
||||
|
||||
// ---- CogVideoX (comfyui-cogvideoxwrapper) ----
|
||||
['CogVideo', 'DownloadAndLoadCogVideoModel', ''],
|
||||
['CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model'],
|
||||
['CogVideo/ControlNet', 'DownloadAndLoadCogVideoControlNet', ''],
|
||||
|
||||
// ---- DynamiCrafter (ComfyUI-DynamiCrafterWrapper) ----
|
||||
['checkpoints/dynamicrafter', 'DownloadAndLoadDynamiCrafterModel', 'model'],
|
||||
[
|
||||
'checkpoints/dynamicrafter/controlnet',
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
],
|
||||
|
||||
// ---- LayerStyle (ComfyUI_LayerStyle_Advance) ----
|
||||
['BEN', 'LS_LoadBenModel', 'model'],
|
||||
['BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model'],
|
||||
['onnx/human-parts', 'LS_HumanPartsUltra', ''],
|
||||
['lama', 'LaMa', 'lama_model'],
|
||||
|
||||
// ---- Inpaint (comfyui-inpaint-nodes) ----
|
||||
['inpaint', 'INPAINT_LoadInpaintModel', 'model_name'],
|
||||
|
||||
// ---- LayerDiffuse (comfyui-layerdiffuse) ----
|
||||
['layer_model', 'LayeredDiffusionApply', 'config'],
|
||||
|
||||
// ---- LTX Video prompt enhancer (ComfyUI-LTXTricks) ----
|
||||
['LLM/Llama-3.2-3B-Instruct', 'LTXVPromptEnhancerLoader', 'llm_name'],
|
||||
[
|
||||
'LLM/Florence-2-large-PromptGen-v2.0',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'image_captioner_name'
|
||||
]
|
||||
] as const satisfies ReadonlyArray<readonly [string, string, string]>
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
@@ -50,6 +51,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
const {
|
||||
placeholder,
|
||||
@@ -209,6 +211,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
|
||||
ref="popoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,
|
||||
@@ -15,6 +16,7 @@ import FormSearchInput from '../FormSearchInput.vue'
|
||||
import type { LayoutMode, SortOption } from './types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const overlayProps = useTransformCompatOverlayProps()
|
||||
|
||||
defineProps<{
|
||||
sortOptions: SortOption[]
|
||||
@@ -132,6 +134,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="sortPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -194,6 +197,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="ownershipPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
@@ -256,6 +260,7 @@ function toggleBaseModelSelection(item: FilterOption) {
|
||||
ref="baseModelPopoverRef"
|
||||
:dismissable="true"
|
||||
:close-on-escape="true"
|
||||
:append-to="overlayProps.appendTo"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { MODEL_NODE_MAPPINGS } from '@/platform/assets/mappings/modelNodeMappings'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
@@ -156,254 +157,9 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
|
||||
}
|
||||
haveDefaultsLoaded.value = true
|
||||
|
||||
quickRegister('checkpoints', 'CheckpointLoaderSimple', 'ckpt_name')
|
||||
quickRegister('checkpoints', 'ImageOnlyCheckpointLoader', 'ckpt_name')
|
||||
quickRegister('loras', 'LoraLoader', 'lora_name')
|
||||
quickRegister('loras', 'LoraLoaderModelOnly', 'lora_name')
|
||||
quickRegister('vae', 'VAELoader', 'vae_name')
|
||||
quickRegister('controlnet', 'ControlNetLoader', 'control_net_name')
|
||||
quickRegister('diffusion_models', 'UNETLoader', 'unet_name')
|
||||
quickRegister('upscale_models', 'UpscaleModelLoader', 'model_name')
|
||||
quickRegister('style_models', 'StyleModelLoader', 'style_model_name')
|
||||
quickRegister('gligen', 'GLIGENLoader', 'gligen_name')
|
||||
quickRegister('clip_vision', 'CLIPVisionLoader', 'clip_name')
|
||||
quickRegister('text_encoders', 'CLIPLoader', 'clip_name')
|
||||
quickRegister('audio_encoders', 'AudioEncoderLoader', 'audio_encoder_name')
|
||||
quickRegister('model_patches', 'ModelPatchLoader', 'name')
|
||||
quickRegister(
|
||||
'animatediff_models',
|
||||
'ADE_LoadAnimateDiffModel',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'animatediff_motion_lora',
|
||||
'ADE_AnimateDiffLoRALoader',
|
||||
'name'
|
||||
)
|
||||
|
||||
// Chatterbox TTS nodes: empty key means the node auto-loads models without
|
||||
// a widget selector (createModelNodeFromAsset skips widget assignment)
|
||||
quickRegister('chatterbox/chatterbox', 'FL_ChatterboxTTS', '')
|
||||
quickRegister('chatterbox/chatterbox_turbo', 'FL_ChatterboxTurboTTS', '')
|
||||
quickRegister(
|
||||
'chatterbox/chatterbox_multilingual',
|
||||
'FL_ChatterboxMultilingualTTS',
|
||||
''
|
||||
)
|
||||
quickRegister('chatterbox/chatterbox_vc', 'FL_ChatterboxVC', '')
|
||||
|
||||
// Latent upscale models (ComfyUI core - nodes_hunyuan.py)
|
||||
quickRegister(
|
||||
'latent_upscale_models',
|
||||
'LatentUpscaleModelLoader',
|
||||
'model_name'
|
||||
)
|
||||
|
||||
// SAM/SAM2 segmentation models (comfyui-segment-anything-2, comfyui-impact-pack)
|
||||
quickRegister('sam2', 'DownloadAndLoadSAM2Model', 'model')
|
||||
quickRegister('sams', 'SAMLoader', 'model_name')
|
||||
|
||||
// Ultralytics detection models (comfyui-impact-subpack)
|
||||
// Note: ultralytics/bbox and ultralytics/segm fall back to this via hierarchical lookup
|
||||
quickRegister('ultralytics', 'UltralyticsDetectorProvider', 'model_name')
|
||||
|
||||
// DepthAnything models (comfyui-depthanythingv2)
|
||||
quickRegister(
|
||||
'depthanything',
|
||||
'DownloadAndLoadDepthAnythingV2Model',
|
||||
'model'
|
||||
)
|
||||
|
||||
// IP-Adapter models (comfyui_ipadapter_plus)
|
||||
quickRegister('ipadapter', 'IPAdapterModelLoader', 'ipadapter_file')
|
||||
|
||||
// Segformer clothing/fashion segmentation models (comfyui_layerstyle)
|
||||
quickRegister('segformer_b2_clothes', 'LS_LoadSegformerModel', 'model_name')
|
||||
quickRegister('segformer_b3_clothes', 'LS_LoadSegformerModel', 'model_name')
|
||||
quickRegister('segformer_b3_fashion', 'LS_LoadSegformerModel', 'model_name')
|
||||
|
||||
// NLF pose estimation models (ComfyUI-WanVideoWrapper)
|
||||
quickRegister('nlf', 'LoadNLFModel', 'nlf_model')
|
||||
|
||||
// FlashVSR video super-resolution (ComfyUI-FlashVSR_Ultra_Fast)
|
||||
// Empty key means the node auto-loads models without a widget selector
|
||||
quickRegister('FlashVSR', 'FlashVSRNode', '')
|
||||
quickRegister('FlashVSR-v1.1', 'FlashVSRNode', '')
|
||||
|
||||
// SEEDVR2 video upscaling (comfyui-seedvr2)
|
||||
quickRegister('SEEDVR2', 'SeedVR2LoadDiTModel', 'model')
|
||||
|
||||
// Qwen VL vision-language models (comfyui-qwen-vl)
|
||||
// Register each specific path to avoid LLM fallback catching unrelated models
|
||||
// (e.g., LLM/llava-* should NOT map to AILab_QwenVL)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen2.5-VL-3B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen2.5-VL-7B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-2B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-2B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-4B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-4B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-8B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-8B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-32B-Instruct',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-VL-32B-Thinking',
|
||||
'AILab_QwenVL',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-0.6B',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Qwen-VL/Qwen3-4B-Instruct-2507',
|
||||
'AILab_QwenVL_PromptEnhancer',
|
||||
'model_name'
|
||||
)
|
||||
quickRegister('LLM/checkpoints', 'LoadChatGLM3', 'chatglm3_checkpoint')
|
||||
|
||||
// Qwen3 TTS speech models (ComfyUI-FunBox)
|
||||
// Top-level 'qwen-tts' catches all qwen-tts/* subdirs via hierarchical fallback
|
||||
quickRegister('qwen-tts', 'FB_Qwen3TTSVoiceClone', 'model_choice')
|
||||
|
||||
// DepthAnything V3 models (comfyui-depthanythingv2)
|
||||
quickRegister(
|
||||
'depthanything3',
|
||||
'DownloadAndLoadDepthAnythingV3Model',
|
||||
'model'
|
||||
)
|
||||
|
||||
// LivePortrait face animation models (comfyui-liveportrait)
|
||||
quickRegister('liveportrait', 'DownloadAndLoadLivePortraitModels', '')
|
||||
|
||||
// MimicMotion video generation models (ComfyUI-MimicMotionWrapper)
|
||||
quickRegister('mimicmotion', 'DownloadAndLoadMimicMotionModel', 'model')
|
||||
quickRegister('dwpose', 'MimicMotionGetPoses', '')
|
||||
|
||||
// Face parsing segmentation models (comfyui_face_parsing)
|
||||
quickRegister('face_parsing', 'FaceParsingModelLoader(FaceParsing)', '')
|
||||
|
||||
// Kolors image generation models (ComfyUI-KolorsWrapper)
|
||||
// Top-level 'diffusers' catches diffusers/Kolors/* subdirs
|
||||
quickRegister('diffusers', 'DownloadAndLoadKolorsModel', 'model')
|
||||
|
||||
// CLIP models for HunyuanVideo (clip/clip-vit-large-patch14 subdir)
|
||||
quickRegister('clip', 'CLIPVisionLoader', 'clip_name')
|
||||
|
||||
// RIFE video frame interpolation (ComfyUI-RIFE)
|
||||
quickRegister('rife', 'RIFE VFI', 'ckpt_name')
|
||||
|
||||
// SAM3 3D segmentation models (comfyui-sam3)
|
||||
quickRegister('sam3', 'LoadSAM3Model', 'model_path')
|
||||
|
||||
// UltraShape 3D model generation
|
||||
quickRegister('UltraShape', 'UltraShapeLoadModel', 'checkpoint')
|
||||
|
||||
// SHaRP depth estimation
|
||||
quickRegister('sharp', 'LoadSharpModel', 'checkpoint_path')
|
||||
|
||||
// ONNX upscale models (used by OnnxDetectionModelLoader and upscale nodes)
|
||||
quickRegister('onnx', 'UpscaleModelLoader', 'model_name')
|
||||
|
||||
// Detection models (vitpose, yolo)
|
||||
quickRegister('detection', 'OnnxDetectionModelLoader', 'yolo_model')
|
||||
|
||||
// HunyuanVideo text encoders (ComfyUI-HunyuanVideoWrapper)
|
||||
quickRegister(
|
||||
'LLM/llava-llama-3-8b-text-encoder-tokenizer',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/llava-llama-3-8b-v1_1-transformers',
|
||||
'DownloadAndLoadHyVideoTextEncoder',
|
||||
'llm_model'
|
||||
)
|
||||
|
||||
// CogVideoX models (comfyui-cogvideoxwrapper)
|
||||
quickRegister('CogVideo/GGUF', 'DownloadAndLoadCogVideoGGUFModel', 'model')
|
||||
// Empty key: HF-download node — don't activate asset browser for the combo widget
|
||||
quickRegister(
|
||||
'CogVideo/ControlNet',
|
||||
'DownloadAndLoadCogVideoControlNet',
|
||||
''
|
||||
)
|
||||
|
||||
// DynamiCrafter models (ComfyUI-DynamiCrafterWrapper)
|
||||
quickRegister(
|
||||
'checkpoints/dynamicrafter',
|
||||
'DownloadAndLoadDynamiCrafterModel',
|
||||
'model'
|
||||
)
|
||||
quickRegister(
|
||||
'checkpoints/dynamicrafter/controlnet',
|
||||
'DownloadAndLoadDynamiCrafterCNModel',
|
||||
'model'
|
||||
)
|
||||
|
||||
// LayerStyle models (ComfyUI_LayerStyle_Advance)
|
||||
quickRegister('BEN', 'LS_LoadBenModel', 'model')
|
||||
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
|
||||
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
|
||||
quickRegister('lama', 'LaMa', 'lama_model')
|
||||
|
||||
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
|
||||
// Empty key: HF-download node — don't activate asset browser for the combo widget
|
||||
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', '')
|
||||
|
||||
// Inpaint models (comfyui-inpaint-nodes)
|
||||
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
|
||||
|
||||
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
|
||||
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
|
||||
|
||||
// LTX Video prompt enhancer models (ComfyUI-LTXTricks)
|
||||
quickRegister(
|
||||
'LLM/Llama-3.2-3B-Instruct',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'llm_name'
|
||||
)
|
||||
quickRegister(
|
||||
'LLM/Florence-2-large-PromptGen-v2.0',
|
||||
'LTXVPromptEnhancerLoader',
|
||||
'image_captioner_name'
|
||||
)
|
||||
for (const [modelType, nodeClass, key] of MODEL_NODE_MAPPINGS) {
|
||||
quickRegister(modelType, nodeClass, key)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user