mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
[QPOv2] Add ... context menu to list view (#7745)
Add ... context menu to list view This is the same ... context menu used in the grid view, now moved up to the tab scope so it can be shared between views. Part of the QPO v2 iteration, figma design can be found [here](https://www.figma.com/design/LVilZgHGk5RwWOkVN6yCEK/Queue-Progress-Modal?node-id=3330-37286&m=dev). This will be implemented in a series of stacked PRs that can be reviewed and merged individually. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7745-QPOv2-Add-context-menu-to-list-view-2d26d73d365081329a11ce97472bbf87) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -74,8 +74,22 @@
|
||||
"
|
||||
:primary-text="getAssetPrimaryText(item.asset)"
|
||||
:secondary-text="getAssetSecondaryText(item.asset)"
|
||||
@mouseenter="onAssetEnter(item.asset.id)"
|
||||
@mouseleave="onAssetLeave(item.asset.id)"
|
||||
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
|
||||
@click.stop="emit('select-asset', item.asset)"
|
||||
/>
|
||||
>
|
||||
<template v-if="hoveredAssetId === item.asset.id" #actions>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
:aria-label="t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="emit('context-menu', $event, item.asset)"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</AssetsListItem>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
</div>
|
||||
@@ -111,12 +125,14 @@ const { assets, isSelected } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-asset', asset: AssetItem): void
|
||||
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||
(e: 'approach-end'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { jobItems } = useJobList()
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const hoveredAssetId = ref<string | null>(null)
|
||||
|
||||
type AssetListItem = { key: string; asset: AssetItem }
|
||||
|
||||
@@ -192,6 +208,16 @@ function onJobLeave(jobId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function onAssetEnter(assetId: string) {
|
||||
hoveredAssetId.value = assetId
|
||||
}
|
||||
|
||||
function onAssetLeave(assetId: string) {
|
||||
if (hoveredAssetId.value === assetId) {
|
||||
hoveredAssetId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
const classes = []
|
||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
:assets="displayAssets"
|
||||
:is-selected="isSelected"
|
||||
@select-asset="handleAssetSelect"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@approach-end="handleApproachEnd"
|
||||
/>
|
||||
<VirtualGrid
|
||||
@@ -120,17 +121,10 @@
|
||||
:selected="isSelected(item.id)"
|
||||
:show-output-count="shouldShowOutputCount(item)"
|
||||
:output-count="getOutputCount(item)"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:open-context-menu-id="openContextMenuId"
|
||||
:selected-assets="getSelectedAssets(displayAssets)"
|
||||
:has-selection="hasSelection"
|
||||
@click="handleAssetSelect(item)"
|
||||
@context-menu="handleAssetContextMenu"
|
||||
@zoom="handleZoomClick(item)"
|
||||
@output-count-click="enterFolderView(item)"
|
||||
@asset-deleted="refreshAssets"
|
||||
@context-menu-opened="openContextMenuId = item.id"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
/>
|
||||
</template>
|
||||
</VirtualGrid>
|
||||
@@ -196,6 +190,21 @@
|
||||
v-model:active-index="galleryActiveIndex"
|
||||
:all-gallery-items="galleryItems"
|
||||
/>
|
||||
<MediaAssetContextMenu
|
||||
v-if="contextMenuAsset"
|
||||
ref="contextMenuRef"
|
||||
:asset="contextMenuAsset"
|
||||
:asset-type="contextMenuAssetType"
|
||||
:file-kind="contextMenuFileKind"
|
||||
:show-delete-button="shouldShowDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="isBulkMode"
|
||||
@zoom="handleZoomClick(contextMenuAsset)"
|
||||
@hide="handleContextMenuHide"
|
||||
@asset-deleted="refreshAssets"
|
||||
@bulk-download="handleBulkDownload"
|
||||
@bulk-delete="handleBulkDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -203,7 +212,7 @@ import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
@@ -216,13 +225,16 @@ import Tab from '@/components/tab/Tab.vue'
|
||||
import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -248,8 +260,8 @@ const isListView = computed(
|
||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||
)
|
||||
|
||||
// Track which asset's context menu is open (for single-instance context menu management)
|
||||
const openContextMenuId = ref<string | null>(null)
|
||||
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
const contextMenuAsset = ref<AssetItem | null>(null)
|
||||
|
||||
// Determine if delete button should be shown
|
||||
// Hide delete button when in input tab and not in cloud (OSS mode - files are from local folders)
|
||||
@@ -258,6 +270,14 @@ const shouldShowDeleteButton = computed(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
const contextMenuAssetType = computed(() =>
|
||||
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
|
||||
)
|
||||
|
||||
const contextMenuFileKind = computed<MediaKind>(() =>
|
||||
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
|
||||
)
|
||||
|
||||
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||
return false
|
||||
@@ -327,8 +347,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
return getTotalOutputCount(selectedAssets)
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
@@ -359,6 +378,12 @@ const displayAssets = computed(() => {
|
||||
return filteredAssets.value
|
||||
})
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
const showLoadingState = computed(
|
||||
() =>
|
||||
loading.value &&
|
||||
@@ -444,6 +469,17 @@ const handleAssetSelect = (asset: AssetItem) => {
|
||||
handleAssetClick(asset, index, displayAssets.value)
|
||||
}
|
||||
|
||||
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
|
||||
contextMenuAsset.value = asset
|
||||
void nextTick(() => {
|
||||
contextMenuRef.value?.show(event)
|
||||
})
|
||||
}
|
||||
|
||||
function handleContextMenuHide() {
|
||||
contextMenuAsset.value = null
|
||||
}
|
||||
|
||||
const handleZoomClick = (asset: AssetItem) => {
|
||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||
|
||||
@@ -552,14 +588,12 @@ const copyJobId = async () => {
|
||||
}
|
||||
|
||||
const handleDownloadSelected = () => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
downloadMultipleAssets(selectedAssets)
|
||||
downloadMultipleAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
||||
await deleteMultipleAssets(selectedAssets)
|
||||
await deleteMultipleAssets(selectedAssets.value)
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@
|
||||
"
|
||||
:data-selected="selected"
|
||||
@click.stop="$emit('click')"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
@contextmenu.prevent.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
>
|
||||
<!-- Top Area: Media Preview -->
|
||||
<div class="relative aspect-square overflow-hidden p-0">
|
||||
@@ -64,7 +66,9 @@
|
||||
variant="overlay-white"
|
||||
size="icon"
|
||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
||||
@click.stop="handleContextMenu"
|
||||
@click.stop="
|
||||
asset ? emit('context-menu', $event, asset) : undefined
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--ellipsis] size-4" />
|
||||
</Button>
|
||||
@@ -119,25 +123,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaAssetContextMenu
|
||||
v-if="asset"
|
||||
ref="contextMenu"
|
||||
:asset="asset"
|
||||
:asset-type="assetType"
|
||||
:file-kind="fileKind"
|
||||
:show-delete-button="showDeleteButton"
|
||||
:selected-assets="selectedAssets"
|
||||
:is-bulk-mode="hasSelection && (selectedAssets?.length ?? 0) > 1"
|
||||
@zoom="handleZoomClick"
|
||||
@asset-deleted="emit('asset-deleted')"
|
||||
@bulk-download="emit('bulk-download', $event)"
|
||||
@bulk-delete="emit('bulk-delete', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover, whenever } from '@vueuse/core'
|
||||
import { useElementHover } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||
|
||||
import IconGroup from '@/components/button/IconGroup.vue'
|
||||
@@ -155,7 +144,6 @@ import { useMediaAssetActions } from '../composables/useMediaAssetActions'
|
||||
import type { AssetItem } from '../schemas/assetSchema'
|
||||
import type { MediaKind } from '../schemas/mediaAssetSchema'
|
||||
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
|
||||
import MediaAssetContextMenu from './MediaAssetContextMenu.vue'
|
||||
import MediaTitle from './MediaTitle.vue'
|
||||
|
||||
const mediaComponents = {
|
||||
@@ -171,40 +159,22 @@ function getTopComponent(kind: MediaKind) {
|
||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||
}
|
||||
|
||||
const {
|
||||
asset,
|
||||
loading,
|
||||
selected,
|
||||
showOutputCount,
|
||||
outputCount,
|
||||
showDeleteButton,
|
||||
openContextMenuId,
|
||||
selectedAssets,
|
||||
hasSelection
|
||||
} = defineProps<{
|
||||
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||
asset?: AssetItem
|
||||
loading?: boolean
|
||||
selected?: boolean
|
||||
showOutputCount?: boolean
|
||||
outputCount?: number
|
||||
showDeleteButton?: boolean
|
||||
openContextMenuId?: string | null
|
||||
selectedAssets?: AssetItem[]
|
||||
hasSelection?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
zoom: [asset: AssetItem]
|
||||
'output-count-click': []
|
||||
'asset-deleted': []
|
||||
'context-menu-opened': []
|
||||
'bulk-download': [assets: AssetItem[]]
|
||||
'bulk-delete': [assets: AssetItem[]]
|
||||
'context-menu': [event: MouseEvent, asset: AssetItem]
|
||||
}>()
|
||||
|
||||
const cardContainerRef = ref<HTMLElement>()
|
||||
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||
|
||||
const isVideoPlaying = ref(false)
|
||||
const showVideoControls = ref(false)
|
||||
@@ -299,17 +269,4 @@ const handleImageLoaded = (width: number, height: number) => {
|
||||
const handleOutputCountClick = () => {
|
||||
emit('output-count-click')
|
||||
}
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
emit('context-menu-opened')
|
||||
contextMenu.value?.show(event)
|
||||
}
|
||||
|
||||
// Close this context menu when another opens
|
||||
whenever(
|
||||
() => openContextMenuId && openContextMenuId !== asset?.id,
|
||||
() => {
|
||||
contextMenu.value?.hide()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
)
|
||||
}
|
||||
}"
|
||||
@hide="emit('hide')"
|
||||
>
|
||||
<template #item="{ item, props }">
|
||||
<Button
|
||||
@@ -65,6 +66,7 @@ const emit = defineEmits<{
|
||||
'asset-deleted': []
|
||||
'bulk-download': [assets: AssetItem[]]
|
||||
'bulk-delete': [assets: AssetItem[]]
|
||||
hide: []
|
||||
}>()
|
||||
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||
|
||||
Reference in New Issue
Block a user