sd-webui-infinite-image-bro.../vue/src/components/FileItem.vue

428 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<script setup lang="ts">
import { FileOutlined, FolderOpenOutlined, EllipsisOutlined, HeartOutlined, HeartFilled } from '@/icon'
import { useGlobalStore } from '@/store/useGlobalStore'
import { fallbackImage, ok } from 'vue3-ts-util'
import type { FileNodeInfo } from '@/api/files'
import { isImageFile, isVideoFile } from '@/util'
import { toImageThumbnailUrl, toVideoCoverUrl, toRawFileUrl } from '@/util/file'
import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface'
import { computed, ref } from 'vue'
import ContextMenu from './ContextMenu.vue'
import ChangeIndicator from './ChangeIndicator.vue'
import { useTagStore } from '@/store/useTagStore'
import { CloseCircleOutlined, StarFilled, StarOutlined } from '@/icon'
import { Tag } from '@/api/db'
import { openVideoModal } from './functionalCallableComp'
import type { GenDiffInfo } from '@/api/files'
import { play } from '@/icon'
import { Top4MediaInfo } from '@/api'
import { watch } from 'vue'
import { debounce } from 'lodash-es'
import { closeImageFullscreenPreview } from '@/util/imagePreviewOperation'
const global = useGlobalStore()
const tagStore = useTagStore()
const props = withDefaults(
defineProps<{
file: FileNodeInfo,
idx: number
selected?: boolean
showMenuIdx?: number
cellWidth: number
fullScreenPreviewImageUrl?: string
enableRightClickMenu?: boolean,
enableCloseIcon?: boolean,
isSelectedMutilFiles?: boolean
genInfo?: string
enableChangeIndicator?: boolean
extraTags?: Tag[]
coverFiles?: Top4MediaInfo[]
getGenDiff?: (ownGenInfo: any, idx: any, increment: any, ownFile: FileNodeInfo) => GenDiffInfo,
getGenDiffWatchDep?: (idx: number) => any
}>(),
{
selected: false, enableRightClickMenu: true, enableCloseIcon: false
}
)
const genDiffToPrevious = ref<GenDiffInfo>()
const genDiffToNext = ref<GenDiffInfo>()
const calcGenInfoDiff = debounce(() => {
const { getGenDiff, file, idx } = props
if (!getGenDiff) return
genDiffToNext.value = getGenDiff(file.gen_info_obj, idx, 1, file)
genDiffToPrevious.value = getGenDiff(file.gen_info_obj, idx, -1, file)
}, 200 + 100 * Math.random())
watch(() => props.getGenDiffWatchDep?.(props.idx), () => {
calcGenInfoDiff()
}, { immediate: true, deep: true })
const emit = defineEmits<{
'update:showMenuIdx': [v: number],
'fileItemClick': [event: MouseEvent, file: FileNodeInfo, idx: number],
'dragstart': [event: DragEvent, idx: number],
'dragend': [event: DragEvent, idx: number],
'previewVisibleChange': [value: boolean, last: boolean],
'contextMenuClick': [e: MenuInfo, file: FileNodeInfo, idx: number],
'close-icon-click': [],
'tiktokView': [file: FileNodeInfo, idx: number]
}>()
const customTags = computed(() => {
return tagStore.tagMap.get(props.file.fullpath) ?? []
})
const imageSrc = computed(() => {
const r = global.gridThumbnailResolution
return global.enableThumbnail ? toImageThumbnailUrl(props.file, [r, r].join('x')) : toRawFileUrl(props.file)
})
const tags = computed(() => {
return (global.conf?.all_custom_tags ?? []).reduce((p, c) => {
return [...p, { ...c, selected: !!customTags.value.find((v) => v.id === c.id) }]
}, [] as (Tag & { selected: boolean })[])
})
const likeTag = computed(() => tags.value.find(v => v.type === 'custom' && v.name === 'like'))
const taggleLikeTag = () => {
ok(likeTag.value)
emit('contextMenuClick', { key: `toggle-tag-${likeTag.value.id}` } as MenuInfo, props.file, props.idx)
}
const minShowDetailWidth = 160
// 处理文件点击事件
const handleFileClick = (event: MouseEvent) => {
// 检查magic switch是否开启且是图片文件视频有自己的处理逻辑
if (global.magicSwitchTiktokView && props.file.type === 'file' && isImageFile(props.file.name)) {
// 直接触发TikTok视图
emit('tiktokView', props.file, props.idx)
setTimeout(() => {
closeImageFullscreenPreview()
}, 500);
} else {
// 正常触发文件点击事件
emit('fileItemClick', event, props.file, props.idx)
}
}
// 处理视频点击事件
const handleVideoClick = () => {
if (global.magicSwitchTiktokView) {
// 直接触发TikTok视图
emit('tiktokView', props.file, props.idx)
} else {
// 正常打开视频模态框
openVideoModal(
props.file,
(id) => emit('contextMenuClick', { key: `toggle-tag-${id}` } as any, props.file, props.idx),
() => emit('tiktokView', props.file, props.idx)
)
}
}
</script>
<template>
<a-dropdown :trigger="['contextmenu']" :visible="!global.longPressOpenContextMenu ? undefined : typeof idx === 'number' && showMenuIdx === idx
" @update:visible="(v: boolean) => typeof idx === 'number' && emit('update:showMenuIdx', v ? idx : -1)">
<li class="file file-item-trigger grid" :class="{
clickable: file.type === 'dir',
selected
}" :data-idx="idx" :key="file.name" draggable="true" @dragstart="emit('dragstart', $event, idx)"
@dragend="emit('dragend', $event, idx)" @click.capture="handleFileClick($event)">
<div>
<div class="close-icon" v-if="enableCloseIcon" @click="emit('close-icon-click')">
<close-circle-outlined />
</div>
<div class="more" v-if="enableRightClickMenu">
<a-dropdown>
<div class="float-btn-wrap">
<ellipsis-outlined />
</div>
<template #overlay>
<context-menu :file="file" :idx="idx" :selected-tag="customTags"
@context-menu-click="(e, f, i) => emit('contextMenuClick', e, f, i)"
:is-selected-mutil-files="isSelectedMutilFiles" />
</template>
</a-dropdown>
<a-dropdown v-if="file.type === 'file'">
<div class="float-btn-wrap" :class="{ 'like-selected': likeTag?.selected }" @click="taggleLikeTag">
<HeartFilled v-if="likeTag?.selected" />
<HeartOutlined v-else />
</div>
<template #overlay>
<a-menu @click="emit('contextMenuClick', $event, file, idx)" v-if="tags.length > 1">
<a-menu-item v-for="tag in tags" :key="`toggle-tag-${tag.id}`">{{ tag.name }}
<star-filled v-if="tag.selected" /><star-outlined v-else />
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- :key="fullScreenPreviewImageUrl ? undefined : file.fullpath"
这么复杂是因为再全屏查看时可能因为直接删除导致fullpath变化然后整个预览直接退出-->
<div :key="file.fullpath" :class="`idx-${idx} item-content`" v-if="isImageFile(file.name)">
<!-- change indicators -->
<ChangeIndicator v-if="enableChangeIndicator && genDiffToNext && genDiffToPrevious"
:gen-diff-to-next="genDiffToNext" :gen-diff-to-previous="genDiffToPrevious" />
<!-- change indicators END -->
<a-image :src="imageSrc" :fallback="fallbackImage" :preview="{
src: fullScreenPreviewImageUrl,
onVisibleChange: (v: boolean, lv: boolean) => emit('previewVisibleChange', v, lv)
}" />
<div class="tags-container" v-if="customTags && cellWidth > minShowDetailWidth">
<a-tag v-for="tag in extraTags ?? customTags" :key="tag.id" :color="tagStore.getColor(tag)">
{{ tag.name }}
</a-tag>
</div>
</div>
<div :class="`idx-${idx} item-content video`" :url="toVideoCoverUrl(file)"
:style="{ 'background-image': `url('${file.cover_url ?? toVideoCoverUrl(file)}')` }" v-else-if="isVideoFile(file.name)"
@click="handleVideoClick">
<div class="play-icon">
<img :src="play" style="width: 40px;height: 40px;">
</div>
<div class="tags-container" v-if="customTags && cellWidth > minShowDetailWidth">
<a-tag v-for="tag in customTags" :key="tag.id" :color="tagStore.getColor(tag)">
{{ tag.name }}
</a-tag>
</div>
</div>
<div v-else class="preview-icon-wrap">
<file-outlined class="icon center" v-if="file.type === 'file'" />
<div v-else-if="coverFiles?.length && cellWidth > 160" class="dir-cover-container">
<img class="dir-cover-item"
:src="item.media_type === 'image' ? toImageThumbnailUrl(item) : toVideoCoverUrl(item)"
v-for="item in coverFiles" :key="item.fullpath">
</div>
<folder-open-outlined class="icon center" v-else />
</div>
<div class="profile" v-if="cellWidth > minShowDetailWidth">
<div class="name line-clamp-1" :title="file.name">
{{ file.name }}
</div>
<div class="basic-info">
<div style="margin-right: 4px;">
{{ file.type }} {{ file.size }}
</div>
<div>
{{ file.date }}
</div>
</div>
</div>
</div>
</li>
<template #overlay>
<context-menu :file="file" :idx="idx" :selected-tag="customTags" v-if="enableRightClickMenu"
@context-menu-click="(e, f, i) => emit('contextMenuClick', e, f, i)"
:is-selected-mutil-files="isSelectedMutilFiles" />
</template>
</a-dropdown>
</template>
<style lang="scss" scoped>
.center {
display: flex;
justify-content: center;
align-items: center;
}
.item-content {
position: relative;
&.video {
background-color: var(--zp-border);
border-radius: 8px;
overflow: hidden;
width: v-bind('$props.cellWidth + "px"');
height: v-bind('$props.cellWidth + "px"');
background-size: cover;
background-position: center;
cursor: pointer;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 100%;
display: flex;
}
.tags-container {
position: absolute;
right: 8px;
bottom: 8px;
display: flex;
width: calc(100% - 16px);
flex-wrap: wrap-reverse;
flex-direction: row-reverse;
&>* {
margin: 0 0 4px 4px;
font-size: 14px;
line-height: 1.6;
}
}
}
.close-icon {
position: absolute;
top: 0;
right: 0;
transform: translate(50%, -50%) scale(1.5);
cursor: pointer;
z-index: 100;
border-radius: 100%;
overflow: hidden;
line-height: 1;
background-color: var(--zp-primary-background);
}
.file {
padding: 8px 16px;
margin: 8px;
display: flex;
align-items: center;
background: var(--zp-primary-background);
border-radius: 8px;
box-shadow: 0 0 4px var(--zp-secondary-variant-background);
position: relative;
&:hover .more {
opacity: 1;
}
.more {
opacity: 0;
transition: all 0.3s ease;
position: absolute;
top: 4px;
right: 4px;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
line-height: 1em;
.float-btn-wrap {
font-size: 1.5em;
cursor: pointer;
font-size: 500;
padding: 4px;
border-radius: 100vh;
color: white;
background: var(--zp-icon-bg);
margin-bottom: 4px;
&.like-selected {
color: rgb(223, 5, 5);
}
}
}
&.grid {
padding: 0;
display: inline-block;
box-sizing: content-box;
box-shadow: unset;
background-color: var(--zp-secondary-background);
:deep() {
.icon {
font-size: 8em;
}
.profile {
padding: 0 4px;
.name {
font-weight: 500;
padding: 0;
}
.basic-info {
display: flex;
justify-content: space-between;
flex-direction: row;
margin: 0;
font-size: 0.7em;
* {
white-space: nowrap;
overflow: hidden;
}
}
}
.ant-image,
.preview-icon-wrap {
border: 1px solid var(--zp-secondary);
background-color: var(--zp-secondary-variant-background);
border-radius: 8px;
overflow: hidden;
}
img:not(.dir-cover-item),
.dir-cover-container,
.preview-icon-wrap>[role='img'] {
height: v-bind('$props.cellWidth + "px"');
width: v-bind('$props.cellWidth + "px"');
object-fit: contain;
}
}
}
&.clickable {
cursor: pointer;
}
&.selected {
outline: #0084ff solid 2px;
}
.name {
flex: 1;
padding: 8px;
word-break: break-all;
}
.basic-info {
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.dir-cover-container {
top: 0;
display: flex;
flex-wrap: wrap;
padding: 4px;
&>img {
width: calc(50% - 8px);
height: calc(50% - 8px);
margin: 4px;
object-fit: cover;
border-radius: 8px;
overflow: hidden
}
}
}
</style>