From 9d131a4267a47f0b0af35eb8638f6373f1792004 Mon Sep 17 00:00:00 2001 From: Jin Yi Date: Thu, 27 Nov 2025 13:02:32 +0700 Subject: [PATCH] [feat] Add right-click context menu to MediaAssetCard (#6844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../sidebar/tabs/AssetsSidebarTab.vue | 11 +- src/locales/en/main.json | 10 +- .../components/MediaAssetButtonDivider.vue | 3 - .../assets/components/MediaAssetCard.vue | 85 +++---- .../components/MediaAssetContextMenu.vue | 196 ++++++++++++++++ .../assets/components/MediaAssetMoreMenu.vue | 212 ------------------ .../composables/useMediaAssetActions.ts | 56 ++--- src/types/buttonTypes.ts | 4 +- 8 files changed, 272 insertions(+), 305 deletions(-) delete mode 100644 src/platform/assets/components/MediaAssetButtonDivider.vue create mode 100644 src/platform/assets/components/MediaAssetContextMenu.vue delete mode 100644 src/platform/assets/components/MediaAssetMoreMenu.vue diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue index 053cc40659..b9e9e05ae0 100644 --- a/src/components/sidebar/tabs/AssetsSidebarTab.vue +++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue @@ -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" /> @@ -113,7 +112,7 @@ count: totalOutputCount }) " - type="transparent" + type="secondary" :class="isCompact ? 'text-left' : ''" @click="handleDeselectAll" /> @@ -202,8 +201,8 @@ const folderPromptId = ref(null) const folderExecutionTime = ref(undefined) const isInFolderView = computed(() => folderPromptId.value !== null) -// Track which asset's popover is open (for single-instance popover management) -const openPopoverId = ref(null) +// Track which asset's context menu is open (for single-instance context menu management) +const openContextMenuId = ref(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) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 813376c732..c31c85f53e 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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", diff --git a/src/platform/assets/components/MediaAssetButtonDivider.vue b/src/platform/assets/components/MediaAssetButtonDivider.vue deleted file mode 100644 index a19b52fc8f..0000000000 --- a/src/platform/assets/components/MediaAssetButtonDivider.vue +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/src/platform/assets/components/MediaAssetCard.vue b/src/platform/assets/components/MediaAssetCard.vue index fff097d7f9..0d83f93662 100644 --- a/src/platform/assets/components/MediaAssetCard.vue +++ b/src/platform/assets/components/MediaAssetCard.vue @@ -16,7 +16,8 @@ rounded="lg" :class="containerClasses" :data-selected="selected" - @click.stop + @click.stop="$emit('click')" + @contextmenu.prevent="handleContextMenu" > @@ -129,6 +113,17 @@ + + diff --git a/src/platform/assets/components/MediaAssetContextMenu.vue b/src/platform/assets/components/MediaAssetContextMenu.vue new file mode 100644 index 0000000000..db15cb3911 --- /dev/null +++ b/src/platform/assets/components/MediaAssetContextMenu.vue @@ -0,0 +1,196 @@ + + + diff --git a/src/platform/assets/components/MediaAssetMoreMenu.vue b/src/platform/assets/components/MediaAssetMoreMenu.vue deleted file mode 100644 index 6014b80635..0000000000 --- a/src/platform/assets/components/MediaAssetMoreMenu.vue +++ /dev/null @@ -1,212 +0,0 @@ - - - diff --git a/src/platform/assets/composables/useMediaAssetActions.ts b/src/platform/assets/composables/useMediaAssetActions.ts index c889f00658..e2fb92736e 100644 --- a/src/platform/assets/composables/useMediaAssetActions.ts +++ b/src/platform/assets/composables/useMediaAssetActions.ts @@ -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( diff --git a/src/types/buttonTypes.ts b/src/types/buttonTypes.ts index 3b514727ab..bfa0f50310 100644 --- a/src/types/buttonTypes.ts +++ b/src/types/buttonTypes.ts @@ -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' }