<template> <div class="yfudmmck"> <nav> <div class="path" @contextmenu.prevent.stop="() => {}"> <XNavFolder :class="{ current: folder == null }" :parent-folder="folder" @move="move" @upload="upload" @removeFile="removeFile" @removeFolder="removeFolder" /> <template v-for="f in hierarchyFolders"> <span class="separator"><i class="fas fa-angle-right"></i></span> <XNavFolder :folder="f" :parent-folder="folder" @move="move" @upload="upload" @removeFile="removeFile" @removeFolder="removeFolder" /> </template> <span v-if="folder != null" class="separator"><i class="fas fa-angle-right"></i></span> <span v-if="folder != null" class="folder current">{{ folder.name }}</span> </div> <button class="menu _button" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> </nav> <div ref="main" class="main" :class="{ uploading: uploadings.length > 0, fetching }" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @drop.prevent.stop="onDrop" @contextmenu.stop="onContextmenu" > <div ref="contents" class="contents"> <div v-show="folders.length > 0" ref="foldersContainer" class="folders"> <XFolder v-for="(f, i) in folders" :key="f.id" v-anim="i" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" @move="move" @upload="upload" @removeFile="removeFile" @removeFolder="removeFolder" @dragstart="isDragSource = true" @dragend="isDragSource = false" /> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div v-for="(n, i) in 16" :key="i" class="padding"></div> <MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton> </div> <div v-show="files.length > 0" ref="filesContainer" class="files"> <XFile v-for="(file, i) in files" :key="file.id" v-anim="i" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" @dragstart="isDragSource = true" @dragend="isDragSource = false" /> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div v-for="(n, i) in 16" :key="i" class="padding"></div> <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton> </div> <div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty"> <p v-if="draghover">{{ i18n.t('empty-draghover') }}</p> <p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p> <p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p> </div> </div> <MkLoading v-if="fetching"/> </div> <div v-if="draghover" class="dropzone"></div> <input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/> </div> </template> <script lang="ts" setup> import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import XNavFolder from './drive.nav-folder.vue'; import XFolder from './drive.folder.vue'; import XFile from './drive.file.vue'; import MkButton from './ui/button.vue'; import * as os from '@/os'; import { stream } from '@/stream'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { uploadFile, uploads } from '@/scripts/upload'; const props = withDefaults(defineProps<{ initialFolder?: Misskey.entities.DriveFolder; type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; }>(), { multiple: false, select: null, }); const emit = defineEmits<{ (ev: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void; (ev: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; (ev: 'move-root'): void; (ev: 'cd', v: Misskey.entities.DriveFolder | null): void; (ev: 'open-folder', v: Misskey.entities.DriveFolder): void; }>(); const loadMoreFiles = ref<InstanceType<typeof MkButton>>(); const fileInput = ref<HTMLInputElement>(); const folder = ref<Misskey.entities.DriveFolder | null>(null); const files = ref<Misskey.entities.DriveFile[]>([]); const folders = ref<Misskey.entities.DriveFolder[]>([]); const moreFiles = ref(false); const moreFolders = ref(false); const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); const uploadings = uploads; const connection = stream.useChannel('drive'); const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい // ドロップされようとしているか const draghover = ref(false); // 自身の所有するアイテムがドラッグをスタートさせたか // (自分自身の階層にドロップできないようにするためのフラグ) const isDragSource = ref(false); const fetching = ref(true); const ilFilesObserver = new IntersectionObserver( (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles() ); watch(folder, () => emit('cd', folder.value)); function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { addFile(file, true); } function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) { const current = folder.value ? folder.value.id : null; if (current !== file.folderId) { removeFile(file); } else { addFile(file, true); } } function onStreamDriveFileDeleted(fileId: string) { removeFile(fileId); } function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) { addFolder(createdFolder, true); } function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) { const current = folder.value ? folder.value.id : null; if (current !== updatedFolder.parentId) { removeFolder(updatedFolder); } else { addFolder(updatedFolder, true); } } function onStreamDriveFolderDeleted(folderId: string) { removeFolder(folderId); } function onDragover(ev: DragEvent): any { if (!ev.dataTransfer) return; // ドラッグ元が自分自身の所有するアイテムだったら if (isDragSource.value) { // 自分自身にはドロップさせない ev.dataTransfer.dropEffect = 'none'; return; } const isFile = ev.dataTransfer.items[0].kind === 'file'; const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_; if (isFile || isDriveFile || isDriveFolder) { ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; } else { ev.dataTransfer.dropEffect = 'none'; } return false; } function onDragenter() { if (!isDragSource.value) draghover.value = true; } function onDragleave() { draghover.value = false; } function onDrop(ev: DragEvent): any { draghover.value = false; if (!ev.dataTransfer) return; // ドロップされてきたものがファイルだったら if (ev.dataTransfer.files.length > 0) { for (const file of Array.from(ev.dataTransfer.files)) { upload(file, folder.value); } return; } //#region ドライブのファイル const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); if (files.value.some(f => f.id === file.id)) return; removeFile(file.id); os.api('drive/files/update', { fileId: file.id, folderId: folder.value ? folder.value.id : null }); } //#endregion //#region ドライブのフォルダ const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); if (driveFolder != null && driveFolder !== '') { const droppedFolder = JSON.parse(driveFolder); // 移動先が自分自身ならreject if (folder.value && droppedFolder.id === folder.value.id) return false; if (folders.value.some(f => f.id === droppedFolder.id)) return false; removeFolder(droppedFolder.id); os.api('drive/folders/update', { folderId: droppedFolder.id, parentId: folder.value ? folder.value.id : null }).then(() => { // noop }).catch(err => { switch (err) { case 'detected-circular-definition': os.alert({ title: i18n.ts.unableToProcess, text: i18n.ts.circularReferenceFolder }); break; default: os.alert({ type: 'error', text: i18n.ts.somethingHappened }); } }); } //#endregion } function selectLocalFile() { fileInput.value?.click(); } function urlUpload() { os.inputText({ title: i18n.ts.uploadFromUrl, type: 'url', placeholder: i18n.ts.uploadFromUrlDescription }).then(({ canceled, result: url }) => { if (canceled || !url) return; os.api('drive/files/upload-from-url', { url: url, folderId: folder.value ? folder.value.id : undefined }); os.alert({ title: i18n.ts.uploadFromUrlRequested, text: i18n.ts.uploadFromUrlMayTakeTime }); }); } function createFolder() { os.inputText({ title: i18n.ts.createFolder, placeholder: i18n.ts.folderName }).then(({ canceled, result: name }) => { if (canceled) return; os.api('drive/folders/create', { name: name, parentId: folder.value ? folder.value.id : undefined }).then(createdFolder => { addFolder(createdFolder, true); }); }); } function renameFolder(folderToRename: Misskey.entities.DriveFolder) { os.inputText({ title: i18n.ts.renameFolder, placeholder: i18n.ts.inputNewFolderName, default: folderToRename.name }).then(({ canceled, result: name }) => { if (canceled) return; os.api('drive/folders/update', { folderId: folderToRename.id, name: name }).then(updatedFolder => { // FIXME: 画面を更新するために自分自身に移動 move(updatedFolder); }); }); } function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { os.api('drive/folders/delete', { folderId: folderToDelete.id }).then(() => { // 削除時に親フォルダに移動 move(folderToDelete.parentId); }).catch(err => { switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': os.alert({ type: 'error', title: i18n.ts.unableToDelete, text: i18n.ts.hasChildFilesOrFolders }); break; default: os.alert({ type: 'error', text: i18n.ts.unableToDelete }); } }); } function onChangeFileInput() { if (!fileInput.value?.files) return; for (const file of Array.from(fileInput.value.files)) { upload(file, folder.value); } } function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) { uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => { addFile(res, true); }); } function chooseFile(file: Misskey.entities.DriveFile) { const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id); if (props.multiple) { if (isAlreadySelected) { selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); } else { selectedFiles.value.push(file); } emit('change-selection', selectedFiles.value); } else { if (isAlreadySelected) { emit('selected', file); } else { selectedFiles.value = [file]; emit('change-selection', [file]); } } } function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { const isAlreadySelected = selectedFolders.value.some(f => f.id === folderToChoose.id); if (props.multiple) { if (isAlreadySelected) { selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToChoose.id); } else { selectedFolders.value.push(folderToChoose); } emit('change-selection', selectedFolders.value); } else { if (isAlreadySelected) { emit('selected', folderToChoose); } else { selectedFolders.value = [folderToChoose]; emit('change-selection', [folderToChoose]); } } } function move(target?: Misskey.entities.DriveFolder) { if (!target) { goRoot(); return; } else if (typeof target === 'object') { target = target.id; } fetching.value = true; os.api('drive/folders/show', { folderId: target }).then(folderToMove => { folder.value = folderToMove; hierarchyFolders.value = []; const dive = folderToDive => { hierarchyFolders.value.unshift(folderToDive); if (folderToDive.parent) dive(folderToDive.parent); }; if (folderToMove.parent) dive(folderToMove.parent); emit('open-folder', folderToMove); fetch(); }); } function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) { const current = folder.value ? folder.value.id : null; if (current !== folderToAdd.parentId) return; if (folders.value.some(f => f.id === folderToAdd.id)) { const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id); folders.value[exist] = folderToAdd; return; } if (unshift) { folders.value.unshift(folderToAdd); } else { folders.value.push(folderToAdd); } } function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) { const current = folder.value ? folder.value.id : null; if (current !== fileToAdd.folderId) return; if (files.value.some(f => f.id === fileToAdd.id)) { const exist = files.value.map(f => f.id).indexOf(fileToAdd.id); files.value[exist] = fileToAdd; return; } if (unshift) { files.value.unshift(fileToAdd); } else { files.value.push(fileToAdd); } } function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) { const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove; folders.value = folders.value.filter(f => f.id !== folderIdToRemove); } function removeFile(file: Misskey.entities.DriveFile | string) { const fileId = typeof file === 'object' ? file.id : file; files.value = files.value.filter(f => f.id !== fileId); } function appendFile(file: Misskey.entities.DriveFile) { addFile(file); } function appendFolder(folderToAppend: Misskey.entities.DriveFolder) { addFolder(folderToAppend); } /* function prependFile(file: Misskey.entities.DriveFile) { addFile(file, true); } function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) { addFolder(folderToPrepend, true); } */ function goRoot() { // 既にrootにいるなら何もしない if (folder.value == null) return; folder.value = null; hierarchyFolders.value = []; emit('move-root'); fetch(); } async function fetch() { folders.value = []; files.value = []; moreFolders.value = false; moreFiles.value = false; fetching.value = true; const foldersMax = 30; const filesMax = 30; const foldersPromise = os.api('drive/folders', { folderId: folder.value ? folder.value.id : null, limit: foldersMax + 1 }).then(fetchedFolders => { if (fetchedFolders.length === foldersMax + 1) { moreFolders.value = true; fetchedFolders.pop(); } return fetchedFolders; }); const filesPromise = os.api('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, limit: filesMax + 1 }).then(fetchedFiles => { if (fetchedFiles.length === filesMax + 1) { moreFiles.value = true; fetchedFiles.pop(); } return fetchedFiles; }); const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]); for (const x of fetchedFolders) appendFolder(x); for (const x of fetchedFiles) appendFile(x); fetching.value = false; } function fetchMoreFiles() { fetching.value = true; const max = 30; // ファイル一覧取得 os.api('drive/files', { folderId: folder.value ? folder.value.id : null, type: props.type, untilId: files.value[files.value.length - 1].id, limit: max + 1 }).then(files => { if (files.length === max + 1) { moreFiles.value = true; files.pop(); } else { moreFiles.value = false; } for (const x of files) appendFile(x); fetching.value = false; }); } function getMenu() { return [{ type: 'switch', text: i18n.ts.keepOriginalUploading, ref: keepOriginal, }, null, { text: i18n.ts.addFile, type: 'label' }, { text: i18n.ts.upload, icon: 'fas fa-upload', action: () => { selectLocalFile(); } }, { text: i18n.ts.fromUrl, icon: 'fas fa-link', action: () => { urlUpload(); } }, null, { text: folder.value ? folder.value.name : i18n.ts.drive, type: 'label' }, folder.value ? { text: i18n.ts.renameFolder, icon: 'fas fa-i-cursor', action: () => { renameFolder(folder.value); } } : undefined, folder.value ? { text: i18n.ts.deleteFolder, icon: 'fas fa-trash-alt', action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); } } : undefined, { text: i18n.ts.createFolder, icon: 'fas fa-folder-plus', action: () => { createFolder(); } }]; } function showMenu(ev: MouseEvent) { os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } function onContextmenu(ev: MouseEvent) { os.contextMenu(getMenu(), ev); } onMounted(() => { if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); } connection.on('fileCreated', onStreamDriveFileCreated); connection.on('fileUpdated', onStreamDriveFileUpdated); connection.on('fileDeleted', onStreamDriveFileDeleted); connection.on('folderCreated', onStreamDriveFolderCreated); connection.on('folderUpdated', onStreamDriveFolderUpdated); connection.on('folderDeleted', onStreamDriveFolderDeleted); if (props.initialFolder) { move(props.initialFolder); } else { fetch(); } }); onActivated(() => { if (defaultStore.state.enableInfiniteScroll) { nextTick(() => { ilFilesObserver.observe(loadMoreFiles.value?.$el); }); } }); onBeforeUnmount(() => { connection.dispose(); ilFilesObserver.disconnect(); }); </script> <style lang="scss" scoped> .yfudmmck { display: flex; flex-direction: column; height: 100%; > nav { display: flex; z-index: 2; width: 100%; padding: 0 8px; box-sizing: border-box; overflow: auto; font-size: 0.9em; box-shadow: 0 1px 0 var(--divider); &, * { user-select: none; } > .path { display: inline-block; vertical-align: bottom; line-height: 50px; white-space: nowrap; > * { display: inline-block; margin: 0; padding: 0 8px; line-height: 50px; cursor: pointer; * { pointer-events: none; } &:hover { text-decoration: underline; } &.current { font-weight: bold; cursor: default; &:hover { text-decoration: none; } } &.separator { margin: 0; padding: 0; opacity: 0.5; cursor: default; > i { margin: 0; } } } } > .menu { margin-left: auto; padding: 0 12px; } } > .main { flex: 1; overflow: auto; padding: var(--margin); &, * { user-select: none; } &.fetching { cursor: wait !important; * { pointer-events: none; } > .contents { opacity: 0.5; } } &.uploading { height: calc(100% - 38px - 100px); } > .contents { > .folders, > .files { display: flex; flex-wrap: wrap; > .folder, > .file { flex-grow: 1; width: 128px; margin: 4px; box-sizing: border-box; } > .padding { flex-grow: 1; pointer-events: none; width: 128px + 8px; } } > .empty { padding: 16px; text-align: center; pointer-events: none; opacity: 0.5; > p { margin: 0; } } } } > .dropzone { position: absolute; left: 0; top: 38px; width: 100%; height: calc(100% - 38px); border: dashed 2px var(--focus); pointer-events: none; } > input { display: none; } } </style>