mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +00:00
[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:
@@ -85,13 +85,12 @@
|
|||||||
:show-output-count="shouldShowOutputCount(item)"
|
:show-output-count="shouldShowOutputCount(item)"
|
||||||
:output-count="getOutputCount(item)"
|
:output-count="getOutputCount(item)"
|
||||||
:show-delete-button="shouldShowDeleteButton"
|
:show-delete-button="shouldShowDeleteButton"
|
||||||
:open-popover-id="openPopoverId"
|
:open-context-menu-id="openContextMenuId"
|
||||||
@click="handleAssetSelect(item)"
|
@click="handleAssetSelect(item)"
|
||||||
@zoom="handleZoomClick(item)"
|
@zoom="handleZoomClick(item)"
|
||||||
@output-count-click="enterFolderView(item)"
|
@output-count-click="enterFolderView(item)"
|
||||||
@asset-deleted="refreshAssets"
|
@asset-deleted="refreshAssets"
|
||||||
@popover-opened="openPopoverId = item.id"
|
@context-menu-opened="openContextMenuId = item.id"
|
||||||
@popover-closed="openPopoverId = null"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</VirtualGrid>
|
||||||
@@ -113,7 +112,7 @@
|
|||||||
count: totalOutputCount
|
count: totalOutputCount
|
||||||
})
|
})
|
||||||
"
|
"
|
||||||
type="transparent"
|
type="secondary"
|
||||||
:class="isCompact ? 'text-left' : ''"
|
:class="isCompact ? 'text-left' : ''"
|
||||||
@click="handleDeselectAll"
|
@click="handleDeselectAll"
|
||||||
/>
|
/>
|
||||||
@@ -202,8 +201,8 @@ const folderPromptId = ref<string | null>(null)
|
|||||||
const folderExecutionTime = ref<number | undefined>(undefined)
|
const folderExecutionTime = ref<number | undefined>(undefined)
|
||||||
const isInFolderView = computed(() => folderPromptId.value !== null)
|
const isInFolderView = computed(() => folderPromptId.value !== null)
|
||||||
|
|
||||||
// Track which asset's popover is open (for single-instance popover management)
|
// Track which asset's context menu is open (for single-instance context menu management)
|
||||||
const openPopoverId = ref<string | null>(null)
|
const openContextMenuId = ref<string | null>(null)
|
||||||
|
|
||||||
// Determine if delete button should be shown
|
// 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)
|
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||||
|
|||||||
@@ -2161,9 +2161,15 @@
|
|||||||
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
|
"deletingImportedFilesCloudOnly": "Deleting imported files is only supported in cloud version",
|
||||||
"failedToDeleteAsset": "Failed to delete asset",
|
"failedToDeleteAsset": "Failed to delete asset",
|
||||||
"actions": {
|
"actions": {
|
||||||
"inspect": "Inspect",
|
"inspect": "Inspect asset",
|
||||||
"more": "More options",
|
"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": {
|
"jobIdToast": {
|
||||||
"jobIdCopied": "Job ID copied to clipboard",
|
"jobIdCopied": "Job ID copied to clipboard",
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="h-px bg-border-default"></div>
|
|
||||||
</template>
|
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
rounded="lg"
|
rounded="lg"
|
||||||
:class="containerClasses"
|
:class="containerClasses"
|
||||||
:data-selected="selected"
|
:data-selected="selected"
|
||||||
@click.stop
|
@click.stop="$emit('click')"
|
||||||
|
@contextmenu.prevent="handleContextMenu"
|
||||||
>
|
>
|
||||||
<template #top>
|
<template #top>
|
||||||
<CardTop
|
<CardTop
|
||||||
@@ -59,29 +60,12 @@
|
|||||||
|
|
||||||
<!-- Media actions - show on hover or when playing -->
|
<!-- Media actions - show on hover or when playing -->
|
||||||
<IconGroup v-else-if="showActionsOverlay">
|
<IconGroup v-else-if="showActionsOverlay">
|
||||||
<IconButton
|
<IconButton size="sm" @click.stop="handleZoomClick">
|
||||||
v-tooltip.top="$t('mediaAsset.actions.inspect')"
|
|
||||||
size="sm"
|
|
||||||
@click.stop="handleZoomClick"
|
|
||||||
>
|
|
||||||
<i class="icon-[lucide--zoom-in] size-4" />
|
<i class="icon-[lucide--zoom-in] size-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<MoreButton
|
<IconButton size="sm" @click.stop="handleContextMenu">
|
||||||
ref="moreButtonRef"
|
<i class="icon-[lucide--ellipsis] size-4" />
|
||||||
v-tooltip.top="$t('mediaAsset.actions.more')"
|
</IconButton>
|
||||||
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>
|
|
||||||
</IconGroup>
|
</IconGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -129,6 +113,17 @@
|
|||||||
</CardBottom>
|
</CardBottom>
|
||||||
</template>
|
</template>
|
||||||
</CardContainer>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -138,7 +133,6 @@ import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
|||||||
import IconButton from '@/components/button/IconButton.vue'
|
import IconButton from '@/components/button/IconButton.vue'
|
||||||
import IconGroup from '@/components/button/IconGroup.vue'
|
import IconGroup from '@/components/button/IconGroup.vue'
|
||||||
import IconTextButton from '@/components/button/IconTextButton.vue'
|
import IconTextButton from '@/components/button/IconTextButton.vue'
|
||||||
import MoreButton from '@/components/button/MoreButton.vue'
|
|
||||||
import CardBottom from '@/components/card/CardBottom.vue'
|
import CardBottom from '@/components/card/CardBottom.vue'
|
||||||
import CardContainer from '@/components/card/CardContainer.vue'
|
import CardContainer from '@/components/card/CardContainer.vue'
|
||||||
import CardTop from '@/components/card/CardTop.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 { AssetItem } from '../schemas/assetSchema'
|
||||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||||
import MediaAssetMoreMenu from './MediaAssetMoreMenu.vue'
|
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
|
||||||
|
|
||||||
const mediaComponents = {
|
const mediaComponents = {
|
||||||
top: {
|
top: {
|
||||||
@@ -183,7 +177,7 @@ const {
|
|||||||
showOutputCount,
|
showOutputCount,
|
||||||
outputCount,
|
outputCount,
|
||||||
showDeleteButton,
|
showDeleteButton,
|
||||||
openPopoverId
|
openContextMenuId
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
asset?: AssetItem
|
asset?: AssetItem
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
@@ -191,22 +185,21 @@ const {
|
|||||||
showOutputCount?: boolean
|
showOutputCount?: boolean
|
||||||
outputCount?: number
|
outputCount?: number
|
||||||
showDeleteButton?: boolean
|
showDeleteButton?: boolean
|
||||||
openPopoverId?: string | null
|
openContextMenuId?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
zoom: [asset: AssetItem]
|
zoom: [asset: AssetItem]
|
||||||
'output-count-click': []
|
'output-count-click': []
|
||||||
'asset-deleted': []
|
'asset-deleted': []
|
||||||
'popover-opened': []
|
'context-menu-opened': []
|
||||||
'popover-closed': []
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const cardContainerRef = ref<HTMLElement>()
|
const cardContainerRef = ref<HTMLElement>()
|
||||||
const moreButtonRef = ref<InstanceType<typeof MoreButton>>()
|
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||||
|
|
||||||
const isVideoPlaying = ref(false)
|
const isVideoPlaying = ref(false)
|
||||||
const isMenuOpen = ref(false)
|
|
||||||
const showVideoControls = ref(false)
|
const showVideoControls = ref(false)
|
||||||
|
|
||||||
// Store actual image dimensions
|
// Store actual image dimensions
|
||||||
@@ -289,26 +282,19 @@ const durationChipClasses = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const isCardOrOverlayHovered = computed(
|
|
||||||
() => isHovered.value || isMenuOpen.value
|
|
||||||
)
|
|
||||||
|
|
||||||
// Show static chips when NOT hovered and NOT playing (normal state)
|
// Show static chips when NOT hovered and NOT playing (normal state)
|
||||||
const showStaticChips = computed(
|
const showStaticChips = computed(
|
||||||
() =>
|
() =>
|
||||||
!loading &&
|
!loading &&
|
||||||
!!asset &&
|
!!asset &&
|
||||||
!isCardOrOverlayHovered.value &&
|
!isHovered.value &&
|
||||||
!isVideoPlaying.value &&
|
!isVideoPlaying.value &&
|
||||||
(formattedDuration.value || fileFormat.value)
|
(formattedDuration.value || fileFormat.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show action overlay when hovered OR playing
|
// Show action overlay when hovered OR playing
|
||||||
const showActionsOverlay = computed(
|
const showActionsOverlay = computed(
|
||||||
() =>
|
() => !loading && !!asset && (isHovered.value || isVideoPlaying.value)
|
||||||
!loading &&
|
|
||||||
!!asset &&
|
|
||||||
(isCardOrOverlayHovered.value || isVideoPlaying.value)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleZoomClick = () => {
|
const handleZoomClick = () => {
|
||||||
@@ -325,25 +311,16 @@ const handleOutputCountClick = () => {
|
|||||||
emit('output-count-click')
|
emit('output-count-click')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAssetDelete = () => {
|
const handleContextMenu = (event: MouseEvent) => {
|
||||||
emit('asset-deleted')
|
emit('context-menu-opened')
|
||||||
|
contextMenu.value?.show(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuOpened = () => {
|
// Close this context menu when another opens
|
||||||
isMenuOpen.value = true
|
|
||||||
emit('popover-opened')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMenuClosed = () => {
|
|
||||||
isMenuOpen.value = false
|
|
||||||
emit('popover-closed')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close this popover when another opens
|
|
||||||
whenever(
|
whenever(
|
||||||
() => openPopoverId && openPopoverId !== asset?.id && isMenuOpen.value,
|
() => openContextMenuId && openContextMenuId !== asset?.id,
|
||||||
() => {
|
() => {
|
||||||
moreButtonRef.value?.hide()
|
contextMenu.value?.hide()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
196
src/platform/assets/components/MediaAssetContextMenu.vue
Normal file
196
src/platform/assets/components/MediaAssetContextMenu.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -58,20 +58,20 @@ export function useMediaAssetActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadAsset = () => {
|
const downloadAsset = (asset?: AssetItem) => {
|
||||||
const asset = mediaContext?.asset.value
|
const targetAsset = asset ?? mediaContext?.asset.value
|
||||||
if (!asset) return
|
if (!targetAsset) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filename = asset.name
|
const filename = targetAsset.name
|
||||||
let downloadUrl: string
|
let downloadUrl: string
|
||||||
|
|
||||||
// In cloud, use preview_url directly (from cloud storage)
|
// In cloud, use preview_url directly (from cloud storage)
|
||||||
// In OSS/localhost, use the /view endpoint
|
// In OSS/localhost, use the /view endpoint
|
||||||
if (isCloud && asset.preview_url) {
|
if (isCloud && targetAsset.preview_url) {
|
||||||
downloadUrl = asset.preview_url
|
downloadUrl = targetAsset.preview_url
|
||||||
} else {
|
} else {
|
||||||
downloadUrl = getAssetUrl(asset)
|
downloadUrl = getAssetUrl(targetAsset)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadFile(downloadUrl, filename)
|
downloadFile(downloadUrl, filename)
|
||||||
@@ -198,13 +198,13 @@ export function useMediaAssetActions() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyJobId = async () => {
|
const copyJobId = async (asset?: AssetItem) => {
|
||||||
const asset = mediaContext?.asset.value
|
const targetAsset = asset ?? mediaContext?.asset.value
|
||||||
if (!asset) return
|
if (!targetAsset) return
|
||||||
|
|
||||||
// Try asset.id first (OSS), then fall back to metadata (Cloud)
|
// Try asset.id first (OSS), then fall back to metadata (Cloud)
|
||||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||||
const promptId = asset.id || metadata?.promptId
|
const promptId = targetAsset.id || metadata?.promptId
|
||||||
|
|
||||||
if (!promptId) {
|
if (!promptId) {
|
||||||
toast.add({
|
toast.add({
|
||||||
@@ -223,12 +223,14 @@ export function useMediaAssetActions() {
|
|||||||
* Add a loader node to the current workflow for this asset
|
* Add a loader node to the current workflow for this asset
|
||||||
* Uses shared utility to detect appropriate node type based on file extension
|
* Uses shared utility to detect appropriate node type based on file extension
|
||||||
*/
|
*/
|
||||||
const addWorkflow = async () => {
|
const addWorkflow = async (asset?: AssetItem) => {
|
||||||
const asset = mediaContext?.asset.value
|
const targetAsset = asset ?? mediaContext?.asset.value
|
||||||
if (!asset) return
|
if (!targetAsset) return
|
||||||
|
|
||||||
// Detect node type using shared utility
|
// Detect node type using shared utility
|
||||||
const { nodeType, widgetName } = detectNodeTypeFromFilename(asset.name)
|
const { nodeType, widgetName } = detectNodeTypeFromFilename(
|
||||||
|
targetAsset.name
|
||||||
|
)
|
||||||
|
|
||||||
if (!nodeType || !widgetName) {
|
if (!nodeType || !widgetName) {
|
||||||
toast.add({
|
toast.add({
|
||||||
@@ -266,13 +268,13 @@ export function useMediaAssetActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get metadata to construct the annotated path
|
// Get metadata to construct the annotated path
|
||||||
const metadata = getOutputAssetMetadata(asset.user_metadata)
|
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
|
||||||
const assetType = getAssetType(asset, 'input')
|
const assetType = getAssetType(targetAsset, 'input')
|
||||||
|
|
||||||
// Create annotated path for the asset
|
// Create annotated path for the asset
|
||||||
const annotated = createAnnotatedPath(
|
const annotated = createAnnotatedPath(
|
||||||
{
|
{
|
||||||
filename: asset.name,
|
filename: targetAsset.name,
|
||||||
subfolder: metadata?.subfolder || '',
|
subfolder: metadata?.subfolder || '',
|
||||||
type: isResultItemType(assetType) ? assetType : undefined
|
type: isResultItemType(assetType) ? assetType : undefined
|
||||||
},
|
},
|
||||||
@@ -300,12 +302,12 @@ export function useMediaAssetActions() {
|
|||||||
* Open the workflow from this asset in a new tab
|
* Open the workflow from this asset in a new tab
|
||||||
* Uses shared workflow extraction and action service
|
* Uses shared workflow extraction and action service
|
||||||
*/
|
*/
|
||||||
const openWorkflow = async () => {
|
const openWorkflow = async (asset?: AssetItem) => {
|
||||||
const asset = mediaContext?.asset.value
|
const targetAsset = asset ?? mediaContext?.asset.value
|
||||||
if (!asset) return
|
if (!targetAsset) return
|
||||||
|
|
||||||
// Extract workflow using shared utility
|
// Extract workflow using shared utility
|
||||||
const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
const { workflow, filename } = await extractWorkflowFromAsset(targetAsset)
|
||||||
|
|
||||||
// Use shared action service
|
// Use shared action service
|
||||||
const result = await workflowActions.openWorkflowAction(workflow, filename)
|
const result = await workflowActions.openWorkflowAction(workflow, filename)
|
||||||
@@ -331,12 +333,12 @@ export function useMediaAssetActions() {
|
|||||||
* Export the workflow from this asset as a JSON file
|
* Export the workflow from this asset as a JSON file
|
||||||
* Uses shared workflow extraction and action service
|
* Uses shared workflow extraction and action service
|
||||||
*/
|
*/
|
||||||
const exportWorkflow = async () => {
|
const exportWorkflow = async (asset?: AssetItem) => {
|
||||||
const asset = mediaContext?.asset.value
|
const targetAsset = asset ?? mediaContext?.asset.value
|
||||||
if (!asset) return
|
if (!targetAsset) return
|
||||||
|
|
||||||
// Extract workflow using shared utility
|
// Extract workflow using shared utility
|
||||||
const { workflow, filename } = await extractWorkflowFromAsset(asset)
|
const { workflow, filename } = await extractWorkflowFromAsset(targetAsset)
|
||||||
|
|
||||||
// Use shared action service
|
// Use shared action service
|
||||||
const result = await workflowActions.exportWorkflowAction(
|
const result = await workflowActions.exportWorkflowAction(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cn } from '@comfyorg/tailwind-utils'
|
import { cn } from '@comfyorg/tailwind-utils'
|
||||||
import type { HTMLAttributes } from 'vue'
|
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 ButtonType = 'primary' | 'secondary' | 'transparent' | 'accent'
|
||||||
type ButtonBorder = boolean
|
type ButtonBorder = boolean
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ export interface BaseButtonProps {
|
|||||||
export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
|
export const getButtonSizeClasses = (size: ButtonSize = 'md') => {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
'fit-content': '',
|
'fit-content': '',
|
||||||
|
'full-width': 'w-full',
|
||||||
sm: 'px-2 py-1.5 text-xs',
|
sm: 'px-2 py-1.5 text-xs',
|
||||||
md: 'px-4 py-2 text-sm'
|
md: 'px-4 py-2 text-sm'
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ export const getBorderButtonTypeClasses = (type: ButtonType = 'primary') => {
|
|||||||
export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => {
|
export const getIconButtonSizeClasses = (size: ButtonSize = 'md') => {
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
'fit-content': 'w-auto h-auto',
|
'fit-content': 'w-auto h-auto',
|
||||||
|
'full-width': 'w-full h-auto',
|
||||||
sm: 'size-8 text-xs !rounded-md',
|
sm: 'size-8 text-xs !rounded-md',
|
||||||
md: 'size-10 text-sm'
|
md: 'size-10 text-sm'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user