mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-28 18:54:09 +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)"
|
||||
: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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div class="h-px bg-border-default"></div>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
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 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(
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user