mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-04 23:20:07 +00:00
Add output explorer
This commit is contained in:
204
src/components/common/ListExplorer.vue
Normal file
204
src/components/common/ListExplorer.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="h-full overflow-hidden pb-1">
|
||||
<div class="flex item-center">
|
||||
<div
|
||||
v-for="item in columns"
|
||||
:key="item.key"
|
||||
class="flex justify-between items-center px-2 overflow-hidden hover:bg-blue-600/40 cursor-pointer"
|
||||
:style="{ flexBasis: `${item.width}px`, height: '36px' }"
|
||||
@click="changeSort(item)"
|
||||
>
|
||||
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ $t(`g.${item.key}`) }}
|
||||
</span>
|
||||
<span
|
||||
v-show="item.key === sortField"
|
||||
:class="[
|
||||
'text-xs pi',
|
||||
sortDirection === 'asc' ? 'pi-angle-up' : 'pi-angle-down'
|
||||
]"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<div :style="{ height: 'calc(100% - 36px)' }">
|
||||
<VirtualScroll :items="sortedItems" :item-size="36">
|
||||
<template #item="{ item: row }">
|
||||
<div
|
||||
class="h-full py-px"
|
||||
@click="emit('itemClick', row, $event)"
|
||||
@dblclick="emit('itemDbClick', row, $event)"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center h-full hover:bg-blue-600/40',
|
||||
selectedKeys.includes(row.key) ? 'bg-blue-700/40' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in columns"
|
||||
:key="item.key"
|
||||
class="flex items-center px-2 py-1 overflow-hidden select-none"
|
||||
:style="{ flexBasis: `${item.width}px`, textAlign: item.align }"
|
||||
>
|
||||
<span v-if="index === 0" :class="['mr-2 pi', row.icon]"></span>
|
||||
<span class="whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ row._display[item.key] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VirtualScroll>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
import VirtualScroll from './VirtualScroll.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
type Item = {
|
||||
key: string
|
||||
name: string
|
||||
type: string
|
||||
modifyTime: number
|
||||
size: number
|
||||
}
|
||||
|
||||
type RecordString<T> = {
|
||||
[key in keyof T]: T[key]
|
||||
}
|
||||
|
||||
type ResolvedItem<T> = T & {
|
||||
icon: string
|
||||
_display: RecordString<T>
|
||||
}
|
||||
|
||||
interface Column {
|
||||
key: string
|
||||
width: number
|
||||
align?: 'left' | 'right'
|
||||
defaultSort?: SortDirection
|
||||
renderText: (val: any, row: Item) => string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: Item[]
|
||||
}>()
|
||||
|
||||
const selectedKeys = defineModel<string[]>({ default: [] })
|
||||
|
||||
const emit = defineEmits<{
|
||||
itemClick: [Item, MouseEvent]
|
||||
itemDbClick: [Item, MouseEvent]
|
||||
}>()
|
||||
|
||||
const columns = ref<Column[]>([
|
||||
{
|
||||
key: 'name',
|
||||
width: 300,
|
||||
renderText: (val) => val
|
||||
},
|
||||
{
|
||||
key: 'modifyTime',
|
||||
width: 200,
|
||||
defaultSort: 'desc',
|
||||
renderText: (val) =>
|
||||
new Date(val).toLocaleDateString() +
|
||||
' ' +
|
||||
new Date(val).toLocaleTimeString()
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
width: 100,
|
||||
renderText: (val) => t(`g.${val}`)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
width: 120,
|
||||
defaultSort: 'desc',
|
||||
align: 'right',
|
||||
renderText: (val, item) => (item.type === 'folder' ? '' : formatSize(val))
|
||||
}
|
||||
])
|
||||
|
||||
provide('listExplorerColumns', columns)
|
||||
|
||||
const sortDirection = ref<SortDirection>('asc')
|
||||
const sortField = ref('name')
|
||||
|
||||
const iconMapLegacy = (icon: string) => {
|
||||
const prefix = 'pi-'
|
||||
const legacy = {
|
||||
audio: 'headphones'
|
||||
}
|
||||
return prefix + (legacy[icon] || icon)
|
||||
}
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
const columnRenderText = columns.value.reduce((acc, column) => {
|
||||
acc[column.key] = column.renderText
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return props.items.map((item) => {
|
||||
const display = Object.entries(item).reduce((acc, [key, value]) => {
|
||||
acc[key] = columnRenderText[key]?.(value, item) ?? value
|
||||
return acc
|
||||
}, {} as RecordString<Item>)
|
||||
return { ...item, icon: iconMapLegacy(item.type), _display: display }
|
||||
})
|
||||
})
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
const folderItems: ResolvedItem<Item>[] = []
|
||||
const fileItems: ResolvedItem<Item>[] = []
|
||||
|
||||
for (const item of renderedItems.value) {
|
||||
if (item.type === 'folder') {
|
||||
folderItems.push(item)
|
||||
} else {
|
||||
fileItems.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
|
||||
const sorting = (a: ResolvedItem<Item>, b: ResolvedItem<Item>) => {
|
||||
const aValue = a[sortField.value]
|
||||
const bValue = b[sortField.value]
|
||||
|
||||
const result =
|
||||
typeof aValue === 'string'
|
||||
? aValue.localeCompare(bValue)
|
||||
: aValue - bValue
|
||||
|
||||
return result * direction
|
||||
}
|
||||
|
||||
folderItems.sort(sorting)
|
||||
fileItems.sort(sorting)
|
||||
|
||||
const folderFirstField = ['modifyTime', 'type']
|
||||
return direction > 0 || folderFirstField.includes(sortField.value)
|
||||
? [...folderItems, ...fileItems]
|
||||
: [...fileItems, ...folderItems]
|
||||
})
|
||||
|
||||
const changeSort = (column: Column) => {
|
||||
if (column.key === sortField.value) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = column.key
|
||||
sortDirection.value = column.defaultSort ?? 'asc'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
85
src/components/common/VirtualScroll.vue
Normal file
85
src/components/common/VirtualScroll.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div ref="container" class="scroll-container">
|
||||
<div :style="{ height: `${state.start * itemSize}px` }"></div>
|
||||
<div :style="contentStyle">
|
||||
<div
|
||||
v-for="item in renderedItems"
|
||||
:key="item.key"
|
||||
:style="{ height: `${itemSize}px` }"
|
||||
data-virtual-item
|
||||
>
|
||||
<slot name="item" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:style="{ height: `${(items.length - state.end) * itemSize}px` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { useElementSize, useScroll } from '@vueuse/core'
|
||||
import { clamp } from 'lodash'
|
||||
import { type CSSProperties, computed, ref } from 'vue'
|
||||
|
||||
type Item = T & { key: string }
|
||||
|
||||
const props = defineProps<{
|
||||
items: Item[]
|
||||
itemSize: number
|
||||
contentStyle?: Partial<CSSProperties>
|
||||
scrollThrottle?: number
|
||||
}>()
|
||||
|
||||
const { scrollThrottle = 64 } = props
|
||||
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const { height } = useElementSize(container)
|
||||
const { y: scrollY } = useScroll(container, {
|
||||
throttle: scrollThrottle,
|
||||
eventListenerOptions: { passive: true }
|
||||
})
|
||||
|
||||
const viewRows = computed(() => Math.ceil(height.value / props.itemSize))
|
||||
const offsetRows = computed(() => Math.floor(scrollY.value / props.itemSize))
|
||||
|
||||
const state = computed(() => {
|
||||
const bufferRows = viewRows.value
|
||||
|
||||
const fromRow = offsetRows.value - bufferRows
|
||||
const toRow = offsetRows.value + bufferRows + viewRows.value
|
||||
|
||||
return {
|
||||
start: clamp(fromRow, 0, props.items.length),
|
||||
end: clamp(toRow, fromRow, props.items.length)
|
||||
}
|
||||
})
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
return props.items.slice(state.value.start, state.value.end)
|
||||
})
|
||||
|
||||
const reset = () => {}
|
||||
|
||||
defineExpose({
|
||||
reset
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scroll-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
239
src/components/sidebar/tabs/OutputExplorerSidebarTab.vue
Normal file
239
src/components/sidebar/tabs/OutputExplorerSidebarTab.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<SidebarTabTemplate :title="$t('sideToolbar.outputExplorer')">
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
icon="pi pi-arrow-up"
|
||||
@click="handleBackParentFolder"
|
||||
severity="secondary"
|
||||
text
|
||||
v-tooltip.bottom="$t('g.back')"
|
||||
:disabled="!currentFolder"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
@click="loadFolderItems"
|
||||
severity="secondary"
|
||||
text
|
||||
v-tooltip.bottom="$t('g.refresh')"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
class="model-lib-search-box p-2 2xl:p-4"
|
||||
v-model:modelValue="searchQuery"
|
||||
:placeholder="$t('g.searchIn', ['output'])"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="h-full overflow-hidden">
|
||||
<ListExplorer
|
||||
class="flex-1"
|
||||
:style="{ height: 'calc(100% - 36px)' }"
|
||||
:items="renderedItems"
|
||||
@item-db-click="handleDbClickItem"
|
||||
></ListExplorer>
|
||||
<div class="h-8 flex items-center px-2 text-sm">
|
||||
<div class="flex gap-1">
|
||||
{{ $t('g.itemsCount', [itemsCount]) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="previewVisible"
|
||||
class="fixed left-0 top-0 z-[5000] flex h-full w-full items-center justify-center bg-black/70"
|
||||
>
|
||||
<div class="absolute right-3 top-3">
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click="closePreview"
|
||||
></Button>
|
||||
</div>
|
||||
<div class="h-full w-full select-none p-10">
|
||||
<img
|
||||
v-if="currentItem?.type === 'image'"
|
||||
class="h-full w-full object-contain"
|
||||
:src="`/output/${folderPrefix}${currentItem?.name}`"
|
||||
alt="preview"
|
||||
/>
|
||||
<video
|
||||
v-if="currentItem?.type === 'video'"
|
||||
class="h-full w-full object-contain"
|
||||
:src="`/output/${folderPrefix}${currentItem?.name}`"
|
||||
controls
|
||||
></video>
|
||||
<div
|
||||
v-if="currentItem?.type === 'audio'"
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="px-8 pt-6 rounded-full"
|
||||
:style="{ background: 'var(--p-button-secondary-background)' }"
|
||||
>
|
||||
<div class="text-center mb-2">{{ currentItem?.name }}</div>
|
||||
<audio
|
||||
:src="`/output/${folderPrefix}${currentItem?.name}`"
|
||||
controls
|
||||
></audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute left-2 top-1/2">
|
||||
<Button
|
||||
icon="pi pi-angle-left"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click="openPreviousItem"
|
||||
></Button>
|
||||
</div>
|
||||
<div class="absolute right-2 top-1/2">
|
||||
<Button
|
||||
icon="pi pi-angle-right"
|
||||
severity="secondary"
|
||||
rounded
|
||||
@click="openNextItem"
|
||||
></Button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import ListExplorer from '@/components/common/ListExplorer.vue'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
interface OutputItem {
|
||||
key: string
|
||||
name: string
|
||||
type: 'folder' | 'image' | 'video' | 'audio'
|
||||
size: number
|
||||
createTime: number
|
||||
modifyTime: number
|
||||
}
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const folderPaths = ref<OutputItem[]>([])
|
||||
const currentFolder = computed(() => {
|
||||
return folderPaths.value.map((item) => item.name).join('/')
|
||||
})
|
||||
const currentFolderItems = ref<OutputItem[]>([])
|
||||
const folderPrefix = computed(() => {
|
||||
return currentFolder.value ? `${currentFolder.value}/` : ''
|
||||
})
|
||||
|
||||
const filterContent = ref('')
|
||||
|
||||
const itemsCount = computed(() => {
|
||||
return currentFolderItems.value.length.toLocaleString()
|
||||
})
|
||||
|
||||
const renderedItems = computed(() => {
|
||||
const query = filterContent.value
|
||||
|
||||
if (!query) {
|
||||
return currentFolderItems.value
|
||||
}
|
||||
|
||||
return currentFolderItems.value.filter((item) => {
|
||||
return item.name.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
})
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
filterContent.value = query
|
||||
}
|
||||
|
||||
const previewVisible = ref(false)
|
||||
const currentItem = ref<OutputItem | null>(null)
|
||||
const currentItemIndex = ref(-1)
|
||||
const currentTypeItems = ref<OutputItem[]>([])
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
currentItem.value = null
|
||||
}
|
||||
|
||||
const openPreviousItem = () => {
|
||||
currentItemIndex.value--
|
||||
if (currentItemIndex.value < 0) {
|
||||
currentItemIndex.value = currentTypeItems.value.length - 1
|
||||
}
|
||||
const item = currentTypeItems.value[currentItemIndex.value]
|
||||
currentItem.value = item
|
||||
}
|
||||
|
||||
const openNextItem = () => {
|
||||
currentItemIndex.value++
|
||||
if (currentItemIndex.value > currentTypeItems.value.length - 1) {
|
||||
currentItemIndex.value = 0
|
||||
}
|
||||
const item = currentTypeItems.value[currentItemIndex.value]
|
||||
currentItem.value = item
|
||||
}
|
||||
|
||||
const openItemPreview = (item: OutputItem) => {
|
||||
previewVisible.value = true
|
||||
currentItem.value = item
|
||||
|
||||
const itemType = item.type
|
||||
currentTypeItems.value = currentFolderItems.value.filter(
|
||||
(o) => o.type === itemType
|
||||
)
|
||||
|
||||
currentItemIndex.value = currentTypeItems.value.indexOf(item)
|
||||
}
|
||||
|
||||
const loadFolderItems = async () => {
|
||||
const resData = await api.getOutputFolderItems(currentFolder.value)
|
||||
currentFolderItems.value = resData.map((item: any) => ({
|
||||
key: item.name,
|
||||
...item
|
||||
}))
|
||||
}
|
||||
|
||||
const openFolder = async (item: OutputItem, pathIndex: number) => {
|
||||
folderPaths.value.splice(pathIndex)
|
||||
folderPaths.value.push(item)
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
const handleBackParentFolder = async () => {
|
||||
folderPaths.value.pop()
|
||||
await loadFolderItems()
|
||||
}
|
||||
|
||||
const handleDbClickItem = (item: OutputItem, event: MouseEvent) => {
|
||||
if (item.type === 'folder') {
|
||||
openFolder(item, folderPaths.value.length)
|
||||
} else {
|
||||
openItemPreview(item)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFolderItems()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.pi-fake-spacer) {
|
||||
height: 1px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
:deep(audio::-webkit-media-controls-enclosure) {
|
||||
background-color: inherit;
|
||||
}
|
||||
</style>
|
||||
18
src/composables/sidebarTabs/outputExplorerSidebarTab.ts
Normal file
18
src/composables/sidebarTabs/outputExplorerSidebarTab.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { markRaw } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import OutputExplorerSidebarTab from '@/components/sidebar/tabs/OutputExplorerSidebarTab.vue'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useOutputExplorerSidebarTab = (): SidebarTabExtension => {
|
||||
const { t } = useI18n()
|
||||
|
||||
return {
|
||||
id: 'output-explorer',
|
||||
icon: 'pi pi-image',
|
||||
title: t('sideToolbar.outputExplorer'),
|
||||
tooltip: t('sideToolbar.outputExplorer'),
|
||||
component: markRaw(OutputExplorerSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,12 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.model-library'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'e'
|
||||
},
|
||||
commandId: 'Workspace.ToggleSidebarTab.output-explorer'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 's',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -699,6 +699,19 @@ export class ComfyApi extends EventTarget {
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of output folder items (eg ['output', 'output/images', 'output/videos', ...])
|
||||
* @param {string} folder The folder to list items from, such as 'output'
|
||||
* @returns The list of output folder items within the specified folder
|
||||
*/
|
||||
async getOutputFolderItems(folder: string) {
|
||||
const res = await this.fetchApi(`/output${folder}`)
|
||||
if (res.status === 404) {
|
||||
return []
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the metadata for a model
|
||||
* @param {string} folder The folder containing the model
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { useOutputExplorerSidebarTab } from '@/composables/sidebarTabs/outputExplorerSidebarTab'
|
||||
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
|
||||
import { useWorkflowsSidebarTab } from '@/composables/sidebarTabs/useWorkflowsSidebarTab'
|
||||
import { t, te } from '@/i18n'
|
||||
@@ -92,6 +93,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
registerSidebarTab(useNodeLibrarySidebarTab())
|
||||
registerSidebarTab(useModelLibrarySidebarTab())
|
||||
registerSidebarTab(useWorkflowsSidebarTab())
|
||||
<<<<<<< HEAD
|
||||
|
||||
const menuStore = useMenuItemStore()
|
||||
|
||||
@@ -111,6 +113,9 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
|
||||
['View'],
|
||||
['Comfy.Canvas.ZoomIn', 'Comfy.Canvas.ZoomOut', 'Comfy.Canvas.FitView']
|
||||
)
|
||||
=======
|
||||
registerSidebarTab(useOutputExplorerSidebarTab())
|
||||
>>>>>>> e9f3e42a (Add output explorer)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user