import { useGlobalStore, type FileTransferTabPane } from '@/store/useGlobalStore' import { onLongPress, useElementSize } from '@vueuse/core' import { ref, computed, watch, onMounted, h, reactive } from 'vue' import { genInfoCompleted, getImageGenerationInfo, setImgPath } from '@/api' import { useWatchDocument, type SearchSelectConv, ok, createTypedShareStateHook, copy2clipboard, delay, FetchQueue, typedEventEmitter, ID } from 'vue3-ts-util' import { gradioApp, isImageFile } from '@/util' import { getTargetFolderFiles, type FileNodeInfo, deleteFiles, moveFiles } from '@/api/files' import { sortFiles, sortMethodMap, SortMethod } from './fileSort' import { cloneDeep, debounce, last, range, uniqBy, uniqueId } from 'lodash-es' import path from 'path-browserify' import type Progress from 'nprogress' // @ts-ignore import NProgress from 'multi-nprogress' import { Modal, message } from 'ant-design-vue' import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface' import { nextTick } from 'vue' import { t } from '@/i18n' import { CloudServerOutlined, DatabaseOutlined } from '@/icon' export const stackCache = new Map() const global = useGlobalStore() export const toRawFileUrl = (file: FileNodeInfo, download = false) => `/infinite_image_browsing/file?filename=${encodeURIComponent(file.fullpath)}${download ? `&disposition=${encodeURIComponent(file.name)}` : ''}` export const toImageThumbnailUrl = (file: FileNodeInfo, size: string) => `/infinite_image_browsing/image-thumbnail?path=${encodeURIComponent(file.fullpath)}&size=${size}` const { eventEmitter: events, useEventListen } = typedEventEmitter<{ removeFiles: [paths: string[], loc: string], addFiles: [paths: string[], loc: string] }>() export interface Scroller { $_startIndex: number $_endIndex: number scrollToItem (idx: number): void } export const { useHookShareState } = createTypedShareStateHook(() => { const props = ref({ tabIdx: -1, paneIdx: -1, target: 'local' }) const currPage = computed(() => last(stack.value)) const stack = ref([]) const basePath = computed(() => stack.value.map((v) => v.curr).slice(global.conf?.is_win && props.value.target === 'local' ? 1 : 0)) const currLocation = computed(() => path.join(...basePath.value)) const sortMethod = ref(SortMethod.DATE_DESC) const sortedFiles = computed(() => { if (!currPage.value) { return [] } const files = currPage.value?.files ?? [] const method = sortMethod.value const { walkFiles } = currPage.value! if (props.value.walkMode) { /** * @see Page */ return walkFiles ? walkFiles.map(dir => sortFiles(dir, method)).flat() : sortFiles(files, method) } return sortFiles(files, method) }) const multiSelectedIdxs = ref([] as number[]) const previewIdx = ref(-1) const canLoadNext = ref(true) const walkModePath = ref() const spinning = ref(false) return { spinning, canLoadNext, multiSelectedIdxs, previewIdx, basePath, currLocation, currPage, stack, sortMethod, sortedFiles, scroller: ref(), stackViewEl: ref(), walkModePath, props, ...typedEventEmitter<{ loadNextDir: undefined, refresh: void }>() } }) export interface Props { target: 'local', tabIdx: number, paneIdx: number, path?: string, walkMode?: boolean } export type ViewMode = 'line' | 'grid' | 'large-size-grid' export interface Page { files: FileNodeInfo[] walkFiles?: FileNodeInfo[][] // 使用walk时,各个文件夹之间分散排序,避免创建时间不同的带来的干扰 curr: string } /** * 全屏预览 * @param props * @returns */ export function usePreview (props: Props) { const { scroller, sortedFiles, previewIdx, eventEmitter, canLoadNext, } = useHookShareState().toRefs() const previewing = ref(false) let waitScrollTo = null as number | null const onPreviewVisibleChange = (v: boolean, lv: boolean) => { previewing.value = v if (waitScrollTo != null && !v && lv) { // 关闭预览时滚动过去 scroller.value?.scrollToItem(waitScrollTo) waitScrollTo = null } } const loadNextIfNeeded = () => { if (props.walkMode && props.target === 'local') { if (!canPreview('next') && canLoadNext) { message.info(t('loadingNextFolder')) eventEmitter.value.emit('loadNextDir') } } } useWatchDocument('keydown', (e) => { if (previewing.value) { let next = previewIdx.value if (['ArrowDown', 'ArrowRight'].includes(e.key)) { next++ while (sortedFiles.value[next] && !isImageFile(sortedFiles.value[next].name)) { next++ } } else if (['ArrowUp', 'ArrowLeft'].includes(e.key)) { next-- while (sortedFiles.value[next] && !isImageFile(sortedFiles.value[next].name)) { next-- } } if (isImageFile(sortedFiles.value[next]?.name) ?? '') { previewIdx.value = next const s = scroller.value if (s && !(next >= s.$_startIndex && next <= s.$_endIndex)) { waitScrollTo = next // 关闭预览时滚动过去 } } loadNextIfNeeded() } }) const previewImgMove = (type: 'next' | 'prev') => { let next = previewIdx.value if (type === 'next') { next++ while (sortedFiles.value[next] && !isImageFile(sortedFiles.value[next].name)) { next++ } } else if (type === 'prev') { next-- while (sortedFiles.value[next] && !isImageFile(sortedFiles.value[next].name)) { next-- } } if (isImageFile(sortedFiles.value[next]?.name) ?? '') { previewIdx.value = next const s = scroller.value if (s && !(next >= s.$_startIndex && next <= s.$_endIndex)) { waitScrollTo = next // 关闭预览时滚动过去 } } loadNextIfNeeded() } const canPreview = (type: 'next' | 'prev') => { let next = previewIdx.value if (type === 'next') { next++ while (sortedFiles.value[next] && !isImageFile(sortedFiles.value[next].name)) { next++ } } else if (type === 'prev') { next-- while (sortedFiles.value[next] && !isImageFile(sortedFiles.value[next].name)) { next-- } } return isImageFile(sortedFiles.value[next]?.name) ?? '' } return { previewIdx, onPreviewVisibleChange, previewing, previewImgMove, canPreview } } export function useLocation (props: Props) { const np = ref() const { scroller, stackViewEl, stack, currPage, currLocation, basePath , sortMethod, useEventListen, walkModePath } = useHookShareState().toRefs() watch(() => stack.value.length, debounce((v, lv) => { if (v !== lv) { scroller.value?.scrollToItem(0) } }, 300)) onMounted(async () => { if (!stack.value.length) { // 有传入stack时直接使用传入的 const resp = await getTargetFolderFiles(props.target, '/') stack.value.push({ files: resp.files, curr: '/' }) } np.value = new NProgress() np.value!.configure({ parent: stackViewEl.value as any }) if (props.path && props.path !== '/') { await to(props.path) if (props.walkMode) { await nextTick() const [firstDir] = sortFiles(currPage.value!.files, sortMethod.value).filter(v => v.type === 'dir') if (firstDir) { to(firstDir.fullpath) } } } else if (props.target == 'local') { global.conf?.home && to(global.conf.home) } }) watch(currLocation, debounce((loc) => { const pane = global.tabList[props.tabIdx].panes[props.paneIdx] as FileTransferTabPane pane.path = loc const filename = pane.path!.split('/').pop() const getTitle = () => { if (!props.walkMode) { return filename } return 'Walk: ' + (global.autoCompletedDirList.find(v => v.dir === walkModePath.value)?.zh ?? filename) } pane.name = h('div', { style: 'display:flex;align-items:center' }, [ h(props.target === 'local' ? DatabaseOutlined : CloudServerOutlined), h('span', { class: 'line-clamp-1', style: 'max-width: 256px' }, getTitle()) ]) as any as string global.recent = global.recent.filter(v => v.key !== pane.key) global.recent.unshift({ path: loc, target: pane.target, key: pane.key }) if (global.recent.length > 20) { global.recent = global.recent.slice(0, 20) } }, 300)) const copyLocation = () => copy2clipboard(currLocation.value) const openNext = async (file: FileNodeInfo) => { if (file.type !== 'dir') { return } try { np.value?.start() const prev = basePath.value const { files } = await getTargetFolderFiles( props.target, path.normalize(path.join(...prev, file.name)) ) stack.value.push({ files, curr: file.name }) } finally { np.value?.done() } } const back = (idx: number) => { while (idx < stack.value.length - 1) { stack.value.pop() } } const to = async (dir: string, refreshIfCurrPage = true) => { const backup = stack.value.slice() try { if (!/^((\w:)|\/)/.test(dir)) { // 相对路径 dir = path.join(global.conf?.sd_cwd ?? '/', dir) } const frags = dir.split(/\\|\//) if (global.conf?.is_win && props.target === 'local') { frags[0] = frags[0] + '/' // 分割完是c: } else { frags.shift() // /开头的一个是空 } const currPaths = stack.value.map(v => v.curr) currPaths.shift() // 是 / while (currPaths[0] && frags[0]) { if (currPaths[0] !== frags[0]) { break } else { currPaths.shift() frags.shift() } } for (let index = 0; index < currPaths.length; index++) { stack.value.pop() } if (!frags.length && refreshIfCurrPage) { return refresh() } for (const frag of frags) { if (frag === '') { // 这种情况 D:\Desktop\sd.webui\webui\outputs\ break } const target = currPage.value?.files.find((v) => v.name === frag) ok(target) await openNext(target) } } catch (error) { message.error(t('moveFailedCheckPath')) console.error(dir, dir.split(/\\|\//), currPage.value) stack.value = backup throw error } } const refresh = async () => { try { np.value?.start() if (walkModePath.value) { await to(walkModePath.value, false) await delay() const [firstDir] = sortFiles(currPage.value!.files, sortMethod.value).filter(v => v.type === 'dir') if (firstDir) { await to(firstDir.fullpath, false) } } else { const { files } = await getTargetFolderFiles(props.target, stack.value.length === 1 ? '/' : currLocation.value) last(stack.value)!.files = files } } finally { np.value?.done() } } useEventListen.value('refresh', refresh) return { refresh, copyLocation, back, openNext, currPage, currLocation, to, stack, scroller } } export function useFilesDisplay (props: Props) { const { scroller, sortedFiles, stack, sortMethod, currLocation, currPage, stackViewEl, canLoadNext } = useHookShareState().toRefs() const { state } = useHookShareState() const moreActionsDropdownShow = ref(false) const viewMode = ref('grid') const viewModeMap: Record = { line: t('detailList'), 'grid': t('previewGrid'), 'large-size-grid': t('largePreviewGrid') } const sortMethodConv: SearchSelectConv = { value: (v) => v, text: (v) => t('sortBy') + ' ' + sortMethodMap[v].toLocaleLowerCase() } const gridSize = 272 const profileHeight = 64 const largeGridSize = gridSize * 2 const { width } = useElementSize(stackViewEl) const gridItems = computed(() => { const w = width.value if (viewMode.value === 'line' || !w) { return } return ~~(w / (viewMode.value === 'grid' ? gridSize : largeGridSize)) }) const itemSize = computed(() => { const mode = viewMode.value if (mode === 'line') { return { first: 80, second: undefined } } const second = (mode === 'grid' ? gridSize : largeGridSize) const first = second + profileHeight return { first, second } }) const loadNextDirLoading = ref(false) const loadNextDir = async () => { if (loadNextDirLoading.value || !props.walkMode || !canLoadNext.value) { return } try { loadNextDirLoading.value = true const par = stack.value[stack.value.length - 2] const parFilesSorted = sortFiles(par.files, sortMethod.value) const currIdx = parFilesSorted.findIndex(v => v.name === currPage.value?.curr) if (currIdx !== -1) { const next = parFilesSorted[currIdx + 1] const p = path.normalize(path.join(currLocation.value, '../', next.name)) const r = await getTargetFolderFiles(props.target, p) const page = currPage.value! page.curr = next.name if (!page.walkFiles) { page.walkFiles = [page.files] } page.walkFiles.push(r.files) console.log("curr page files length", currPage.value?.files.length) } } catch (e) { canLoadNext.value = false } finally { loadNextDirLoading.value = false } const s = scroller.value // 填充够一页,直到不行为止 while (s && s.$_endIndex > sortedFiles.value.length - 10 && canLoadNext.value) { await loadNextDir() } } state.useEventListen('loadNextDir', loadNextDir) const onScroll = debounce(async () => { const s = scroller.value if (s && (s.$_endIndex > sortedFiles.value.length - 10) && props.walkMode) { loadNextDir() } }, 300) return { gridItems, sortedFiles, sortMethodConv, viewModeMap, moreActionsDropdownShow, viewMode, gridSize, sortMethod, largeGridSize, onScroll, loadNextDir, loadNextDirLoading, canLoadNext, itemSize } } export function useFileTransfer (props: Props) { const { currLocation, sortedFiles, currPage, multiSelectedIdxs, eventEmitter } = useHookShareState().toRefs() const recover = () => { multiSelectedIdxs.value = [] } useWatchDocument('click', recover) useWatchDocument('blur', recover) watch(currPage, recover) const onFileDragStart = (e: DragEvent, idx: number) => { const file = cloneDeep(sortedFiles.value[idx]) console.log('onFileDragStart set drag file ', e, idx, file) const files = [file] let includeDir = file.type === 'dir' if (multiSelectedIdxs.value.includes(idx)) { const selectedFiles = multiSelectedIdxs.value.map(idx => sortedFiles.value[idx]) files.push(...selectedFiles) includeDir = selectedFiles.some(v => v.type === 'dir') } e.dataTransfer!.setData( 'text/plain', JSON.stringify({ from: props.target, includeDir, loc: currLocation.value, path: uniqBy(files, 'fullpath').map(f => f.fullpath) }) ) } const onDrop = async (e: DragEvent) => { type Data = { from: typeof props.target path: string[], loc: string includeDir: boolean } const data = JSON.parse(e.dataTransfer?.getData('text') || '{}') as Data console.log(data) if (data.from && data.path && typeof data.includeDir !== 'undefined' && data.loc) { const toPath = currLocation.value if (data.from === props.target && data.loc === toPath) { return } if (props.target == data.from) { const content = h('div', [ h('div', `${t('moveSelectedFilesTo')}${toPath}`), h('ol', data.path.map(v => v.split(/[/\\]/).pop()).map(v => h('li', v))), ]) Modal.confirm({ title: t('confirm'), content, maskClosable: true, async onOk () { await moveFiles(props.target, data.path, toPath) events.emit('removeFiles', [data.path, data.loc]) await eventEmitter.value.emit('refresh') } }) } else { // 原有的百度云 } } } return { onFileDragStart, onDrop, multiSelectedIdxs } } export function useFileItemActions (props: Props, { openNext }: { openNext: (file: FileNodeInfo) => Promise }) { const showGenInfo = ref(false) const imageGenInfo = ref('') const { sortedFiles, previewIdx, multiSelectedIdxs, stack, currLocation, spinning } = useHookShareState().toRefs() useEventListen('removeFiles', ([paths, loc]: [paths: string[], loc: string]) => { if (loc !== currLocation.value) { return } const top = last(stack.value)! top.files = top.files.filter(v => !paths.includes(v.fullpath)) if (top.walkFiles) { top.walkFiles = top.walkFiles.map(files => files.filter(file => !paths.includes(file.fullpath))) } }) const q = reactive(new FetchQueue()) const onFileItemClick = async (e: MouseEvent, file: FileNodeInfo) => { const files = sortedFiles.value const idx = files.findIndex(v => v.name === file.name) previewIdx.value = idx if (e.shiftKey) { multiSelectedIdxs.value.push(idx) multiSelectedIdxs.value.sort((a, b) => a - b) const first = multiSelectedIdxs.value[0] const last = multiSelectedIdxs.value[multiSelectedIdxs.value.length - 1] multiSelectedIdxs.value = range(first, last + 1) console.log(multiSelectedIdxs.value) e.stopPropagation() } else if (e.ctrlKey || e.metaKey) { multiSelectedIdxs.value.push(idx) e.stopPropagation() } else { await openNext(file) } } const onContextMenuClick = async (e: MenuInfo, file: FileNodeInfo, idx: number) => { const url = toRawFileUrl(file) const path = currLocation.value const copyImgTo = async (tab: ["txt2img", "img2img", "inpaint", "extras"][number]) => { if (spinning.value) { return } try { spinning.value = true await setImgPath(file.fullpath) // 设置图像路径 const btn = gradioApp().querySelector('#iib_hidden_img_update_trigger')! as HTMLButtonElement btn.click() // 触发图像组件更新 ok(await genInfoCompleted(), 'genInfoCompleted timeout') // 等待消息生成完成 const tabBtn = gradioApp().querySelector(`#iib_hidden_tab_${tab}`) as HTMLButtonElement tabBtn.click() // 触发粘贴 } catch (error) { console.error(error) message.error('发送图像失败,请携带console的错误消息找开发者') } finally { spinning.value = false } } switch (e.key) { case 'previewInNewWindow': return window.open(url) case 'download': return window.open(toRawFileUrl(file, true)) case 'copyPreviewUrl': return copy2clipboard(location.host + url) case 'send2txt2img': return copyImgTo('txt2img') case 'send2img2img': return copyImgTo('img2img') case 'send2inpaint': return copyImgTo('inpaint') case 'send2extras': return copyImgTo('extras') case 'openWithWalkMode': { stackCache.set(path, stack.value) const tab = global.tabList[props.tabIdx] const pane: FileTransferTabPane = { type: props.target, target: props.target, key: uniqueId(), path: file.fullpath, name: props.target === 'local' ? t('local') : t('cloud'), stackKey: path, walkMode: true } tab.panes.push(pane) tab.key = pane.key break } case 'openInNewTab': { stackCache.set(path, stack.value) const tab = global.tabList[props.tabIdx] const pane: FileTransferTabPane = { type: props.target, target: props.target, key: uniqueId(), path: file.fullpath, name: props.target === 'local' ? t('local') : t('cloud'), stackKey: path } tab.panes.push(pane) tab.key = pane.key break } case 'openOnTheRight': { stackCache.set(path, stack.value) let tab = global.tabList[props.tabIdx + 1] if (!tab) { tab = ID({ panes: [], key: '' }) global.tabList[props.tabIdx + 1] = tab } const pane: FileTransferTabPane = { type: props.target, target: props.target, key: uniqueId(), path: file.fullpath, name: props.target === 'local' ? t('local') : t('cloud'), stackKey: path } tab.panes.push(pane) tab.key = pane.key break } case 'viewGenInfo': { showGenInfo.value = true imageGenInfo.value = await q.pushAction(() => getImageGenerationInfo(file.fullpath)).res break } case 'deleteFiles': { let selectedFiles: FileNodeInfo[] = [] if (multiSelectedIdxs.value.includes(idx)) { selectedFiles = multiSelectedIdxs.value.map(idx => sortedFiles.value[idx]) } else { selectedFiles.push(file) } Modal.confirm({ title: t('confirmDelete'), maskClosable: true, content: h('ol', { style: 'max-height:50vh;overflow:auto;' }, selectedFiles.map(v => v.fullpath.split(/[/\\]/).pop()).map(v => h('li', v))), async onOk () { const paths = selectedFiles.map(v => v.fullpath) await deleteFiles(props.target, paths) message.success(t('deleteSuccess')) events.emit('removeFiles', [paths, currLocation.value]) }, }) } } } return { onFileItemClick, onContextMenuClick, showGenInfo, imageGenInfo, q } } /** * 针对移动端端操作优化,使用长按提到右键 */ export const useMobileOptimization = () => { const { stackViewEl } = useHookShareState().toRefs() const showMenuIdx = ref(-1) onLongPress( stackViewEl, e => { let fileEl = e.target as HTMLDivElement while (fileEl.parentElement) { fileEl = fileEl.parentElement as any if (fileEl.tagName.toLowerCase() === 'li' && fileEl.classList.contains('file-item-trigger')) { const idx = fileEl.dataset?.idx if (idx && Number.isSafeInteger(+idx)) { showMenuIdx.value = +idx } return } } } ) return { showMenuIdx } }