mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-21 21:07:33 +00:00
Compare commits
11 Commits
refactor/m
...
codex/trig
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e54b7b01 | ||
|
|
d07c8750e6 | ||
|
|
02322a13d7 | ||
|
|
bb4abb599e | ||
|
|
39fcc230e3 | ||
|
|
e8e18f0213 | ||
|
|
8bc72145b3 | ||
|
|
69b3930dd6 | ||
|
|
ac06a08d92 | ||
|
|
ef546e3da8 | ||
|
|
3af52bd4e8 |
@@ -3,6 +3,9 @@ import type { Page, Route } from '@playwright/test'
|
||||
export class QueueHelper {
|
||||
private queueRouteHandler: ((route: Route) => void) | null = null
|
||||
private historyRouteHandler: ((route: Route) => void) | null = null
|
||||
private jobsRouteHandler: ((route: Route) => void) | null = null
|
||||
private queueJobs: Array<Record<string, unknown>> = []
|
||||
private historyJobs: Array<Record<string, unknown>> = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@@ -13,6 +16,26 @@ export class QueueHelper {
|
||||
running: number = 0,
|
||||
pending: number = 0
|
||||
): Promise<void> {
|
||||
const baseTime = Date.now()
|
||||
this.queueJobs = [
|
||||
...Array.from({ length: running }, (_, i) => ({
|
||||
id: `running-${i}`,
|
||||
status: 'in_progress',
|
||||
create_time: baseTime - i * 1000,
|
||||
execution_start_time: baseTime - 5000 - i * 1000,
|
||||
execution_end_time: null,
|
||||
priority: i + 1
|
||||
})),
|
||||
...Array.from({ length: pending }, (_, i) => ({
|
||||
id: `pending-${i}`,
|
||||
status: 'pending',
|
||||
create_time: baseTime - (running + i) * 1000,
|
||||
execution_start_time: null,
|
||||
execution_end_time: null,
|
||||
priority: running + i + 1
|
||||
}))
|
||||
]
|
||||
|
||||
this.queueRouteHandler = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -35,6 +58,7 @@ export class QueueHelper {
|
||||
})
|
||||
})
|
||||
await this.page.route('**/api/queue', this.queueRouteHandler)
|
||||
await this.installJobsRoute()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +67,30 @@ export class QueueHelper {
|
||||
async mockHistory(
|
||||
jobs: Array<{ promptId: string; status: 'success' | 'error' }>
|
||||
): Promise<void> {
|
||||
const baseTime = Date.now()
|
||||
this.historyJobs = jobs.map((job, index) => {
|
||||
const completed = job.status === 'success'
|
||||
|
||||
return {
|
||||
id: job.promptId,
|
||||
status: completed ? 'completed' : 'failed',
|
||||
create_time: baseTime - index * 1000,
|
||||
execution_start_time: baseTime - 5000 - index * 1000,
|
||||
execution_end_time: baseTime - index * 1000,
|
||||
outputs_count: completed ? 1 : 0,
|
||||
workflow_id: `workflow-${job.promptId}`,
|
||||
preview_output: completed
|
||||
? {
|
||||
filename: `${job.promptId}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
}
|
||||
: null
|
||||
}
|
||||
})
|
||||
|
||||
const history: Record<string, unknown> = {}
|
||||
for (const job of jobs) {
|
||||
history[job.promptId] = {
|
||||
@@ -61,6 +109,44 @@ export class QueueHelper {
|
||||
body: JSON.stringify(history)
|
||||
})
|
||||
await this.page.route('**/api/history**', this.historyRouteHandler)
|
||||
await this.installJobsRoute()
|
||||
}
|
||||
|
||||
private async installJobsRoute() {
|
||||
if (this.jobsRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.jobsRouteHandler = (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses =
|
||||
url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.filter((status) => status.length > 0) ?? []
|
||||
const offset = Number(url.searchParams.get('offset') ?? 0)
|
||||
const limit = Number(url.searchParams.get('limit') ?? 200)
|
||||
const jobs = [...this.queueJobs, ...this.historyJobs].filter(
|
||||
(job) => statuses.length === 0 || statuses.includes(String(job.status))
|
||||
)
|
||||
const paginatedJobs = jobs.slice(offset, offset + limit)
|
||||
|
||||
void route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: paginatedJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: jobs.length,
|
||||
has_more: offset + paginatedJobs.length < jobs.length
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route('**/api/jobs**', this.jobsRouteHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,5 +161,9 @@ export class QueueHelper {
|
||||
await this.page.unroute('**/api/history**', this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
if (this.jobsRouteHandler) {
|
||||
await this.page.unroute('**/api/jobs**', this.jobsRouteHandler)
|
||||
this.jobsRouteHandler = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
browser_tests/tests/sidebar/assetsSidebar.spec.ts
Normal file
38
browser_tests/tests/sidebar/assetsSidebar.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Assets Sidebar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.queue.mockHistory([
|
||||
{ promptId: 'history-asset-1', status: 'success' }
|
||||
])
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
await comfyPage.page.getByRole('button', { name: /^Assets/ }).click()
|
||||
await expect(
|
||||
comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('actions menu closes on scroll', async ({ comfyPage }) => {
|
||||
const assetCard = comfyPage.page.getByRole('button', {
|
||||
name: /history-asset-1\.png/i
|
||||
})
|
||||
|
||||
await assetCard.hover()
|
||||
await assetCard.getByRole('button', { name: /more options/i }).click()
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.media-asset-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
|
||||
await expect(menuPanel).toBeHidden()
|
||||
})
|
||||
})
|
||||
53
browser_tests/tests/sidebar/jobHistorySidebar.spec.ts
Normal file
53
browser_tests/tests/sidebar/jobHistorySidebar.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Job History Sidebar', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.queue.mockQueueState()
|
||||
await comfyPage.queue.mockHistory([
|
||||
{ promptId: 'history-job-1', status: 'success' }
|
||||
])
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
|
||||
await comfyPage.setup({ clearStorage: false })
|
||||
|
||||
await comfyPage.page.getByTestId('queue-overlay-toggle').click()
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('hover popover and actions menu stay clickable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const jobRow = comfyPage.page.locator('[data-job-id="history-job-1"]')
|
||||
await jobRow.hover()
|
||||
|
||||
const popover = comfyPage.page.locator('.job-details-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
await popover.getByRole('button', { name: /^copy$/i }).click()
|
||||
|
||||
await jobRow.hover()
|
||||
const moreButton = jobRow.locator('.job-actions-menu-trigger')
|
||||
await expect(moreButton).toBeVisible()
|
||||
await moreButton.click()
|
||||
|
||||
const menuPanel = comfyPage.page.locator('.job-menu-panel')
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
const box = await menuPanel.boundingBox()
|
||||
if (!box) {
|
||||
throw new Error('Job actions menu did not render a bounding box')
|
||||
}
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width / 2,
|
||||
box.y + Math.min(box.height / 2, 24)
|
||||
)
|
||||
await expect(menuPanel).toBeVisible()
|
||||
|
||||
await menuPanel.getByRole('menuitem', { name: /copy job id/i }).click()
|
||||
await expect(menuPanel).toBeHidden()
|
||||
})
|
||||
})
|
||||
74
src/components/common/ContextMenu.test.ts
Normal file
74
src/components/common/ContextMenu.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||
|
||||
const mountComponent = ({ closeOnScroll = false } = {}) =>
|
||||
mount(ContextMenu, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
closeOnScroll,
|
||||
contentClass: 'context-menu-content'
|
||||
},
|
||||
slots: {
|
||||
default: '<button class="context-trigger" type="button">Trigger</button>',
|
||||
content:
|
||||
'<div class="context-menu-content-inner" role="menuitem">Action</div>'
|
||||
}
|
||||
})
|
||||
|
||||
async function openMenu() {
|
||||
const trigger = document.body.querySelector('.context-trigger')
|
||||
|
||||
if (!(trigger instanceof HTMLElement)) {
|
||||
throw new Error('Context trigger element not found')
|
||||
}
|
||||
|
||||
trigger.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 2
|
||||
})
|
||||
)
|
||||
await waitForMenuUpdate()
|
||||
}
|
||||
|
||||
const isMenuVisible = () =>
|
||||
document.body.querySelector('.context-menu-content-inner') !== null
|
||||
|
||||
const waitForMenuUpdate = async () => {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('ContextMenu', () => {
|
||||
it('opens from the slotted context-menu trigger', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await openMenu()
|
||||
|
||||
expect(isMenuVisible()).toBe(true)
|
||||
expect(
|
||||
document.body.querySelectorAll('[role="menuitem"]').length
|
||||
).toBeGreaterThan(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('closes on scroll when enabled', async () => {
|
||||
const wrapper = mountComponent({ closeOnScroll: true })
|
||||
await openMenu()
|
||||
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
await waitForMenuUpdate()
|
||||
|
||||
expect(isMenuVisible()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
88
src/components/common/ContextMenu.vue
Normal file
88
src/components/common/ContextMenu.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
injectContextMenuRootContext,
|
||||
ContextMenuPortal,
|
||||
ContextMenuRoot,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
contentClass,
|
||||
collisionPadding = 8,
|
||||
closeOnScroll = false
|
||||
} = defineProps<{
|
||||
contentClass?: string
|
||||
collisionPadding?: number
|
||||
closeOnScroll?: boolean
|
||||
}>()
|
||||
|
||||
const ContextMenuContentProvider = defineComponent({
|
||||
name: 'ContextMenuContentProvider',
|
||||
props: {
|
||||
closeOnScroll: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
setup(providerProps, { slots }) {
|
||||
const rootContext = injectContextMenuRootContext()
|
||||
|
||||
function closeMenu() {
|
||||
rootContext.onOpenChange(false)
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
() => {
|
||||
if (providerProps.closeOnScroll) {
|
||||
closeMenu()
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
|
||||
return () =>
|
||||
slots.default?.({
|
||||
close: closeMenu,
|
||||
itemComponent: ContextMenuItem,
|
||||
separatorComponent: ContextMenuSeparator
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<slot />
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuPortal>
|
||||
<ContextMenuContent
|
||||
:collision-padding="collisionPadding"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
>
|
||||
<ContextMenuContentProvider :close-on-scroll="closeOnScroll">
|
||||
<template #default="{ close, itemComponent, separatorComponent }">
|
||||
<slot
|
||||
name="content"
|
||||
:close="close"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
/>
|
||||
</template>
|
||||
</ContextMenuContentProvider>
|
||||
</ContextMenuContent>
|
||||
</ContextMenuPortal>
|
||||
</ContextMenuRoot>
|
||||
</template>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
@@ -18,7 +21,23 @@ defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const {
|
||||
entries,
|
||||
icon,
|
||||
to,
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
buttonSize,
|
||||
buttonClass,
|
||||
align,
|
||||
showArrow = true,
|
||||
side = 'bottom',
|
||||
sideOffset = 5,
|
||||
collisionPadding = 10,
|
||||
closeOnScroll = false
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -26,6 +45,12 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
align?: 'start' | 'center' | 'end'
|
||||
showArrow?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
sideOffset?: number
|
||||
collisionPadding?: number
|
||||
closeOnScroll?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -41,10 +66,25 @@ const contentClass = computed(() =>
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
|
||||
function closeMenu() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
() => {
|
||||
if (closeOnScroll) {
|
||||
closeMenu()
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -55,22 +95,39 @@ const contentClass = computed(() =>
|
||||
|
||||
<DropdownMenuPortal :to>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
:align
|
||||
:side
|
||||
:side-offset="sideOffset"
|
||||
:collision-padding="collisionPadding"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
v-for="(item, index) in entries ?? []"
|
||||
:key="toValue(item.label) ?? index"
|
||||
:item-class
|
||||
:content-class
|
||||
:item
|
||||
/>
|
||||
<slot
|
||||
name="content"
|
||||
:close="closeMenu"
|
||||
:item-class="itemClass"
|
||||
:item-component="DropdownMenuItem"
|
||||
:separator-component="DropdownMenuSeparator"
|
||||
>
|
||||
<slot
|
||||
:close="closeMenu"
|
||||
:item-class="itemClass"
|
||||
:item-component="DropdownMenuItem"
|
||||
:separator-component="DropdownMenuSeparator"
|
||||
>
|
||||
<DropdownItem
|
||||
v-for="(item, index) in entries ?? []"
|
||||
:key="toValue(item.label) ?? index"
|
||||
:item-class
|
||||
:content-class
|
||||
:item
|
||||
/>
|
||||
</slot>
|
||||
</slot>
|
||||
<DropdownMenuArrow class="fill-base-background stroke-border-subtle" />
|
||||
<DropdownMenuArrow
|
||||
v-if="showArrow"
|
||||
class="fill-base-background stroke-border-subtle"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
|
||||
70
src/components/common/MenuPanel.vue
Normal file
70
src/components/common/MenuPanel.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div :class="panelClass">
|
||||
<template v-for="entry in entries" :key="entry.key">
|
||||
<component
|
||||
:is="separatorComponent"
|
||||
v-if="entry.kind === 'divider'"
|
||||
:class="separatorWrapperClass"
|
||||
>
|
||||
<div :class="separatorClass" />
|
||||
</component>
|
||||
<component
|
||||
:is="itemComponent"
|
||||
v-else
|
||||
as-child
|
||||
:disabled="entry.disabled"
|
||||
:text-value="entry.label"
|
||||
@select="emit('action', entry)"
|
||||
>
|
||||
<Button
|
||||
:variant="buttonVariant"
|
||||
:size="buttonSize"
|
||||
:class="buttonClass"
|
||||
:disabled="entry.disabled"
|
||||
>
|
||||
<i v-if="entry.icon" :class="cn(entry.icon, iconClass)" />
|
||||
<span :class="labelClass">{{ entry.label }}</span>
|
||||
</Button>
|
||||
</component>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
entries,
|
||||
itemComponent,
|
||||
separatorComponent,
|
||||
panelClass,
|
||||
separatorWrapperClass,
|
||||
separatorClass,
|
||||
buttonClass,
|
||||
iconClass,
|
||||
labelClass,
|
||||
buttonVariant = 'secondary',
|
||||
buttonSize = 'sm'
|
||||
} = defineProps<{
|
||||
entries: MenuEntry[]
|
||||
itemComponent: Component
|
||||
separatorComponent: Component
|
||||
panelClass: string
|
||||
separatorWrapperClass: string
|
||||
separatorClass: string
|
||||
buttonClass: string
|
||||
iconClass: string
|
||||
labelClass?: string
|
||||
buttonVariant?: ButtonVariants['variant']
|
||||
buttonSize?: ButtonVariants['size']
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [entry: MenuActionEntry]
|
||||
}>()
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({ jobMenuEntries: [] })
|
||||
useJobMenu: () => ({ getJobMenuEntries: () => [] })
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useErrorHandling', () => ({
|
||||
@@ -30,10 +30,6 @@ const JobAssetsListStub = {
|
||||
template: '<div class="job-assets-list-stub" />'
|
||||
}
|
||||
|
||||
const JobContextMenuStub = {
|
||||
template: '<div />'
|
||||
}
|
||||
|
||||
const createJob = (): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
@@ -56,8 +52,7 @@ const mountComponent = () =>
|
||||
stubs: {
|
||||
QueueOverlayHeader: QueueOverlayHeaderStub,
|
||||
JobFiltersBar: JobFiltersBarStub,
|
||||
JobAssetsList: JobAssetsListStub,
|
||||
JobContextMenu: JobContextMenuStub
|
||||
JobAssetsList: JobAssetsListStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,36 +23,28 @@
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:get-menu-entries="getJobMenuEntries"
|
||||
@cancel-item="onCancelItemEvent"
|
||||
@delete-item="onDeleteItemEvent"
|
||||
@menu-action="onJobMenuAction"
|
||||
@view-item="$emit('viewItem', $event)"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type {
|
||||
JobGroup,
|
||||
JobListItem,
|
||||
JobSortMode,
|
||||
JobTab
|
||||
} from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import type { MenuActionEntry } from '@/types/menuTypes'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
|
||||
import QueueOverlayHeader from './QueueOverlayHeader.vue'
|
||||
import JobContextMenu from './job/JobContextMenu.vue'
|
||||
import JobAssetsList from './job/JobAssetsList.vue'
|
||||
import JobFiltersBar from './job/JobFiltersBar.vue'
|
||||
|
||||
@@ -78,13 +70,10 @@ const emit = defineEmits<{
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
const { jobMenuEntries } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
(item) => emit('viewItem', item)
|
||||
const { getJobMenuEntries } = useJobMenu(undefined, (item) =>
|
||||
emit('viewItem', item)
|
||||
)
|
||||
|
||||
const onCancelItemEvent = (item: JobListItem) => {
|
||||
@@ -95,14 +84,9 @@ const onDeleteItemEvent = (item: JobListItem) => {
|
||||
emit('deleteItem', item)
|
||||
}
|
||||
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(
|
||||
async (entry: MenuActionEntry) => {
|
||||
if (entry.onClick) await entry.onClick()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -3,11 +3,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem as ApiJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
|
||||
import JobAssetsList from './JobAssetsList.vue'
|
||||
|
||||
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
|
||||
default: {
|
||||
name: 'JobDetailsPopover',
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
@@ -32,43 +37,34 @@ vi.mock('vue-i18n', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const createResultItem = (
|
||||
const createPreviewOutput = (
|
||||
filename: string,
|
||||
mediaType: string = 'images'
|
||||
): ResultItemImpl => {
|
||||
const item = new ResultItemImpl({
|
||||
): NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']> =>
|
||||
({
|
||||
filename,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: 'node-1',
|
||||
mediaType
|
||||
})
|
||||
Object.defineProperty(item, 'url', {
|
||||
get: () => `/api/view/${filename}`
|
||||
})
|
||||
return item
|
||||
}
|
||||
mediaType,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video',
|
||||
isAudio: mediaType === 'audio',
|
||||
is3D: mediaType === 'model',
|
||||
url: `/api/view/${filename}`
|
||||
}) as NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
|
||||
|
||||
const createTaskRef = (preview?: ResultItemImpl): TaskItemImpl => {
|
||||
const job: ApiJobListItem = {
|
||||
id: `task-${Math.random().toString(36).slice(2)}`,
|
||||
status: 'completed',
|
||||
create_time: Date.now(),
|
||||
preview_output: null,
|
||||
outputs_count: preview ? 1 : 0,
|
||||
workflow_id: 'workflow-1',
|
||||
priority: 0
|
||||
}
|
||||
const flatOutputs = preview ? [preview] : []
|
||||
return new TaskItemImpl(job, {}, flatOutputs)
|
||||
}
|
||||
const createTaskRef = (
|
||||
preview?: NonNullable<NonNullable<JobListItem['taskRef']>['previewOutput']>
|
||||
): JobListItem['taskRef'] =>
|
||||
({
|
||||
workflowId: 'workflow-1',
|
||||
previewOutput: preview
|
||||
}) as JobListItem['taskRef']
|
||||
|
||||
const buildJob = (overrides: Partial<JobListItem> = {}): JobListItem => ({
|
||||
id: 'job-1',
|
||||
title: 'Job 1',
|
||||
meta: 'meta',
|
||||
state: 'completed',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png')),
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png')),
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -82,7 +78,10 @@ const mountJobAssetsList = (jobs: JobListItem[]) => {
|
||||
]
|
||||
|
||||
return mount(JobAssetsList, {
|
||||
props: { displayedJobGroups },
|
||||
props: {
|
||||
displayedJobGroups,
|
||||
getMenuEntries: () => []
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
teleport: true,
|
||||
@@ -147,7 +146,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on double-click for completed video jobs without icon image', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.webm', 'video'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.webm', 'video'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
@@ -164,7 +163,7 @@ describe('JobAssetsList', () => {
|
||||
it('emits viewItem on icon click for completed 3D jobs without preview tile', async () => {
|
||||
const job = buildJob({
|
||||
iconImageUrl: undefined,
|
||||
taskRef: createTaskRef(createResultItem('job-1.glb', 'model'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.glb', 'model'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
@@ -179,7 +178,7 @@ describe('JobAssetsList', () => {
|
||||
it('does not emit viewItem on double-click for non-completed jobs', async () => {
|
||||
const job = buildJob({
|
||||
state: 'running',
|
||||
taskRef: createTaskRef(createResultItem('job-1.png'))
|
||||
taskRef: createTaskRef(createPreviewOutput('job-1.png'))
|
||||
})
|
||||
const wrapper = mountJobAssetsList([job])
|
||||
|
||||
|
||||
@@ -15,64 +15,98 @@
|
||||
@mouseenter="onJobEnter(job, $event)"
|
||||
@mouseleave="onJobLeave(job.id)"
|
||||
>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@contextmenu.prevent.stop="$emit('menu', job, $event)"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
<ContextMenu
|
||||
content-class="z-1700 bg-transparent p-0 font-inter shadow-lg"
|
||||
>
|
||||
<template v-if="hoveredJobId === job.id" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
@click.stop="$emit('menu', job, $event)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
<AssetsListItem
|
||||
:class="
|
||||
cn(
|
||||
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
|
||||
job.state === 'running' && 'bg-secondary-background'
|
||||
)
|
||||
"
|
||||
:preview-url="getJobPreviewUrl(job)"
|
||||
:is-video-preview="isVideoPreviewJob(job)"
|
||||
:preview-alt="job.title"
|
||||
:icon-name="job.iconName ?? iconForJobState(job.state)"
|
||||
:icon-class="getJobIconClass(job)"
|
||||
:primary-text="job.title"
|
||||
:secondary-text="job.meta"
|
||||
:progress-total-percent="job.progressTotalPercent"
|
||||
:progress-current-percent="job.progressCurrentPercent"
|
||||
@dblclick.stop="emitViewItem(job)"
|
||||
@preview-click="emitViewItem(job)"
|
||||
@click.stop
|
||||
>
|
||||
<template v-if="shouldShowActionsMenu(job.id)" #actions>
|
||||
<Button
|
||||
v-if="isCancelable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.cancel')"
|
||||
@click.stop="emitCancelItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="isFailedDeletable(job)"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:aria-label="t('g.delete')"
|
||||
@click.stop="emitDeleteItem(job)"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="job.state === 'completed'"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
@click.stop="emitCompletedViewItem(job)"
|
||||
>
|
||||
{{ t('menuLabels.View') }}
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
:open="openActionsJobId === job.id"
|
||||
:show-arrow="false"
|
||||
content-class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
:side-offset="4"
|
||||
:collision-padding="8"
|
||||
@update:open="onActionsMenuOpenChange(job.id, $event)"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
class="job-actions-menu-trigger"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('g.more')"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template
|
||||
#content="{ close, itemComponent, separatorComponent }"
|
||||
>
|
||||
<MenuPanel
|
||||
:entries="getMenuEntries(job)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="jobMenuPanelProps"
|
||||
@action="onMenuAction($event, close)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getMenuEntries(job)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="jobMenuPanelProps"
|
||||
@action="onMenuAction($event, close)"
|
||||
/>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +114,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeDetails && popoverPosition"
|
||||
class="job-details-popover fixed z-50"
|
||||
class="job-details-popover fixed z-1700"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
@@ -100,9 +134,13 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import MenuPanel from '@/components/common/MenuPanel.vue'
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
@@ -110,17 +148,32 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
import { isActiveJobState } from '@/utils/queueUtil'
|
||||
|
||||
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
|
||||
const { displayedJobGroups, getMenuEntries } = defineProps<{
|
||||
displayedJobGroups: JobGroup[]
|
||||
getMenuEntries: (item: JobListItem) => MenuEntry[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'cancelItem', item: JobListItem): void
|
||||
(e: 'deleteItem', item: JobListItem): void
|
||||
(e: 'menu', item: JobListItem, ev: MouseEvent): void
|
||||
(e: 'menu-action', entry: MenuActionEntry): void
|
||||
(e: 'viewItem', item: JobListItem): void
|
||||
}>()
|
||||
|
||||
const jobMenuPanelProps = {
|
||||
panelClass:
|
||||
'job-menu-panel flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter',
|
||||
separatorWrapperClass: 'px-2 py-1',
|
||||
separatorClass: 'h-px bg-interface-stroke',
|
||||
buttonVariant: 'textonly',
|
||||
buttonClass:
|
||||
'w-full justify-start bg-transparent data-highlighted:bg-secondary-background-hover',
|
||||
iconClass: 'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
} as const
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const openActionsJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const {
|
||||
@@ -188,6 +241,26 @@ function isFailedDeletable(job: JobListItem) {
|
||||
return job.showClear !== false && job.state === 'failed'
|
||||
}
|
||||
|
||||
function shouldShowActionsMenu(jobId: string) {
|
||||
return hoveredJobId.value === jobId || openActionsJobId.value === jobId
|
||||
}
|
||||
|
||||
function onActionsMenuOpenChange(jobId: string, isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
openActionsJobId.value = jobId
|
||||
return
|
||||
}
|
||||
|
||||
if (openActionsJobId.value === jobId) {
|
||||
openActionsJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
emit('menu-action', entry)
|
||||
}
|
||||
|
||||
function getPreviewOutput(job: JobListItem) {
|
||||
return job.taskRef?.previewOutput
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
const popoverStub = defineComponent({
|
||||
name: 'Popover',
|
||||
emits: ['show', 'hide'],
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
container: null as HTMLElement | null,
|
||||
eventTarget: null as EventTarget | null,
|
||||
target: null as EventTarget | null
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
updated() {
|
||||
this.container = this.$refs.container as HTMLElement | null
|
||||
},
|
||||
methods: {
|
||||
toggle(event: Event, target?: EventTarget | null) {
|
||||
if (this.visible) {
|
||||
this.hide()
|
||||
return
|
||||
}
|
||||
|
||||
this.show(event, target)
|
||||
},
|
||||
show(event: Event, target?: EventTarget | null) {
|
||||
this.visible = true
|
||||
this.eventTarget = event.currentTarget
|
||||
this.target = target ?? event.currentTarget
|
||||
this.$emit('show')
|
||||
},
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.$emit('hide')
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div v-if="visible" ref="container" class="popover-stub">
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const buttonStub = {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="button-stub"
|
||||
:data-disabled="String(disabled)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const createEntries = (): MenuEntry[] => [
|
||||
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
},
|
||||
{ kind: 'divider', key: 'divider-1' }
|
||||
]
|
||||
|
||||
const mountComponent = (entries: MenuEntry[]) =>
|
||||
mount(JobContextMenu, {
|
||||
props: { entries },
|
||||
global: {
|
||||
stubs: {
|
||||
Popover: popoverStub,
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
|
||||
({
|
||||
type,
|
||||
currentTarget,
|
||||
target: currentTarget
|
||||
}) as Event
|
||||
|
||||
const openMenu = async (
|
||||
wrapper: ReturnType<typeof mountComponent>,
|
||||
type: string = 'click'
|
||||
) => {
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
await wrapper.vm.open(createTriggerEvent(type, trigger))
|
||||
await nextTick()
|
||||
return trigger
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('JobContextMenu', () => {
|
||||
it('passes disabled state to action buttons', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
await openMenu(wrapper)
|
||||
|
||||
const buttons = wrapper.findAll('.button-stub')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].attributes('data-disabled')).toBe('false')
|
||||
expect(buttons[1].attributes('data-disabled')).toBe('true')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits action for enabled entries', async () => {
|
||||
const entries = createEntries()
|
||||
const wrapper = mountComponent(entries)
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.findAll('.button-stub')[0].trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not emit action for disabled entries', async () => {
|
||||
const wrapper = mountComponent([
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
disabled: true,
|
||||
onClick: vi.fn()
|
||||
}
|
||||
])
|
||||
await openMenu(wrapper)
|
||||
|
||||
await wrapper.get('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides on pointerdown outside the popover', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(trigger, outside)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<Popover
|
||||
ref="jobItemPopoverRef"
|
||||
:dismissable="false"
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="{
|
||||
root: { class: 'absolute z-50' },
|
||||
content: {
|
||||
class: [
|
||||
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg font-inter'
|
||||
]
|
||||
}
|
||||
}"
|
||||
@show="isVisible = true"
|
||||
@hide="onHide"
|
||||
>
|
||||
<div
|
||||
ref="contentRef"
|
||||
class="flex min-w-56 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3 font-inter"
|
||||
>
|
||||
<template v-for="entry in entries" :key="entry.key">
|
||||
<div v-if="entry.kind === 'divider'" class="px-2 py-1">
|
||||
<div class="h-px bg-interface-stroke" />
|
||||
</div>
|
||||
<Button
|
||||
v-else
|
||||
class="w-full justify-start bg-transparent"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
:aria-label="entry.label"
|
||||
:disabled="entry.disabled"
|
||||
@click="onEntry(entry)"
|
||||
>
|
||||
<i
|
||||
v-if="entry.icon"
|
||||
:class="[
|
||||
entry.icon,
|
||||
'block size-4 shrink-0 leading-none text-text-secondary'
|
||||
]"
|
||||
/>
|
||||
<span>{{ entry.label }}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Popover from 'primevue/popover'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
|
||||
defineProps<{ entries: MenuEntry[] }>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'action', entry: MenuEntry): void
|
||||
}>()
|
||||
|
||||
type PopoverHandle = {
|
||||
hide: () => void
|
||||
show: (event: Event, target?: EventTarget | null) => void
|
||||
}
|
||||
|
||||
const jobItemPopoverRef = ref<PopoverHandle | null>(null)
|
||||
const contentRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isVisible = ref(false)
|
||||
const openedByClick = ref(false)
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: isVisible,
|
||||
getOverlayEl: () => contentRef.value,
|
||||
getTriggerEl: () => (openedByClick.value ? triggerRef.value : null),
|
||||
onDismiss: hide
|
||||
})
|
||||
|
||||
async function open(event: Event) {
|
||||
const trigger =
|
||||
event.currentTarget instanceof HTMLElement ? event.currentTarget : null
|
||||
const isSameClickTrigger =
|
||||
event.type === 'click' && trigger === triggerRef.value && isVisible.value
|
||||
|
||||
if (isSameClickTrigger) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
openedByClick.value = event.type === 'click'
|
||||
triggerRef.value = trigger
|
||||
|
||||
if (isVisible.value) {
|
||||
hide()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
jobItemPopoverRef.value?.show(event, trigger)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
jobItemPopoverRef.value?.hide()
|
||||
}
|
||||
|
||||
function onHide() {
|
||||
isVisible.value = false
|
||||
openedByClick.value = false
|
||||
}
|
||||
|
||||
function onEntry(entry: MenuEntry) {
|
||||
if (entry.kind === 'divider' || entry.disabled) return
|
||||
emit('action', entry)
|
||||
}
|
||||
|
||||
defineExpose({ open, hide })
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="!isPreviewVisible && showDetails && popoverPosition"
|
||||
class="fixed z-50"
|
||||
class="fixed z-1700"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
@@ -23,7 +23,7 @@
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isPreviewVisible && canShowPreview && popoverPosition"
|
||||
class="fixed z-50"
|
||||
class="fixed z-1700"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
|
||||
@@ -8,16 +8,38 @@
|
||||
@approach-end="emit('approach-end')"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<MediaAssetCard
|
||||
:asset="item.asset"
|
||||
:selected="isSelected(item.asset.id)"
|
||||
:show-output-count="showOutputCount(item.asset)"
|
||||
:output-count="getOutputCount(item.asset)"
|
||||
@click="emit('select-asset', item.asset)"
|
||||
@context-menu="emit('context-menu', $event, item.asset)"
|
||||
@zoom="emit('zoom', item.asset)"
|
||||
@output-count-click="emit('output-count-click', item.asset)"
|
||||
/>
|
||||
<ContextMenu
|
||||
close-on-scroll
|
||||
content-class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
>
|
||||
<MediaAssetCard
|
||||
:asset="item.asset"
|
||||
:selected="isSelected(item.asset.id)"
|
||||
:show-output-count="showOutputCount(item.asset)"
|
||||
:output-count="getOutputCount(item.asset)"
|
||||
:show-delete-button
|
||||
:selected-assets
|
||||
:is-bulk-mode
|
||||
@click="emit('select-asset', item.asset)"
|
||||
@zoom="emit('zoom', item.asset)"
|
||||
@asset-deleted="emit('asset-deleted')"
|
||||
@bulk-download="emit('bulk-download', $event)"
|
||||
@bulk-delete="emit('bulk-delete', $event)"
|
||||
@bulk-add-to-workflow="emit('bulk-add-to-workflow', $event)"
|
||||
@bulk-open-workflow="emit('bulk-open-workflow', $event)"
|
||||
@bulk-export-workflow="emit('bulk-export-workflow', $event)"
|
||||
@output-count-click="emit('output-count-click', item.asset)"
|
||||
/>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
/>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
@@ -26,24 +48,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||
import MenuPanel from '@/components/common/MenuPanel.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
|
||||
|
||||
const { assets, isSelected, showOutputCount, getOutputCount } = defineProps<{
|
||||
const {
|
||||
assets,
|
||||
isSelected,
|
||||
showOutputCount,
|
||||
getOutputCount,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
assets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
showOutputCount: (asset: AssetItem) => boolean
|
||||
getOutputCount: (asset: AssetItem) => number
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'zoom', asset: AssetItem): void
|
||||
(e: 'asset-deleted'): void
|
||||
(e: 'bulk-download', assets: AssetItem[]): void
|
||||
(e: 'bulk-delete', assets: AssetItem[]): void
|
||||
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-open-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-export-workflow', assets: AssetItem[]): void
|
||||
(e: 'output-count-click', asset: AssetItem): void
|
||||
}>()
|
||||
const { getMenuEntries } = useMediaAssetMenu({
|
||||
inspectAsset: (asset) => emit('zoom', asset),
|
||||
assetDeleted: () => emit('asset-deleted'),
|
||||
bulkDownload: (assets) => emit('bulk-download', assets),
|
||||
bulkDelete: (assets) => emit('bulk-delete', assets),
|
||||
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
|
||||
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
|
||||
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
|
||||
})
|
||||
|
||||
type AssetGridItem = { key: string; asset: AssetItem }
|
||||
|
||||
@@ -54,6 +108,22 @@ const assetItems = computed<AssetGridItem[]>(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
|
||||
return getMenuEntries({
|
||||
asset,
|
||||
assetType: getAssetType(asset.tags),
|
||||
fileKind: getMediaTypeFromFilename(asset.name),
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
})
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
const gridStyle = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(min(200px, 30vw), 1fr))',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent } from 'vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
@@ -7,9 +8,9 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
import AssetsSidebarListView from './AssetsSidebarListView.vue'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
vi.mock('@/platform/assets/composables/useMediaAssetMenu', () => ({
|
||||
useMediaAssetMenu: () => ({
|
||||
getMenuEntries: () => []
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -19,7 +20,18 @@ vi.mock('@/stores/assetsStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const VirtualGridStub = defineComponent({
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: {
|
||||
en: {}
|
||||
}
|
||||
})
|
||||
|
||||
const VirtualGridStub = {
|
||||
name: 'VirtualGrid',
|
||||
props: {
|
||||
items: {
|
||||
@@ -29,7 +41,41 @@ const VirtualGridStub = defineComponent({
|
||||
},
|
||||
template:
|
||||
'<div><slot v-for="item in items" :key="item.key" name="item" :item="item" /></div>'
|
||||
})
|
||||
}
|
||||
|
||||
const AssetsListItemStub = {
|
||||
name: 'AssetsListItem',
|
||||
template:
|
||||
'<div class="assets-list-item-stub"><slot /><slot name="actions" /></div>'
|
||||
}
|
||||
|
||||
const ContextMenuStub = {
|
||||
name: 'ContextMenu',
|
||||
template:
|
||||
'<div class="context-menu-stub"><slot /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
|
||||
}
|
||||
|
||||
const DropdownMenuStub = {
|
||||
name: 'DropdownMenu',
|
||||
props: {
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
template:
|
||||
'<div class="dropdown-menu-stub"><slot name="button" /><slot name="content" v-bind="{ close: () => {}, itemComponent: \'div\', separatorComponent: \'div\' }" /></div>'
|
||||
}
|
||||
|
||||
const ButtonComponentStub = {
|
||||
name: 'AppButton',
|
||||
template: '<button class="button-stub" type="button"><slot /></button>'
|
||||
}
|
||||
|
||||
const MenuPanelStub = {
|
||||
name: 'MenuPanel',
|
||||
template: '<div class="menu-panel-stub" />'
|
||||
}
|
||||
|
||||
const buildAsset = (id: string, name: string): AssetItem =>
|
||||
({
|
||||
@@ -53,12 +99,35 @@ const mountListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
toggleStack: async () => {}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
VirtualGrid: VirtualGridStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const mountInteractiveListView = (assetItems: OutputStackListItem[] = []) =>
|
||||
mount(AssetsSidebarListView, {
|
||||
props: {
|
||||
assetItems,
|
||||
selectableAssets: [],
|
||||
isSelected: () => false,
|
||||
isStackExpanded: () => false,
|
||||
toggleStack: async () => {}
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
AssetsListItem: AssetsListItemStub,
|
||||
Button: ButtonComponentStub,
|
||||
ContextMenu: ContextMenuStub,
|
||||
DropdownMenu: DropdownMenuStub,
|
||||
MenuPanel: MenuPanelStub,
|
||||
VirtualGrid: VirtualGridStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('AssetsSidebarListView', () => {
|
||||
it('marks mp4 assets as video previews', () => {
|
||||
const videoAsset = {
|
||||
@@ -131,4 +200,46 @@ describe('AssetsSidebarListView', () => {
|
||||
|
||||
expect(wrapper.emitted('preview-asset')).toEqual([[imageAsset]])
|
||||
})
|
||||
|
||||
it('keeps row actions mounted while the dropdown is open', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-open', 'image.png'),
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
|
||||
const assetListItem = wrapper.find('.assets-list-item-stub')
|
||||
|
||||
await assetListItem.trigger('mouseenter')
|
||||
|
||||
const actionsMenu = wrapper.findComponent(DropdownMenuStub)
|
||||
expect(actionsMenu.exists()).toBe(true)
|
||||
|
||||
actionsMenu.vm.$emit('update:open', true)
|
||||
await nextTick()
|
||||
await assetListItem.trigger('mouseleave')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(true)
|
||||
|
||||
wrapper.findComponent(DropdownMenuStub).vm.$emit('update:open', false)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.findComponent(DropdownMenuStub).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not select the row when clicking the actions trigger', async () => {
|
||||
const imageAsset = {
|
||||
...buildAsset('image-asset-actions', 'image.png'),
|
||||
user_metadata: {}
|
||||
} satisfies AssetItem
|
||||
|
||||
const wrapper = mountInteractiveListView([buildOutputItem(imageAsset)])
|
||||
const assetListItem = wrapper.find('.assets-list-item-stub')
|
||||
|
||||
await assetListItem.trigger('mouseenter')
|
||||
await wrapper.find('.button-stub').trigger('click')
|
||||
|
||||
expect(wrapper.emitted('select-asset')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,49 +16,85 @@
|
||||
>
|
||||
<i class="pi pi-trash text-xs" />
|
||||
</LoadingOverlay>
|
||||
<AssetsListItem
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
getAssetCardClass(isSelected(item.asset.id)),
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
||||
:stack-expanded="isStackExpanded(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@dblclick.stop="emit('preview-asset', item.asset)"
|
||||
@preview-click="emit('preview-asset', item.asset)"
|
||||
@stack-toggle="void toggleStack(item.asset)"
|
||||
<ContextMenu
|
||||
close-on-scroll
|
||||
content-class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="emit('context-menu', $event, item.asset)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
<AssetsListItem
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="
|
||||
t('assetBrowser.ariaLabel.assetCard', {
|
||||
name: getAssetDisplayName(item.asset),
|
||||
type: getAssetMediaType(item.asset)
|
||||
})
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
getAssetCardClass(isSelected(item.asset.id)),
|
||||
item.isChild && 'pl-6'
|
||||
)
|
||||
"
|
||||
:preview-url="getAssetPreviewUrl(item.asset)"
|
||||
:preview-alt="getAssetDisplayName(item.asset)"
|
||||
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
|
||||
:is-video-preview="isVideoAsset(item.asset)"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
:stack-count="getStackCount(item.asset)"
|
||||
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
|
||||
:stack-expanded="isStackExpanded(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@click.stop="emit('select-asset', item.asset, selectableAssets)"
|
||||
@dblclick.stop="emit('preview-asset', item.asset)"
|
||||
@preview-click="emit('preview-asset', item.asset)"
|
||||
@stack-toggle="void toggleStack(item.asset)"
|
||||
>
|
||||
<template v-if="shouldShowActionsMenu(item.asset.id)" #actions>
|
||||
<DropdownMenu
|
||||
:open="openActionsAssetId === item.asset.id"
|
||||
:show-arrow="false"
|
||||
content-class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
:side-offset="4"
|
||||
:collision-padding="8"
|
||||
close-on-scroll
|
||||
@update:open="onActionsMenuOpenChange(item.asset.id, $event)"
|
||||
>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template
|
||||
#content="{ close, itemComponent, separatorComponent }"
|
||||
>
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries(item.asset)"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
/>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -69,16 +105,23 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import MenuPanel from '@/components/common/MenuPanel.vue'
|
||||
import VirtualGrid from '@/components/common/VirtualGrid.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useMediaAssetMenu } from '@/platform/assets/composables/useMediaAssetMenu'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { mediaAssetMenuPanelProps } from '@/platform/assets/components/mediaAssetMenuPanelConfig'
|
||||
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
@@ -92,13 +135,19 @@ const {
|
||||
selectableAssets,
|
||||
isSelected,
|
||||
isStackExpanded,
|
||||
toggleStack
|
||||
toggleStack,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
assetItems: OutputStackListItem[]
|
||||
selectableAssets: AssetItem[]
|
||||
isSelected: (assetId: string) => boolean
|
||||
isStackExpanded: (asset: AssetItem) => boolean
|
||||
toggleStack: (asset: AssetItem) => Promise<void>
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
@@ -106,12 +155,27 @@ const assetsStore = useAssetsStore()
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
|
||||
(e: 'preview-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
(e: 'asset-deleted'): void
|
||||
(e: 'bulk-download', assets: AssetItem[]): void
|
||||
(e: 'bulk-delete', assets: AssetItem[]): void
|
||||
(e: 'bulk-add-to-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-open-workflow', assets: AssetItem[]): void
|
||||
(e: 'bulk-export-workflow', assets: AssetItem[]): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
const openActionsAssetId = ref<string | null>(null)
|
||||
const { getMenuEntries } = useMediaAssetMenu({
|
||||
inspectAsset: (asset) => emit('preview-asset', asset),
|
||||
assetDeleted: () => emit('asset-deleted'),
|
||||
bulkDownload: (assets) => emit('bulk-download', assets),
|
||||
bulkDelete: (assets) => emit('bulk-delete', assets),
|
||||
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
|
||||
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
|
||||
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
|
||||
})
|
||||
|
||||
const listGridStyle = {
|
||||
display: 'grid',
|
||||
@@ -128,6 +192,17 @@ function getAssetMediaType(asset: AssetItem) {
|
||||
return getMediaTypeFromFilename(asset.name)
|
||||
}
|
||||
|
||||
function getAssetMenuEntries(asset: AssetItem): MenuEntry[] {
|
||||
return getMenuEntries({
|
||||
asset,
|
||||
assetType: getAssetType(asset.tags),
|
||||
fileKind: getAssetMediaType(asset),
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
})
|
||||
}
|
||||
|
||||
function isVideoAsset(asset: AssetItem): boolean {
|
||||
return getAssetMediaType(asset) === 'video'
|
||||
}
|
||||
@@ -180,6 +255,28 @@ function getAssetCardClass(selected: boolean): string {
|
||||
)
|
||||
}
|
||||
|
||||
function shouldShowActionsMenu(assetId: string): boolean {
|
||||
return (
|
||||
hoveredAssetId.value === assetId || openActionsAssetId.value === assetId
|
||||
)
|
||||
}
|
||||
|
||||
function onActionsMenuOpenChange(assetId: string, isOpen: boolean): void {
|
||||
if (isOpen) {
|
||||
openActionsAssetId.value = assetId
|
||||
return
|
||||
}
|
||||
|
||||
if (openActionsAssetId.value === assetId) {
|
||||
openActionsAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
function onAssetEnter(assetId: string) {
|
||||
hoveredAssetId.value = assetId
|
||||
}
|
||||
|
||||
@@ -94,10 +94,18 @@
|
||||
:is-selected="isSelected"
|
||||
:selectable-assets="listViewSelectableAssets"
|
||||
:is-stack-expanded="isListViewStackExpanded"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
:toggle-stack="toggleListViewStack"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
@select-asset="handleAssetSelect"
|
||||
@preview-asset="handleZoomClick"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<AssetsSidebarGridView
|
||||
@@ -106,8 +114,16 @@
|
||||
:is-selected="isSelected"
|
||||
:show-output-count="shouldShowOutputCount"
|
||||
:get-output-count="getOutputCount"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
@zoom="handleZoomClick"
|
||||
@output-count-click="enterFolderView"
|
||||
@@ -174,24 +190,6 @@
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<MediaAssetContextMenu
|
||||
v-if="contextMenuAsset"
|
||||
ref="contextMenuRef"
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@zoom="handleZoomClick(contextMenuAsset)"
|
||||
@hide="handleContextMenuHide"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
@bulk-add-to-workflow="handleBulkAddToWorkflow"
|
||||
@bulk-open-workflow="handleBulkOpenWorkflow"
|
||||
@bulk-export-workflow="handleBulkExportWorkflow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -200,14 +198,12 @@ import {
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
useStorage
|
||||
} from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
@@ -224,9 +220,7 @@ import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
|
||||
import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
@@ -236,7 +230,6 @@ import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadat
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -267,9 +260,6 @@ const viewMode = useStorage<'list' | 'grid'>(
|
||||
)
|
||||
const isListView = computed(() => viewMode.value === 'list')
|
||||
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
@@ -277,14 +267,6 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -502,26 +484,6 @@ function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
|
||||
handleAssetClick(asset, index, assetList)
|
||||
}
|
||||
|
||||
const { start: scheduleCleanup, stop: cancelCleanup } = useTimeoutFn(
|
||||
() => {
|
||||
contextMenuAsset.value = null
|
||||
},
|
||||
0,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
cancelCleanup()
|
||||
contextMenuAsset.value = asset
|
||||
void nextTick(() => {
|
||||
contextMenuRef.value?.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
scheduleCleanup()
|
||||
}
|
||||
|
||||
const handleBulkDownload = (assets: AssetItem[]) => {
|
||||
downloadMultipleAssets(assets)
|
||||
clearSelection()
|
||||
|
||||
@@ -5,6 +5,49 @@ import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
|
||||
|
||||
vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
|
||||
default: {
|
||||
name: 'JobDetailsPopover',
|
||||
template: '<div class="job-details-popover-stub" />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/queue/JobHistoryActionsMenu.vue', () => ({
|
||||
default: {
|
||||
name: 'JobHistoryActionsMenu',
|
||||
template: '<div />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/queue/job/JobFilterActions.vue', () => ({
|
||||
default: {
|
||||
name: 'JobFilterActions',
|
||||
template: '<div />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/queue/job/JobFilterTabs.vue', () => ({
|
||||
default: {
|
||||
name: 'JobFilterTabs',
|
||||
template: '<div />'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/SidebarTabTemplate.vue', () => ({
|
||||
default: {
|
||||
name: 'SidebarTabTemplate',
|
||||
template:
|
||||
'<div><slot name="alt-title" /><slot name="header" /><slot name="body" /></div>'
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/sidebar/tabs/queue/MediaLightbox.vue', () => ({
|
||||
default: {
|
||||
name: 'MediaLightbox',
|
||||
template: '<div />'
|
||||
}
|
||||
}))
|
||||
|
||||
const JobDetailsPopoverStub = defineComponent({
|
||||
name: 'JobDetailsPopover',
|
||||
props: {
|
||||
@@ -52,7 +95,7 @@ vi.mock('@/composables/queue/useJobList', async () => {
|
||||
|
||||
vi.mock('@/composables/queue/useJobMenu', () => ({
|
||||
useJobMenu: () => ({
|
||||
jobMenuEntries: [],
|
||||
getJobMenuEntries: () => [],
|
||||
cancelJob: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -130,7 +173,6 @@ function mountComponent() {
|
||||
JobFilterTabs: true,
|
||||
JobFilterActions: true,
|
||||
JobHistoryActionsMenu: true,
|
||||
JobContextMenu: true,
|
||||
ResultGallery: true,
|
||||
teleport: true,
|
||||
JobDetailsPopover: JobDetailsPopoverStub
|
||||
|
||||
@@ -48,15 +48,11 @@
|
||||
<template #body>
|
||||
<JobAssetsList
|
||||
:displayed-job-groups="displayedJobGroups"
|
||||
:get-menu-entries="getJobMenuEntries"
|
||||
@cancel-item="onCancelItem"
|
||||
@delete-item="onDeleteItem"
|
||||
@menu-action="onJobMenuAction"
|
||||
@view-item="onViewItem"
|
||||
@menu="onMenuItem"
|
||||
/>
|
||||
<JobContextMenu
|
||||
ref="jobContextMenuRef"
|
||||
:entries="jobMenuEntries"
|
||||
@action="onJobMenuAction"
|
||||
/>
|
||||
<MediaLightbox
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
@@ -67,15 +63,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import JobFilterActions from '@/components/queue/job/JobFilterActions.vue'
|
||||
import JobFilterTabs from '@/components/queue/job/JobFilterTabs.vue'
|
||||
import JobAssetsList from '@/components/queue/job/JobAssetsList.vue'
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import type { MenuActionEntry } from '@/types/menuTypes'
|
||||
import { useJobMenu } from '@/composables/queue/useJobMenu'
|
||||
import { useJobList } from '@/composables/queue/useJobList'
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
@@ -182,13 +177,7 @@ const onInspectAsset = (item: JobListItem) => {
|
||||
void onViewItem(item)
|
||||
}
|
||||
|
||||
const currentMenuItem = ref<JobListItem | null>(null)
|
||||
const jobContextMenuRef = ref<InstanceType<typeof JobContextMenu> | null>(null)
|
||||
|
||||
const { jobMenuEntries, cancelJob } = useJobMenu(
|
||||
() => currentMenuItem.value,
|
||||
onInspectAsset
|
||||
)
|
||||
const { getJobMenuEntries, cancelJob } = useJobMenu(undefined, onInspectAsset)
|
||||
|
||||
const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await cancelJob(item)
|
||||
@@ -199,14 +188,9 @@ const onDeleteItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
await queueStore.delete(item.taskRef)
|
||||
})
|
||||
|
||||
const onMenuItem = (item: JobListItem, event: Event) => {
|
||||
currentMenuItem.value = item
|
||||
jobContextMenuRef.value?.open(event)
|
||||
}
|
||||
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(async (entry: MenuEntry) => {
|
||||
if (entry.kind === 'divider') return
|
||||
if (entry.onClick) await entry.onClick()
|
||||
jobContextMenuRef.value?.hide()
|
||||
})
|
||||
const onJobMenuAction = wrapWithErrorHandlingAsync(
|
||||
async (entry: MenuActionEntry) => {
|
||||
if (entry.onClick) await entry.onClick()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { nextTick, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { JobListItem } from '@/composables/queue/useJobList'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
import type { MenuEntry } from '@/types/menuTypes'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
|
||||
@@ -20,20 +20,10 @@ import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
|
||||
import type { MenuEntry } from '@/types/menuTypes'
|
||||
import { createAnnotatedPath } from '@/utils/createAnnotatedPath'
|
||||
import { appendJsonExt } from '@/utils/formatUtil'
|
||||
|
||||
export type MenuEntry =
|
||||
| {
|
||||
kind?: 'item'
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
| { kind: 'divider'; key: string }
|
||||
|
||||
/**
|
||||
* Provides job context menu entries and actions.
|
||||
*
|
||||
@@ -117,10 +107,10 @@ export function useJobMenu(
|
||||
|
||||
// This is very magical only because it matches the respective backend implementation
|
||||
// There is or will be a better way to do this
|
||||
const addOutputLoaderNode = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
const addOutputLoaderNode = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
|
||||
let nodeType: 'LoadImage' | 'LoadVideo' | 'LoadAudio' | null = null
|
||||
@@ -168,10 +158,10 @@ export function useJobMenu(
|
||||
/**
|
||||
* Trigger a download of the job's previewable output asset.
|
||||
*/
|
||||
const downloadPreviewAsset = () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const result: ResultItemImpl | undefined = item.taskRef?.previewOutput
|
||||
const downloadPreviewAsset = (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const result: ResultItemImpl | undefined = target.taskRef?.previewOutput
|
||||
if (!result) return
|
||||
downloadFile(result.url)
|
||||
}
|
||||
@@ -179,14 +169,14 @@ export function useJobMenu(
|
||||
/**
|
||||
* Export the workflow JSON attached to the job.
|
||||
*/
|
||||
const exportJobWorkflow = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const data = await getJobWorkflow(item.id)
|
||||
const exportJobWorkflow = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const data = await getJobWorkflow(target.id)
|
||||
if (!data) return
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
let filename = `Job ${item.id}.json`
|
||||
let filename = `Job ${target.id}.json`
|
||||
|
||||
if (settingStore.get('Comfy.PromptFilename')) {
|
||||
const input = await useDialogService().prompt({
|
||||
@@ -203,10 +193,10 @@ export function useJobMenu(
|
||||
downloadBlob(filename, blob)
|
||||
}
|
||||
|
||||
const deleteJobAsset = async () => {
|
||||
const item = currentMenuItem()
|
||||
if (!item) return
|
||||
const task = item.taskRef as TaskItemImpl | undefined
|
||||
const deleteJobAsset = async (item?: JobListItem | null) => {
|
||||
const target = resolveItem(item)
|
||||
if (!target) return
|
||||
const task = target.taskRef as TaskItemImpl | undefined
|
||||
const preview = task?.previewOutput
|
||||
if (!task || !preview) return
|
||||
|
||||
@@ -237,11 +227,11 @@ export function useJobMenu(
|
||||
st('queue.jobMenu.cancelJob', 'Cancel job')
|
||||
)
|
||||
|
||||
const jobMenuEntries = computed<MenuEntry[]>(() => {
|
||||
const item = currentMenuItem()
|
||||
const state = item?.state
|
||||
const buildJobMenuEntries = (item?: JobListItem | null): MenuEntry[] => {
|
||||
const target = resolveItem(item)
|
||||
const state = target?.state
|
||||
if (!state) return []
|
||||
const hasPreviewAsset = !!item?.taskRef?.previewOutput
|
||||
const hasPreviewAsset = !!target?.taskRef?.previewOutput
|
||||
if (state === 'completed') {
|
||||
return [
|
||||
{
|
||||
@@ -251,8 +241,7 @@ export function useJobMenu(
|
||||
disabled: !hasPreviewAsset || !onInspectAsset,
|
||||
onClick: onInspectAsset
|
||||
? () => {
|
||||
const item = currentMenuItem()
|
||||
if (item) onInspectAsset(item)
|
||||
if (target) onInspectAsset(target)
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
@@ -264,34 +253,34 @@ export function useJobMenu(
|
||||
),
|
||||
icon: 'icon-[comfy--node]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: addOutputLoaderNode
|
||||
onClick: () => addOutputLoaderNode(target)
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: st('queue.jobMenu.download', 'Download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
disabled: !hasPreviewAsset,
|
||||
onClick: downloadPreviewAsset
|
||||
onClick: () => downloadPreviewAsset(target)
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
onClick: () => openJobWorkflow(target)
|
||||
},
|
||||
{
|
||||
key: 'export-workflow',
|
||||
label: st('queue.jobMenu.exportWorkflow', 'Export workflow'),
|
||||
icon: 'icon-[comfy--file-output]',
|
||||
onClick: exportJobWorkflow
|
||||
onClick: () => exportJobWorkflow(target)
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
onClick: () => copyJobId(target)
|
||||
},
|
||||
{ kind: 'divider', key: 'd3' },
|
||||
...(hasPreviewAsset
|
||||
@@ -300,7 +289,7 @@ export function useJobMenu(
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.deleteAsset', 'Delete asset'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: deleteJobAsset
|
||||
onClick: () => deleteJobAsset(target)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
@@ -312,33 +301,33 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowFailedLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
onClick: () => openJobWorkflow(target)
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
onClick: () => copyJobId(target)
|
||||
},
|
||||
{
|
||||
key: 'copy-error',
|
||||
label: st('queue.jobMenu.copyErrorMessage', 'Copy error message'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyErrorMessage
|
||||
onClick: () => copyErrorMessage(target)
|
||||
},
|
||||
{
|
||||
key: 'report-error',
|
||||
label: st('queue.jobMenu.reportError', 'Report error'),
|
||||
icon: 'icon-[lucide--message-circle-warning]',
|
||||
onClick: reportError
|
||||
onClick: () => reportError(target)
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: st('queue.jobMenu.removeJob', 'Remove job'),
|
||||
icon: 'icon-[lucide--circle-minus]',
|
||||
onClick: removeFailedJob
|
||||
onClick: () => removeFailedJob(target?.taskRef)
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -347,27 +336,30 @@ export function useJobMenu(
|
||||
key: 'open-workflow',
|
||||
label: jobMenuOpenWorkflowLabel.value,
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: openJobWorkflow
|
||||
onClick: () => openJobWorkflow(target)
|
||||
},
|
||||
{ kind: 'divider', key: 'd1' },
|
||||
{
|
||||
key: 'copy-id',
|
||||
label: jobMenuCopyJobIdLabel.value,
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: copyJobId
|
||||
onClick: () => copyJobId(target)
|
||||
},
|
||||
{ kind: 'divider', key: 'd2' },
|
||||
{
|
||||
key: 'cancel-job',
|
||||
label: jobMenuCancelLabel.value,
|
||||
icon: 'icon-[lucide--x]',
|
||||
onClick: cancelJob
|
||||
onClick: () => cancelJob(target)
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const jobMenuEntries = computed<MenuEntry[]>(() => buildJobMenuEntries())
|
||||
|
||||
return {
|
||||
jobMenuEntries,
|
||||
getJobMenuEntries: buildJobMenuEntries,
|
||||
openJobWorkflow,
|
||||
copyJobId,
|
||||
cancelJob,
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { EffectScope, Ref } from 'vue'
|
||||
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
|
||||
describe('useDismissableOverlay', () => {
|
||||
let scope: EffectScope | undefined
|
||||
let isOpen: Ref<boolean>
|
||||
let overlayEl: HTMLElement
|
||||
let triggerEl: HTMLElement
|
||||
let outsideEl: HTMLElement
|
||||
let dismissCount: number
|
||||
|
||||
const mountComposable = ({
|
||||
dismissOnScroll = false,
|
||||
getTriggerEl
|
||||
}: {
|
||||
dismissOnScroll?: boolean
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
} = {}) => {
|
||||
scope = effectScope()
|
||||
scope.run(() =>
|
||||
useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl: () => overlayEl,
|
||||
getTriggerEl,
|
||||
onDismiss: () => {
|
||||
dismissCount += 1
|
||||
},
|
||||
dismissOnScroll
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
isOpen = ref(true)
|
||||
overlayEl = document.createElement('div')
|
||||
triggerEl = document.createElement('button')
|
||||
outsideEl = document.createElement('div')
|
||||
dismissCount = 0
|
||||
document.body.append(overlayEl, triggerEl, outsideEl)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = undefined
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('dismisses on outside pointerdown', () => {
|
||||
mountComposable()
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the overlay', () => {
|
||||
mountComposable()
|
||||
|
||||
overlayEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores pointerdown inside the trigger', () => {
|
||||
mountComposable({
|
||||
getTriggerEl: () => triggerEl
|
||||
})
|
||||
|
||||
triggerEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('dismisses on scroll when enabled', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(1)
|
||||
})
|
||||
|
||||
it('ignores scroll inside the overlay', () => {
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
overlayEl.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
|
||||
it('does not dismiss when closed', () => {
|
||||
isOpen.value = false
|
||||
mountComposable({
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
outsideEl.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
|
||||
expect(dismissCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
interface UseDismissableOverlayOptions {
|
||||
isOpen: MaybeRefOrGetter<boolean>
|
||||
getOverlayEl: () => HTMLElement | null
|
||||
onDismiss: () => void
|
||||
getTriggerEl?: () => HTMLElement | null
|
||||
dismissOnScroll?: boolean
|
||||
}
|
||||
|
||||
const isNode = (value: EventTarget | null | undefined): value is Node =>
|
||||
value instanceof Node
|
||||
|
||||
const isInside = (target: Node, element: HTMLElement | null | undefined) =>
|
||||
!!element?.contains(target)
|
||||
|
||||
export function useDismissableOverlay({
|
||||
isOpen,
|
||||
getOverlayEl,
|
||||
onDismiss,
|
||||
getTriggerEl,
|
||||
dismissOnScroll = false
|
||||
}: UseDismissableOverlayOptions) {
|
||||
const dismissIfOutside = (event: Event) => {
|
||||
if (!toValue(isOpen)) {
|
||||
return
|
||||
}
|
||||
|
||||
const overlay = getOverlayEl()
|
||||
if (!overlay) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isNode(event.target)) {
|
||||
onDismiss()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
isInside(event.target, overlay) ||
|
||||
isInside(event.target, getTriggerEl?.())
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerdown', dismissIfOutside, { capture: true })
|
||||
|
||||
if (dismissOnScroll) {
|
||||
useEventListener(window, 'scroll', dismissIfOutside, {
|
||||
capture: true,
|
||||
passive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,6 @@
|
||||
:data-selected="selected"
|
||||
:draggable="true"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
@dragstart="dragStart"
|
||||
>
|
||||
<!-- Top Area: Media Preview -->
|
||||
@@ -69,16 +66,35 @@
|
||||
>
|
||||
<i class="icon-[lucide--zoom-in] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
<DropdownMenu
|
||||
v-if="asset"
|
||||
v-model:open="isActionsMenuOpen"
|
||||
:show-arrow="false"
|
||||
content-class="z-1700 bg-transparent p-0 shadow-lg"
|
||||
:side-offset="4"
|
||||
:collision-padding="8"
|
||||
close-on-scroll
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
<template #button>
|
||||
<Button
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template #content="{ close, itemComponent, separatorComponent }">
|
||||
<MenuPanel
|
||||
:entries="getAssetMenuEntries()"
|
||||
:item-component="itemComponent"
|
||||
:separator-component="separatorComponent"
|
||||
v-bind="mediaAssetMenuPanelProps"
|
||||
@action="void onAssetMenuAction($event, close)"
|
||||
/>
|
||||
</template>
|
||||
</DropdownMenu>
|
||||
</IconGroup>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,9 +155,12 @@ import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
import DropdownMenu from '@/components/common/DropdownMenu.vue'
|
||||
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
|
||||
import MenuPanel from '@/components/common/MenuPanel.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import type { MenuActionEntry, MenuEntry } from '@/types/menuTypes'
|
||||
import {
|
||||
formatDuration,
|
||||
formatSize,
|
||||
@@ -154,11 +173,13 @@ import { cn } from '@/utils/tailwindUtil'
|
||||
import { getAssetType } from '../composables/media/assetMappers'
|
||||
import { getAssetUrl } from '../utils/assetUrlUtil'
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import { useMediaAssetMenu } from '../composables/useMediaAssetMenu'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
import { mediaAssetMenuPanelProps } from './mediaAssetMenuPanelConfig'
|
||||
|
||||
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
|
||||
|
||||
@@ -177,12 +198,24 @@ function getTopComponent(kind: PreviewKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.other
|
||||
}
|
||||
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
const {
|
||||
asset,
|
||||
loading,
|
||||
selected,
|
||||
showOutputCount,
|
||||
outputCount,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
asset?: AssetItem
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
showOutputCount?: boolean
|
||||
outputCount?: number
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const assetsStore = useAssetsStore()
|
||||
@@ -196,13 +229,19 @@ const emit = defineEmits<{
|
||||
click: []
|
||||
zoom: [asset: AssetItem]
|
||||
'output-count-click': []
|
||||
'context-menu': [event: MouseEvent, asset: AssetItem]
|
||||
'asset-deleted': []
|
||||
'bulk-download': [assets: AssetItem[]]
|
||||
'bulk-delete': [assets: AssetItem[]]
|
||||
'bulk-add-to-workflow': [assets: AssetItem[]]
|
||||
'bulk-open-workflow': [assets: AssetItem[]]
|
||||
'bulk-export-workflow': [assets: AssetItem[]]
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
const isActionsMenuOpen = ref(false)
|
||||
|
||||
// Store actual image dimensions
|
||||
const imageDimensions = ref<{ width: number; height: number } | undefined>()
|
||||
@@ -210,6 +249,15 @@ const imageDimensions = ref<{ width: number; height: number } | undefined>()
|
||||
const isHovered = useElementHover(cardContainerRef)
|
||||
|
||||
const actions = useMediaAssetActions()
|
||||
const { getMenuEntries } = useMediaAssetMenu({
|
||||
inspectAsset: (target) => emit('zoom', target),
|
||||
assetDeleted: () => emit('asset-deleted'),
|
||||
bulkDownload: (assets) => emit('bulk-download', assets),
|
||||
bulkDelete: (assets) => emit('bulk-delete', assets),
|
||||
bulkAddToWorkflow: (assets) => emit('bulk-add-to-workflow', assets),
|
||||
bulkOpenWorkflow: (assets) => emit('bulk-open-workflow', assets),
|
||||
bulkExportWorkflow: (assets) => emit('bulk-export-workflow', assets)
|
||||
})
|
||||
|
||||
// Get asset type from tags
|
||||
const assetType = computed(() => {
|
||||
@@ -290,7 +338,12 @@ const metaInfo = computed(() => {
|
||||
|
||||
const showActionsOverlay = computed(() => {
|
||||
if (loading || !asset || isDeleting.value) return false
|
||||
return isHovered.value || selected || isVideoPlaying.value
|
||||
return (
|
||||
isHovered.value ||
|
||||
selected ||
|
||||
isVideoPlaying.value ||
|
||||
isActionsMenuOpen.value
|
||||
)
|
||||
})
|
||||
|
||||
const handleZoomClick = () => {
|
||||
@@ -306,6 +359,27 @@ const handleImageLoaded = (width: number, height: number) => {
|
||||
const handleOutputCountClick = () => {
|
||||
emit('output-count-click')
|
||||
}
|
||||
|
||||
function getAssetMenuEntries(): MenuEntry[] {
|
||||
if (!asset) {
|
||||
return []
|
||||
}
|
||||
|
||||
return getMenuEntries({
|
||||
asset,
|
||||
assetType: assetType.value,
|
||||
fileKind: fileKind.value,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
})
|
||||
}
|
||||
|
||||
async function onAssetMenuAction(entry: MenuActionEntry, close: () => void) {
|
||||
close()
|
||||
await entry.onClick?.()
|
||||
}
|
||||
|
||||
function dragStart(e: DragEvent) {
|
||||
if (!asset?.preview_url) return
|
||||
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/utils/workflowExtractionUtil', () => ({
|
||||
supportsWorkflowMetadata: () => true
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/formatUtil', () => ({
|
||||
isPreviewableMediaType: () => true
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/loaderNodeUtil', () => ({
|
||||
detectNodeTypeFromFilename: () => ({ nodeType: 'LoadImage' })
|
||||
}))
|
||||
|
||||
const mediaAssetActions = {
|
||||
addWorkflow: vi.fn(),
|
||||
downloadAsset: vi.fn(),
|
||||
openWorkflow: vi.fn(),
|
||||
exportWorkflow: vi.fn(),
|
||||
copyJobId: vi.fn(),
|
||||
deleteAssets: vi.fn().mockResolvedValue(false)
|
||||
}
|
||||
|
||||
vi.mock('../composables/useMediaAssetActions', () => ({
|
||||
useMediaAssetActions: () => mediaAssetActions
|
||||
}))
|
||||
|
||||
const contextMenuStub = defineComponent({
|
||||
name: 'ContextMenu',
|
||||
props: {
|
||||
pt: {
|
||||
type: Object,
|
||||
default: undefined
|
||||
}
|
||||
},
|
||||
emits: ['hide'],
|
||||
data() {
|
||||
return {
|
||||
visible: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
show() {
|
||||
this.visible = true
|
||||
},
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.$emit('hide')
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
v-if="visible"
|
||||
class="context-menu-stub"
|
||||
v-bind="pt?.root"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const asset: AssetItem = {
|
||||
id: 'asset-1',
|
||||
name: 'image.png',
|
||||
tags: [],
|
||||
user_metadata: {}
|
||||
}
|
||||
|
||||
const buttonStub = {
|
||||
template: '<div class="button-stub"><slot /></div>'
|
||||
}
|
||||
|
||||
type MediaAssetContextMenuExposed = ComponentPublicInstance & {
|
||||
show: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(MediaAssetContextMenu, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
asset,
|
||||
assetType: 'output',
|
||||
fileKind: 'image'
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ContextMenu: contextMenuStub,
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function showMenu(
|
||||
wrapper: ReturnType<typeof mountComponent>
|
||||
): Promise<HTMLElement> {
|
||||
const exposed = wrapper.vm as MediaAssetContextMenuExposed
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true })
|
||||
exposed.show(event)
|
||||
await nextTick()
|
||||
|
||||
return wrapper.get('.context-menu-stub').element as HTMLElement
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('MediaAssetContextMenu', () => {
|
||||
it('dismisses outside pointerdown using the rendered root id', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(outside)
|
||||
|
||||
const menu = await showMenu(wrapper)
|
||||
const menuId = menu.id
|
||||
|
||||
expect(menuId).not.toBe('')
|
||||
expect(document.getElementById(menuId)).toBe(menu)
|
||||
|
||||
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.context-menu-stub').exists()).toBe(false)
|
||||
expect(wrapper.emitted('hide')).toEqual([[]])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -1,286 +0,0 @@
|
||||
<template>
|
||||
<ContextMenu
|
||||
ref="contextMenu"
|
||||
:model="contextMenuItems"
|
||||
:pt="{
|
||||
root: {
|
||||
id: contextMenuId,
|
||||
class: cn(
|
||||
'rounded-lg',
|
||||
'bg-secondary-background text-base-foreground',
|
||||
'shadow-lg'
|
||||
)
|
||||
}
|
||||
}"
|
||||
@hide="onMenuHide"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full justify-start"
|
||||
v-bind="props.action"
|
||||
>
|
||||
<i v-if="item.icon" :class="item.icon" class="size-4" />
|
||||
<span>{{
|
||||
typeof item.label === 'function' ? item.label() : (item.label ?? '')
|
||||
}}</span>
|
||||
</Button>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ContextMenu from 'primevue/contextmenu'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useDismissableOverlay } from '@/composables/useDismissableOverlay'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
|
||||
|
||||
const {
|
||||
asset,
|
||||
assetType,
|
||||
fileKind,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
} = defineProps<{
|
||||
asset: AssetItem
|
||||
assetType: AssetContext['type']
|
||||
fileKind: MediaKind
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
zoom: []
|
||||
hide: []
|
||||
'asset-deleted': []
|
||||
'bulk-download': [assets: AssetItem[]]
|
||||
'bulk-delete': [assets: AssetItem[]]
|
||||
'bulk-add-to-workflow': [assets: AssetItem[]]
|
||||
'bulk-open-workflow': [assets: AssetItem[]]
|
||||
'bulk-export-workflow': [assets: AssetItem[]]
|
||||
}>()
|
||||
|
||||
type ContextMenuHandle = {
|
||||
show: (event: MouseEvent) => void
|
||||
hide: () => void
|
||||
}
|
||||
|
||||
const contextMenu = ref<ContextMenuHandle | null>(null)
|
||||
const contextMenuId = useId()
|
||||
const isVisible = ref(false)
|
||||
const actions = useMediaAssetActions()
|
||||
const { t } = useI18n()
|
||||
|
||||
useDismissableOverlay({
|
||||
isOpen: isVisible,
|
||||
getOverlayEl: () => document.getElementById(contextMenuId),
|
||||
onDismiss: hide,
|
||||
dismissOnScroll: true
|
||||
})
|
||||
|
||||
const showAddToWorkflow = computed(() => {
|
||||
// Output assets can always be added
|
||||
if (assetType === 'output') return true
|
||||
|
||||
// Input assets: check if file type is supported by loader nodes
|
||||
if (assetType === 'input' && asset?.name) {
|
||||
const { nodeType } = detectNodeTypeFromFilename(asset.name)
|
||||
return nodeType !== null
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const showWorkflowActions = computed(() => {
|
||||
// Output assets always have workflow metadata
|
||||
if (assetType === 'output') return true
|
||||
|
||||
// Input assets: only formats that support workflow metadata
|
||||
if (assetType === 'input' && asset?.name) {
|
||||
return supportsWorkflowMetadata(asset.name)
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const showCopyJobId = computed(() => {
|
||||
return assetType !== 'input'
|
||||
})
|
||||
|
||||
const shouldShowDeleteButton = computed(() => {
|
||||
const propAllows = showDeleteButton ?? true
|
||||
const typeAllows =
|
||||
assetType === 'output' || (assetType === 'input' && isCloud)
|
||||
|
||||
return propAllows && typeAllows
|
||||
})
|
||||
|
||||
// Context menu items
|
||||
const contextMenuItems = computed<MenuItem[]>(() => {
|
||||
if (!asset) return []
|
||||
|
||||
const items: MenuItem[] = []
|
||||
|
||||
// Check if current asset is part of the selection
|
||||
const isCurrentAssetSelected = selectedAssets?.some(
|
||||
(selectedAsset) => selectedAsset.id === asset.id
|
||||
)
|
||||
|
||||
// Bulk mode: Show selected count and bulk actions only if current asset is selected
|
||||
if (
|
||||
isBulkMode &&
|
||||
selectedAssets &&
|
||||
selectedAssets.length > 0 &&
|
||||
isCurrentAssetSelected
|
||||
) {
|
||||
// Header item showing selected count
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.multipleSelectedAssets'),
|
||||
disabled: true
|
||||
})
|
||||
|
||||
// Bulk Add to Workflow
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
|
||||
icon: 'icon-[comfy--node]',
|
||||
command: () => emit('bulk-add-to-workflow', selectedAssets)
|
||||
})
|
||||
|
||||
// Bulk Open Workflow
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.openWorkflowAll'),
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
command: () => emit('bulk-open-workflow', selectedAssets)
|
||||
})
|
||||
|
||||
// Bulk Export Workflow
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.exportWorkflowAll'),
|
||||
icon: 'icon-[lucide--file-output]',
|
||||
command: () => emit('bulk-export-workflow', selectedAssets)
|
||||
})
|
||||
|
||||
// Bulk Download
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.downloadSelectedAll'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
command: () => emit('bulk-download', selectedAssets)
|
||||
})
|
||||
|
||||
// Bulk Delete (if allowed)
|
||||
if (shouldShowDeleteButton.value) {
|
||||
items.push({
|
||||
label: t('mediaAsset.selection.deleteSelectedAll'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
command: () => emit('bulk-delete', selectedAssets)
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// Individual mode: Show all menu options
|
||||
|
||||
// Inspect
|
||||
if (isPreviewableMediaType(fileKind)) {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.inspect'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
command: () => emit('zoom')
|
||||
})
|
||||
}
|
||||
|
||||
// Add to workflow (conditional)
|
||||
if (showAddToWorkflow.value) {
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
|
||||
icon: 'icon-[comfy--node]',
|
||||
command: () => actions.addWorkflow(asset)
|
||||
})
|
||||
}
|
||||
|
||||
// Download
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
command: () => actions.downloadAsset(asset)
|
||||
})
|
||||
|
||||
// Separator before workflow actions (only if there are workflow actions)
|
||||
if (showWorkflowActions.value) {
|
||||
items.push({ separator: true })
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.openWorkflow'),
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
command: () => actions.openWorkflow(asset)
|
||||
})
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.exportWorkflow'),
|
||||
icon: 'icon-[lucide--file-output]',
|
||||
command: () => actions.exportWorkflow(asset)
|
||||
})
|
||||
}
|
||||
|
||||
// Copy job ID
|
||||
if (showCopyJobId.value) {
|
||||
items.push({ separator: true })
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.copyJobId'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
command: async () => {
|
||||
await actions.copyJobId(asset)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Delete
|
||||
if (shouldShowDeleteButton.value) {
|
||||
items.push({ separator: true })
|
||||
items.push({
|
||||
label: t('mediaAsset.actions.delete'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
command: async () => {
|
||||
if (asset) {
|
||||
const confirmed = await actions.deleteAssets(asset)
|
||||
if (confirmed) {
|
||||
emit('asset-deleted')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function onMenuHide() {
|
||||
isVisible.value = false
|
||||
emit('hide')
|
||||
}
|
||||
|
||||
function show(event: MouseEvent) {
|
||||
isVisible.value = true
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
isVisible.value = false
|
||||
contextMenu.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,8 @@
|
||||
export const mediaAssetMenuPanelProps = {
|
||||
panelClass:
|
||||
'media-asset-menu-panel flex min-w-56 flex-col rounded-lg border border-border-subtle bg-secondary-background p-2 text-base-foreground',
|
||||
separatorWrapperClass: 'm-1',
|
||||
separatorClass: 'h-px bg-border-subtle',
|
||||
buttonClass: 'w-full justify-start',
|
||||
iconClass: 'size-4'
|
||||
} as const
|
||||
260
src/platform/assets/composables/useMediaAssetMenu.ts
Normal file
260
src/platform/assets/composables/useMediaAssetMenu.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import type { MenuEntry } from '@/types/menuTypes'
|
||||
import { isPreviewableMediaType } from '@/utils/formatUtil'
|
||||
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
|
||||
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
|
||||
|
||||
import { useMediaAssetActions } from './useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
|
||||
|
||||
type MediaAssetMenuContext = {
|
||||
asset: AssetItem
|
||||
assetType: AssetContext['type']
|
||||
fileKind: MediaKind
|
||||
showDeleteButton?: boolean
|
||||
selectedAssets?: AssetItem[]
|
||||
isBulkMode?: boolean
|
||||
}
|
||||
|
||||
type MediaAssetMenuHandlers = {
|
||||
inspectAsset?: (asset: AssetItem) => void | Promise<void>
|
||||
assetDeleted?: (asset: AssetItem) => void | Promise<void>
|
||||
bulkDownload?: (assets: AssetItem[]) => void | Promise<void>
|
||||
bulkDelete?: (assets: AssetItem[]) => void | Promise<void>
|
||||
bulkAddToWorkflow?: (assets: AssetItem[]) => void | Promise<void>
|
||||
bulkOpenWorkflow?: (assets: AssetItem[]) => void | Promise<void>
|
||||
bulkExportWorkflow?: (assets: AssetItem[]) => void | Promise<void>
|
||||
}
|
||||
|
||||
function canAddToWorkflow(
|
||||
candidate: AssetItem,
|
||||
assetType: AssetContext['type']
|
||||
): boolean {
|
||||
if (assetType === 'output') return true
|
||||
|
||||
if (assetType === 'input' && candidate.name) {
|
||||
return detectNodeTypeFromFilename(candidate.name).nodeType !== null
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function canShowWorkflowActions(
|
||||
candidate: AssetItem,
|
||||
assetType: AssetContext['type']
|
||||
): boolean {
|
||||
if (assetType === 'output') return true
|
||||
|
||||
if (assetType === 'input' && candidate.name) {
|
||||
return supportsWorkflowMetadata(candidate.name)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function canDeleteAsset(
|
||||
assetType: AssetContext['type'],
|
||||
showDeleteButton?: boolean
|
||||
): boolean {
|
||||
const propAllows = showDeleteButton ?? true
|
||||
const typeAllows =
|
||||
assetType === 'output' || (assetType === 'input' && isCloud)
|
||||
|
||||
return propAllows && typeAllows
|
||||
}
|
||||
|
||||
export function useMediaAssetMenu(handlers: MediaAssetMenuHandlers = {}) {
|
||||
const { t } = useI18n()
|
||||
const actions = useMediaAssetActions()
|
||||
|
||||
async function deleteAsset(asset: AssetItem) {
|
||||
const deleted = await actions.deleteAssets(asset)
|
||||
|
||||
if (deleted) {
|
||||
await handlers.assetDeleted?.(asset)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedAssets(selectedAssets: AssetItem[]) {
|
||||
if (handlers.bulkDelete) {
|
||||
await handlers.bulkDelete(selectedAssets)
|
||||
return
|
||||
}
|
||||
|
||||
await actions.deleteAssets(selectedAssets)
|
||||
}
|
||||
|
||||
function getMenuEntries({
|
||||
asset,
|
||||
assetType,
|
||||
fileKind,
|
||||
showDeleteButton,
|
||||
selectedAssets,
|
||||
isBulkMode
|
||||
}: MediaAssetMenuContext): MenuEntry[] {
|
||||
const isSelectedAsset = selectedAssets?.some(
|
||||
(selectedAsset) => selectedAsset.id === asset.id
|
||||
)
|
||||
const showBulkActions =
|
||||
isBulkMode &&
|
||||
selectedAssets &&
|
||||
selectedAssets.length > 0 &&
|
||||
isSelectedAsset
|
||||
|
||||
if (showBulkActions) {
|
||||
const allSelectedCanAddToWorkflow = selectedAssets.every(
|
||||
(selectedAsset) => canAddToWorkflow(selectedAsset, assetType)
|
||||
)
|
||||
const allSelectedSupportWorkflowActions = selectedAssets.every(
|
||||
(selectedAsset) => canShowWorkflowActions(selectedAsset, assetType)
|
||||
)
|
||||
const bulkDeleteEnabled = canDeleteAsset(assetType, showDeleteButton)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'bulk-selection-header',
|
||||
label: t('mediaAsset.selection.multipleSelectedAssets'),
|
||||
disabled: true
|
||||
},
|
||||
...(allSelectedCanAddToWorkflow
|
||||
? [
|
||||
{
|
||||
key: 'bulk-add-to-workflow',
|
||||
label: t('mediaAsset.selection.insertAllAssetsAsNodes'),
|
||||
icon: 'icon-[comfy--node]',
|
||||
onClick: () => {
|
||||
if (handlers.bulkAddToWorkflow) {
|
||||
return handlers.bulkAddToWorkflow(selectedAssets)
|
||||
}
|
||||
return actions.addMultipleToWorkflow(selectedAssets)
|
||||
}
|
||||
} satisfies MenuEntry
|
||||
]
|
||||
: []),
|
||||
...(allSelectedSupportWorkflowActions
|
||||
? [
|
||||
{
|
||||
key: 'bulk-open-workflow',
|
||||
label: t('mediaAsset.selection.openWorkflowAll'),
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => {
|
||||
if (handlers.bulkOpenWorkflow) {
|
||||
return handlers.bulkOpenWorkflow(selectedAssets)
|
||||
}
|
||||
return actions.openMultipleWorkflows(selectedAssets)
|
||||
}
|
||||
} satisfies MenuEntry,
|
||||
{
|
||||
key: 'bulk-export-workflow',
|
||||
label: t('mediaAsset.selection.exportWorkflowAll'),
|
||||
icon: 'icon-[lucide--file-output]',
|
||||
onClick: () => {
|
||||
if (handlers.bulkExportWorkflow) {
|
||||
return handlers.bulkExportWorkflow(selectedAssets)
|
||||
}
|
||||
return actions.exportMultipleWorkflows(selectedAssets)
|
||||
}
|
||||
} satisfies MenuEntry
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'bulk-download',
|
||||
label: t('mediaAsset.selection.downloadSelectedAll'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
onClick: () => {
|
||||
if (handlers.bulkDownload) {
|
||||
return handlers.bulkDownload(selectedAssets)
|
||||
}
|
||||
return actions.downloadMultipleAssets(selectedAssets)
|
||||
}
|
||||
},
|
||||
...(bulkDeleteEnabled
|
||||
? [
|
||||
{
|
||||
key: 'bulk-delete',
|
||||
label: t('mediaAsset.selection.deleteSelectedAll'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: async () => {
|
||||
await deleteSelectedAssets(selectedAssets)
|
||||
}
|
||||
} satisfies MenuEntry
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
|
||||
const entries: MenuEntry[] = []
|
||||
const showWorkflowActions = canShowWorkflowActions(asset, assetType)
|
||||
const deleteEnabled = canDeleteAsset(assetType, showDeleteButton)
|
||||
|
||||
if (isPreviewableMediaType(fileKind)) {
|
||||
entries.push({
|
||||
key: 'inspect',
|
||||
label: t('mediaAsset.actions.inspect'),
|
||||
icon: 'icon-[lucide--zoom-in]',
|
||||
onClick: () => handlers.inspectAsset?.(asset)
|
||||
})
|
||||
}
|
||||
|
||||
if (canAddToWorkflow(asset, assetType)) {
|
||||
entries.push({
|
||||
key: 'add-to-workflow',
|
||||
label: t('mediaAsset.actions.insertAsNodeInWorkflow'),
|
||||
icon: 'icon-[comfy--node]',
|
||||
onClick: () => actions.addWorkflow(asset)
|
||||
})
|
||||
}
|
||||
|
||||
entries.push({
|
||||
key: 'download',
|
||||
label: t('mediaAsset.actions.download'),
|
||||
icon: 'icon-[lucide--download]',
|
||||
onClick: () => actions.downloadAsset(asset)
|
||||
})
|
||||
|
||||
if (showWorkflowActions) {
|
||||
entries.push({ kind: 'divider', key: 'workflow-divider' })
|
||||
entries.push({
|
||||
key: 'open-workflow',
|
||||
label: t('mediaAsset.actions.openWorkflow'),
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
onClick: () => actions.openWorkflow(asset)
|
||||
})
|
||||
entries.push({
|
||||
key: 'export-workflow',
|
||||
label: t('mediaAsset.actions.exportWorkflow'),
|
||||
icon: 'icon-[lucide--file-output]',
|
||||
onClick: () => actions.exportWorkflow(asset)
|
||||
})
|
||||
}
|
||||
|
||||
if (assetType !== 'input') {
|
||||
entries.push({ kind: 'divider', key: 'copy-job-id-divider' })
|
||||
entries.push({
|
||||
key: 'copy-job-id',
|
||||
label: t('mediaAsset.actions.copyJobId'),
|
||||
icon: 'icon-[lucide--copy]',
|
||||
onClick: async () => {
|
||||
await actions.copyJobId(asset)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (deleteEnabled) {
|
||||
entries.push({ kind: 'divider', key: 'delete-divider' })
|
||||
entries.push({
|
||||
key: 'delete',
|
||||
label: t('mediaAsset.actions.delete'),
|
||||
icon: 'icon-[lucide--trash-2]',
|
||||
onClick: async () => deleteAsset(asset)
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
return { getMenuEntries }
|
||||
}
|
||||
15
src/types/menuTypes.ts
Normal file
15
src/types/menuTypes.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type MenuActionEntry = {
|
||||
kind?: 'item'
|
||||
key: string
|
||||
label: string
|
||||
icon?: string
|
||||
disabled?: boolean
|
||||
onClick?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
type MenuDividerEntry = {
|
||||
kind: 'divider'
|
||||
key: string
|
||||
}
|
||||
|
||||
export type MenuEntry = MenuActionEntry | MenuDividerEntry
|
||||
Reference in New Issue
Block a user