Add output explorer

This commit is contained in:
hayden
2025-01-24 16:40:52 +08:00
committed by snomiao
parent 5a35562d3d
commit cf1ff71651
8 changed files with 615 additions and 1034 deletions

View 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>

View 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>

View 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>

View 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'
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 {