mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +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)"
|
:primary-text="getAssetPrimaryText(item.asset)"
|
||||||
:secondary-text="getAssetSecondaryText(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)"
|
@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>
|
</template>
|
||||||
</VirtualGrid>
|
</VirtualGrid>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,12 +125,14 @@ const { assets, isSelected } = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'select-asset', asset: AssetItem): void
|
(e: 'select-asset', asset: AssetItem): void
|
||||||
|
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
|
||||||
(e: 'approach-end'): void
|
(e: 'approach-end'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { jobItems } = useJobList()
|
const { jobItems } = useJobList()
|
||||||
const hoveredJobId = ref<string | null>(null)
|
const hoveredJobId = ref<string | null>(null)
|
||||||
|
const hoveredAssetId = ref<string | null>(null)
|
||||||
|
|
||||||
type AssetListItem = { key: string; asset: AssetItem }
|
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 {
|
function getJobIconClass(job: JobListItem): string | undefined {
|
||||||
const classes = []
|
const classes = []
|
||||||
const iconName = job.iconName ?? iconForJobState(job.state)
|
const iconName = job.iconName ?? iconForJobState(job.state)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
:assets="displayAssets"
|
:assets="displayAssets"
|
||||||
:is-selected="isSelected"
|
:is-selected="isSelected"
|
||||||
@select-asset="handleAssetSelect"
|
@select-asset="handleAssetSelect"
|
||||||
|
@context-menu="handleAssetContextMenu"
|
||||||
@approach-end="handleApproachEnd"
|
@approach-end="handleApproachEnd"
|
||||||
/>
|
/>
|
||||||
<VirtualGrid
|
<VirtualGrid
|
||||||
@@ -120,17 +121,10 @@
|
|||||||
:selected="isSelected(item.id)"
|
:selected="isSelected(item.id)"
|
||||||
:show-output-count="shouldShowOutputCount(item)"
|
:show-output-count="shouldShowOutputCount(item)"
|
||||||
:output-count="getOutputCount(item)"
|
:output-count="getOutputCount(item)"
|
||||||
:show-delete-button="shouldShowDeleteButton"
|
|
||||||
:open-context-menu-id="openContextMenuId"
|
|
||||||
:selected-assets="getSelectedAssets(displayAssets)"
|
|
||||||
:has-selection="hasSelection"
|
|
||||||
@click="handleAssetSelect(item)"
|
@click="handleAssetSelect(item)"
|
||||||
|
@context-menu="handleAssetContextMenu"
|
||||||
@zoom="handleZoomClick(item)"
|
@zoom="handleZoomClick(item)"
|
||||||
@output-count-click="enterFolderView(item)"
|
@output-count-click="enterFolderView(item)"
|
||||||
@asset-deleted="refreshAssets"
|
|
||||||
@context-menu-opened="openContextMenuId = item.id"
|
|
||||||
@bulk-download="handleBulkDownload"
|
|
||||||
@bulk-delete="handleBulkDelete"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</VirtualGrid>
|
</VirtualGrid>
|
||||||
@@ -196,6 +190,21 @@
|
|||||||
v-model:active-index="galleryActiveIndex"
|
v-model:active-index="galleryActiveIndex"
|
||||||
:all-gallery-items="galleryItems"
|
: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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -203,7 +212,7 @@ import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
|
|||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
import ProgressSpinner from 'primevue/progressspinner'
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
import { useToast } from 'primevue/usetoast'
|
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 { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
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 TabList from '@/components/tab/TabList.vue'
|
||||||
import Button from '@/components/ui/button/Button.vue'
|
import Button from '@/components/ui/button/Button.vue'
|
||||||
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.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 MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||||
|
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||||
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
|
||||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||||
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
|
||||||
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
|
||||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||||
|
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
|
||||||
import { isCloud } from '@/platform/distribution/types'
|
import { isCloud } from '@/platform/distribution/types'
|
||||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
@@ -248,8 +260,8 @@ const isListView = computed(
|
|||||||
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
|
||||||
)
|
)
|
||||||
|
|
||||||
// Track which asset's context menu is open (for single-instance context menu management)
|
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
||||||
const openContextMenuId = ref<string | null>(null)
|
const contextMenuAsset = ref<AssetItem | 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)
|
||||||
@@ -258,6 +270,14 @@ const shouldShowDeleteButton = computed(() => {
|
|||||||
return true
|
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 => {
|
const shouldShowOutputCount = (item: AssetItem): boolean => {
|
||||||
if (activeTab.value !== 'output' || isInFolderView.value) {
|
if (activeTab.value !== 'output' || isInFolderView.value) {
|
||||||
return false
|
return false
|
||||||
@@ -327,8 +347,7 @@ const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
|||||||
|
|
||||||
// Total output count for all selected assets
|
// Total output count for all selected assets
|
||||||
const totalOutputCount = computed(() => {
|
const totalOutputCount = computed(() => {
|
||||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
return getTotalOutputCount(selectedAssets.value)
|
||||||
return getTotalOutputCount(selectedAssets)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentAssets = computed(() =>
|
const currentAssets = computed(() =>
|
||||||
@@ -359,6 +378,12 @@ const displayAssets = computed(() => {
|
|||||||
return filteredAssets.value
|
return filteredAssets.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
|
||||||
|
|
||||||
|
const isBulkMode = computed(
|
||||||
|
() => hasSelection.value && selectedAssets.value.length > 1
|
||||||
|
)
|
||||||
|
|
||||||
const showLoadingState = computed(
|
const showLoadingState = computed(
|
||||||
() =>
|
() =>
|
||||||
loading.value &&
|
loading.value &&
|
||||||
@@ -444,6 +469,17 @@ const handleAssetSelect = (asset: AssetItem) => {
|
|||||||
handleAssetClick(asset, index, displayAssets.value)
|
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 handleZoomClick = (asset: AssetItem) => {
|
||||||
const mediaType = getMediaTypeFromFilename(asset.name)
|
const mediaType = getMediaTypeFromFilename(asset.name)
|
||||||
|
|
||||||
@@ -552,14 +588,12 @@ const copyJobId = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDownloadSelected = () => {
|
const handleDownloadSelected = () => {
|
||||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
downloadMultipleAssets(selectedAssets.value)
|
||||||
downloadMultipleAssets(selectedAssets)
|
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteSelected = async () => {
|
const handleDeleteSelected = async () => {
|
||||||
const selectedAssets = getSelectedAssets(displayAssets.value)
|
await deleteMultipleAssets(selectedAssets.value)
|
||||||
await deleteMultipleAssets(selectedAssets)
|
|
||||||
clearSelection()
|
clearSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
"
|
"
|
||||||
:data-selected="selected"
|
:data-selected="selected"
|
||||||
@click.stop="$emit('click')"
|
@click.stop="$emit('click')"
|
||||||
@contextmenu.prevent="handleContextMenu"
|
@contextmenu.prevent.stop="
|
||||||
|
asset ? emit('context-menu', $event, asset) : undefined
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<!-- Top Area: Media Preview -->
|
<!-- Top Area: Media Preview -->
|
||||||
<div class="relative aspect-square overflow-hidden p-0">
|
<div class="relative aspect-square overflow-hidden p-0">
|
||||||
@@ -64,7 +66,9 @@
|
|||||||
variant="overlay-white"
|
variant="overlay-white"
|
||||||
size="icon"
|
size="icon"
|
||||||
:aria-label="$t('mediaAsset.actions.moreOptions')"
|
: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" />
|
<i class="icon-[lucide--ellipsis] size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -119,25 +123,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useElementHover, whenever } from '@vueuse/core'
|
import { useElementHover } from '@vueuse/core'
|
||||||
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
|
||||||
|
|
||||||
import IconGroup from '@/components/button/IconGroup.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 { 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 MediaAssetContextMenu from './MediaAssetContextMenu.vue'
|
|
||||||
import MediaTitle from './MediaTitle.vue'
|
import MediaTitle from './MediaTitle.vue'
|
||||||
|
|
||||||
const mediaComponents = {
|
const mediaComponents = {
|
||||||
@@ -171,40 +159,22 @@ function getTopComponent(kind: MediaKind) {
|
|||||||
return mediaComponents.top[kind] || mediaComponents.top.image
|
return mediaComponents.top[kind] || mediaComponents.top.image
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
|
||||||
asset,
|
|
||||||
loading,
|
|
||||||
selected,
|
|
||||||
showOutputCount,
|
|
||||||
outputCount,
|
|
||||||
showDeleteButton,
|
|
||||||
openContextMenuId,
|
|
||||||
selectedAssets,
|
|
||||||
hasSelection
|
|
||||||
} = defineProps<{
|
|
||||||
asset?: AssetItem
|
asset?: AssetItem
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
showOutputCount?: boolean
|
showOutputCount?: boolean
|
||||||
outputCount?: number
|
outputCount?: number
|
||||||
showDeleteButton?: boolean
|
|
||||||
openContextMenuId?: string | null
|
|
||||||
selectedAssets?: AssetItem[]
|
|
||||||
hasSelection?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: []
|
click: []
|
||||||
zoom: [asset: AssetItem]
|
zoom: [asset: AssetItem]
|
||||||
'output-count-click': []
|
'output-count-click': []
|
||||||
'asset-deleted': []
|
'context-menu': [event: MouseEvent, asset: AssetItem]
|
||||||
'context-menu-opened': []
|
|
||||||
'bulk-download': [assets: AssetItem[]]
|
|
||||||
'bulk-delete': [assets: AssetItem[]]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const cardContainerRef = ref<HTMLElement>()
|
const cardContainerRef = ref<HTMLElement>()
|
||||||
const contextMenu = ref<InstanceType<typeof MediaAssetContextMenu>>()
|
|
||||||
|
|
||||||
const isVideoPlaying = ref(false)
|
const isVideoPlaying = ref(false)
|
||||||
const showVideoControls = ref(false)
|
const showVideoControls = ref(false)
|
||||||
@@ -299,17 +269,4 @@ const handleImageLoaded = (width: number, height: number) => {
|
|||||||
const handleOutputCountClick = () => {
|
const handleOutputCountClick = () => {
|
||||||
emit('output-count-click')
|
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>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
|
@hide="emit('hide')"
|
||||||
>
|
>
|
||||||
<template #item="{ item, props }">
|
<template #item="{ item, props }">
|
||||||
<Button
|
<Button
|
||||||
@@ -65,6 +66,7 @@ const emit = defineEmits<{
|
|||||||
'asset-deleted': []
|
'asset-deleted': []
|
||||||
'bulk-download': [assets: AssetItem[]]
|
'bulk-download': [assets: AssetItem[]]
|
||||||
'bulk-delete': [assets: AssetItem[]]
|
'bulk-delete': [assets: AssetItem[]]
|
||||||
|
hide: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
const contextMenu = ref<InstanceType<typeof ContextMenu>>()
|
||||||
|
|||||||
Reference in New Issue
Block a user