[feat] Add right-click context menu to MediaAssetCard (#6844)

## Summary
- Add right-click context menu functionality to MediaAssetCard
- Separate context menu into its own component
(MediaAssetContextMenu.vue)
- Ensure only one context menu is visible at a time within the same tab

## Changes
- Add `MediaAssetContextMenu.vue` - new component for context menu
- Update `MediaAssetCard.vue` - show context menu on right-click and
more button click
- Delete `MediaAssetMoreMenu.vue` - consolidated into context menu
- Delete `MediaAssetButtonDivider.vue` - unused
- Update `AssetsSidebarTab.vue` - add context menu state management
- Refactor `useMediaAssetActions.ts`

## Screenshot

[screen-capture.webm](https://github.com/user-attachments/assets/6fe414ef-b134-4fbe-98aa-6437bb354b41)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-11-27 13:02:32 +07:00
committed by GitHub
parent c57ceaf826
commit 9d131a4267
8 changed files with 272 additions and 305 deletions

View File

@@ -85,13 +85,12 @@
:show-output-count="shouldShowOutputCount(item)"
:output-count="getOutputCount(item)"
:show-delete-button="shouldShowDeleteButton"
:open-popover-id="openPopoverId"
:open-context-menu-id="openContextMenuId"
@click="handleAssetSelect(item)"
@zoom="handleZoomClick(item)"
@output-count-click="enterFolderView(item)"
@asset-deleted="refreshAssets"
@popover-opened="openPopoverId = item.id"
@popover-closed="openPopoverId = null"
@context-menu-opened="openContextMenuId = item.id"
/>
</template>
</VirtualGrid>
@@ -113,7 +112,7 @@
count: totalOutputCount
})
"
type="transparent"
type="secondary"
:class="isCompact ? 'text-left' : ''"
@click="handleDeselectAll"
/>
@@ -202,8 +201,8 @@ const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const isInFolderView = computed(() => folderPromptId.value !== null)
// Track which asset's popover is open (for single-instance popover management)
const openPopoverId = ref<string | null>(null)
// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | 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)

View File

@@ -2161,9 +2161,15 @@
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
"failedToDeleteAsset": "Failed to delete asset",
"actions": {
"inspect": "Inspect",
"inspect": "Inspect asset",
"more": "More options",
"seeMoreOutputs": "See more outputs"
"seeMoreOutputs": "See more outputs",
"addToWorkflow": "Add to current workflow",
"download": "Download",
"openWorkflow": "Open as workflow in new tab",
"exportWorkflow": "Export workflow",
"copyJobId": "Copy job ID",
"delete": "Delete"
},
"jobIdToast": {
"jobIdCopied": "Job ID copied to clipboard",

View File

@@ -1,3 +0,0 @@
<template>
<div class="h-px bg-border-default"></div>
</template>

View File

@@ -16,7 +16,8 @@
rounded="lg"
:class="containerClasses"
:data-selected="selected"
@click.stop
@click.stop="$emit('click')"
@contextmenu.prevent="handleContextMenu"
>
<template #top>
<CardTop
@@ -59,29 +60,12 @@
<!-- Media actions - show on hover or when playing -->
<IconGroup v-else-if="showActionsOverlay">
<IconButton
v-tooltip.top="$t('mediaAsset.actions.inspect')"
size="sm"
@click.stop="handleZoomClick"
>
<IconButton size="sm" @click.stop="handleZoomClick">
<i class="icon-[lucide--zoom-in] size-4" />
</IconButton>
<MoreButton
ref="moreButtonRef"
v-tooltip.top="$t('mediaAsset.actions.more')"
size="sm"
@menu-opened="handleMenuOpened"
@menu-closed="handleMenuClosed"
>
<template #default="{ close }">
<MediaAssetMoreMenu
:close="close"
:show-delete-button="showDeleteButton"
@inspect="handleZoomClick"
@asset-deleted="handleAssetDelete"
/>
</template>
</MoreButton>
<IconButton size="sm" @click.stop="handleContextMenu">
<i class="icon-[lucide--ellipsis] size-4" />
</IconButton>
</IconGroup>
</template>
@@ -129,6 +113,17 @@
</CardBottom>
</template>
</CardContainer>
<MediaAssetContextMenu
v-if="asset"
ref="contextMenu"
:asset="asset"
:asset-type="assetType"
:file-kind="fileKind"
:show-delete-button="showDeleteButton"
@zoom="handleZoomClick"
@asset-deleted="emit('asset-deleted')"
/>
</template>
<script setup lang="ts">
@@ -138,7 +133,6 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconGroup from '@/components/button/IconGroup.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
@@ -151,7 +145,7 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
const mediaComponents = {
top: {
@@ -183,7 +177,7 @@ const {
showOutputCount,
outputCount,
showDeleteButton,
openPopoverId
openContextMenuId
} = defineProps<{
asset?: AssetItem
loading?: boolean
@@ -191,22 +185,21 @@ const {
showOutputCount?: boolean
outputCount?: number
showDeleteButton?: boolean
openPopoverId?: string | null
openContextMenuId?: string | null
}>()
const emit = defineEmits<{
click: []
zoom: [asset: AssetItem]
'output-count-click': []
'asset-deleted': []
'popover-opened': []
'popover-closed': []
'context-menu-opened': []
}>()
const cardContainerRef = ref<HTMLElement>()
const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
const isVideoPlaying = ref(false)
const isMenuOpen = ref(false)
const showVideoControls = ref(false)
// Store actual image dimensions
@@ -289,26 +282,19 @@ const durationChipClasses = computed(() => {
return ''
})
const isCardOrOverlayHovered = computed(
() => isHovered.value || isMenuOpen.value
)
// Show static chips when NOT hovered and NOT playing (normal state)
const showStaticChips = computed(
() =>
!loading &&
!!asset &&
!isCardOrOverlayHovered.value &&
!isHovered.value &&
!isVideoPlaying.value &&
(formattedDuration.value || fileFormat.value)
)
// Show action overlay when hovered OR playing
const showActionsOverlay = computed(
() =>
!loading &&
!!asset &&
(isCardOrOverlayHovered.value || isVideoPlaying.value)
() => !loading && !!asset && (isHovered.value || isVideoPlaying.value)
)
const handleZoomClick = () => {
@@ -325,25 +311,16 @@ const handleOutputCountClick = () => {
emit('output-count-click')
}
const handleAssetDelete = () => {
emit('asset-deleted')
const handleContextMenu = (event: MouseEvent) => {
emit('context-menu-opened')
contextMenu.value?.show(event)
}
const handleMenuOpened = () => {
isMenuOpen.value = true
emit('popover-opened')
}
const handleMenuClosed = () => {
isMenuOpen.value = false
emit('popover-closed')
}
// Close this popover when another opens
// Close this context menu when another opens
whenever(
() => openPopoverId && openPopoverId !== asset?.id && isMenuOpen.value,
() => openContextMenuId && openContextMenuId !== asset?.id,
() => {
moreButtonRef.value?.hide()
contextMenu.value?.hide()
}
)
</script>

View File

@@ -0,0 +1,196 @@
<template>
<ContextMenu
ref="contextMenu"
:model="contextMenuItems"
:pt="{
root: {
class: cn(
'rounded-lg',
'bg-secondary-background text-base-foreground',
'shadow-lg'
)
}
}"
>
<template #item="{ item, props }">
<IconTextButton
type="secondary"
size="full-width"
:label="
typeof item.label === 'function' ? item.label() : (item.label ?? '')
"
v-bind="props.action"
>
<template #icon>
<i :class="item.icon" class="size-4" />
</template>
</IconTextButton>
</template>
</ContextMenu>
</template>
<script setup lang="ts">
import { onClickOutside } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import { computed, ref } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
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 } = defineProps<{
asset: AssetItem
assetType: AssetContext['type']
fileKind: MediaKind
showDeleteButton?: boolean
}>()
const emit = defineEmits<{
zoom: []
'asset-deleted': []
}>()
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
const actions = useMediaAssetActions()
// Close context menu when clicking outside
onClickOutside(
computed(() => (contextMenu.value as any)?.$el),
() => {
hide()
}
)
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[] = []
// Inspect (if not 3D)
if (fileKind !== '3D') {
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.addToWorkflow'),
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 success = await actions.confirmDelete(asset)
if (success) {
emit('asset-deleted')
}
}
}
})
}
return items
})
const show = (event: MouseEvent) => {
contextMenu.value?.show(event)
}
const hide = () => {
contextMenu.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,212 +0,0 @@
<template>
<div class="flex flex-col">
<!-- TODO: 3D assets currently excluded from inspection.
When 3D loader nodes are implemented, update detectNodeTypeFromFilename
to return appropriate node type for .gltf, .glb files and remove this exclusion -->
<IconTextButton
v-if="asset?.kind !== '3D'"
type="transparent"
:label="$t('queue.jobMenu.inspectAsset')"
@click="handleInspect"
>
<template #icon>
<i class="icon-[lucide--zoom-in] size-4" />
</template>
</IconTextButton>
<IconTextButton
v-if="showAddToWorkflow"
type="transparent"
:label="$t('queue.jobMenu.addToCurrentWorkflow')"
@click="handleAddToWorkflow"
>
<template #icon>
<i class="icon-[comfy--node] size-4" />
</template>
</IconTextButton>
<IconTextButton
type="transparent"
:label="$t('queue.jobMenu.download')"
@click="handleDownload"
>
<template #icon>
<i class="icon-[lucide--download] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showAddToWorkflow || showWorkflowActions" />
<IconTextButton
v-if="showWorkflowActions"
type="transparent"
:label="$t('queue.jobMenu.openAsWorkflowNewTab')"
@click="handleOpenWorkflow"
>
<template #icon>
<i class="icon-[comfy--workflow] size-4" />
</template>
</IconTextButton>
<IconTextButton
v-if="showWorkflowActions"
type="transparent"
:label="$t('queue.jobMenu.exportWorkflow')"
@click="handleExportWorkflow"
>
<template #icon>
<i class="icon-[lucide--file-output] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showWorkflowActions && showCopyJobId" />
<IconTextButton
v-if="showCopyJobId"
type="transparent"
:label="$t('queue.jobMenu.copyJobId')"
@click="handleCopyJobId"
>
<template #icon>
<i class="icon-[lucide--copy] size-4" />
</template>
</IconTextButton>
<MediaAssetButtonDivider v-if="showCopyJobId && shouldShowDeleteButton" />
<IconTextButton
v-if="shouldShowDeleteButton"
type="transparent"
:label="$t('queue.jobMenu.delete')"
@click="handleDelete"
>
<template #icon>
<i class="icon-[lucide--trash-2] size-4" />
</template>
</IconTextButton>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
import { isCloud } from '@/platform/distribution/types'
import { supportsWorkflowMetadata } from '@/platform/workflow/utils/workflowExtractionUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import MediaAssetButtonDivider from './MediaAssetButtonDivider.vue'
const { close, showDeleteButton } = defineProps<{
close: () => void
showDeleteButton?: boolean
}>()
const emit = defineEmits<{
inspect: []
'asset-deleted': []
}>()
const { asset, context } = inject(MediaAssetKey)!
const actions = useMediaAssetActions()
const assetType = computed(() => {
return asset.value?.tags?.[0] || context.value?.type || 'output'
})
// Show "Add to current workflow" for all media files (images, videos, audio)
// This works for any file type that has a corresponding loader node
const showAddToWorkflow = computed(() => {
// Output assets can always be added
if (assetType.value === 'output') return true
// Input assets: check if file type is supported by loader nodes
// Use the same utility as the actual addWorkflow function for consistency
if (assetType.value === 'input' && asset.value?.name) {
const { nodeType } = detectNodeTypeFromFilename(asset.value.name)
return nodeType !== null
}
return false
})
// Show "Open/Export workflow" only for files with workflow metadata
// This is more restrictive - only PNG, WEBP, FLAC support embedded workflows
const showWorkflowActions = computed(() => {
// Output assets always have workflow metadata
if (assetType.value === 'output') return true
// Input assets: only formats that support workflow metadata
if (assetType.value === 'input' && asset.value?.name) {
return supportsWorkflowMetadata(asset.value.name)
}
return false
})
// Only show Copy Job ID for output assets (not for imported/input assets)
const showCopyJobId = computed(() => {
return assetType.value !== 'input'
})
const shouldShowDeleteButton = computed(() => {
const propAllows = showDeleteButton ?? true
const typeAllows =
assetType.value === 'output' || (assetType.value === 'input' && isCloud)
return propAllows && typeAllows
})
const handleInspect = () => {
emit('inspect')
close()
}
const handleAddToWorkflow = () => {
if (asset.value) {
actions.addWorkflow()
}
close()
}
const handleDownload = () => {
if (asset.value) {
actions.downloadAsset()
}
close()
}
const handleOpenWorkflow = () => {
if (asset.value) {
actions.openWorkflow()
}
close()
}
const handleExportWorkflow = () => {
if (asset.value) {
actions.exportWorkflow()
}
close()
}
const handleCopyJobId = async () => {
if (asset.value) {
await actions.copyJobId()
}
close()
}
const handleDelete = async () => {
if (!asset.value) return
close() // Close the menu first
const success = await actions.confirmDelete(asset.value)
if (success) {
emit('asset-deleted')
}
}
</script>

View File

@@ -58,20 +58,20 @@ export function useMediaAssetActions() {
}
}
const downloadAsset = () => {
const asset = mediaContext?.asset.value
if (!asset) return
const downloadAsset = (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
try {
const filename = asset.name
const filename = targetAsset.name
let downloadUrl: string
// In cloud, use preview_url directly (from cloud storage)
// In OSS/localhost, use the /view endpoint
if (isCloud && asset.preview_url) {
downloadUrl = asset.preview_url
if (isCloud && targetAsset.preview_url) {
downloadUrl = targetAsset.preview_url
} else {
downloadUrl = getAssetUrl(asset)
downloadUrl = getAssetUrl(targetAsset)
}
downloadFile(downloadUrl, filename)
@@ -198,13 +198,13 @@ export function useMediaAssetActions() {
}
}
const copyJobId = async () => {
const asset = mediaContext?.asset.value
if (!asset) return
const copyJobId = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
// Try asset.id first (OSS), then fall back to metadata (Cloud)
const metadata = getOutputAssetMetadata(asset.user_metadata)
const promptId = asset.id || metadata?.promptId
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const promptId = targetAsset.id || metadata?.promptId
if (!promptId) {
toast.add({
@@ -223,12 +223,14 @@ export function useMediaAssetActions() {
* Add a loader node to the current workflow for this asset
* Uses shared utility to detect appropriate node type based on file extension
*/
const addWorkflow = async () => {
const asset = mediaContext?.asset.value
if (!asset) return
const addWorkflow = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
// Detect node type using shared utility
const { nodeType, widgetName } = detectNodeTypeFromFilename(asset.name)
const { nodeType, widgetName } = detectNodeTypeFromFilename(
targetAsset.name
)
if (!nodeType || !widgetName) {
toast.add({
@@ -266,13 +268,13 @@ export function useMediaAssetActions() {
}
// Get metadata to construct the annotated path
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
// Create annotated path for the asset
const annotated = createAnnotatedPath(
{
filename: asset.name,
filename: targetAsset.name,
subfolder: metadata?.subfolder || '',
type: isResultItemType(assetType) ? assetType : undefined
},
@@ -300,12 +302,12 @@ export function useMediaAssetActions() {
* Open the workflow from this asset in a new tab
* Uses shared workflow extraction and action service
*/
const openWorkflow = async () => {
const asset = mediaContext?.asset.value
if (!asset) return
const openWorkflow = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
// Extract workflow using shared utility
const { workflow, filename } = await extractWorkflowFromAsset(asset)
const { workflow, filename } = await extractWorkflowFromAsset(targetAsset)
// Use shared action service
const result = await workflowActions.openWorkflowAction(workflow, filename)
@@ -331,12 +333,12 @@ export function useMediaAssetActions() {
* Export the workflow from this asset as a JSON file
* Uses shared workflow extraction and action service
*/
const exportWorkflow = async () => {
const asset = mediaContext?.asset.value
if (!asset) return
const exportWorkflow = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
// Extract workflow using shared utility
const { workflow, filename } = await extractWorkflowFromAsset(asset)
const { workflow, filename } = await extractWorkflowFromAsset(targetAsset)
// Use shared action service
const result = await workflowActions.exportWorkflowAction(

View File

@@ -1,7 +1,7 @@
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
export type ButtonSize = 'fit-content' | 'sm' | 'md'
export type ButtonSize = 'full-width' | 'fit-content' | 'sm' | 'md'
type ButtonType = 'primary' | 'secondary' | 'transparent' | 'accent'
type ButtonBorder = boolean
@@ -16,6 +16,7 @@ export interface BaseButtonProps {
export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
const sizeClasses = {
'fit-content': '',
'full-width': 'w-full',
sm: 'px-2 py-1.5 text-xs',
md: 'px-4 py-2 text-sm'
}
@@ -66,6 +67,7 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => {
const sizeClasses = {
'fit-content': 'w-auto h-auto',
'full-width': 'w-full h-auto',
sm: 'size-8 text-xs !rounded-md',
md: 'size-10 text-sm'
}