diff options
Diffstat (limited to 'packages/frontend/src/components')
17 files changed, 1235 insertions, 691 deletions
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index ba21394cbc..7f592fba79 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only @closed="emit('closed')" > <template #header>{{ i18n.ts.cropImage }}</template> - <template #default="{ width, height }"> - <div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`"> - <Transition name="fade"> - <div v-if="loading" class="loading"> - <MkLoading/> - </div> - </Transition> - <div class="container"> - <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad"> + <div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`"> + <Transition name="fade"> + <div v-if="loading" class="loading"> + <MkLoading/> </div> + </Transition> + <div class="container"> + <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad"> </div> - </template> + </div> </MkModalWindow> </template> @@ -35,27 +33,23 @@ import { onMounted, useTemplateRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; -import { apiUrl } from '@@/js/config.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import * as os from '@/os.js'; -import { $i } from '@/i.js'; import { i18n } from '@/i18n.js'; -import { getProxiedImageUrl } from '@/utility/media-proxy.js'; -import { prefer } from '@/preferences.js'; + +const props = defineProps<{ + imageFile: File | Blob; + aspectRatio: number | null; + uploadFolder?: string | null; +}>(); const emit = defineEmits<{ - (ev: 'ok', cropped: Misskey.entities.DriveFile): void; + (ev: 'ok', cropped: File | Blob): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); -const props = defineProps<{ - file: Misskey.entities.DriveFile; - aspectRatio: number; - uploadFolder?: string | null; -}>(); - -const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); +const imgUrl = URL.createObjectURL(props.imageFile); const dialogEl = useTemplateRef('dialogEl'); const imgEl = useTemplateRef('imgEl'); let cropper: Cropper | null = null; @@ -73,31 +67,10 @@ const ok = async () => { const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender }); croppedCanvas?.toBlob(blob => { if (!blob) return; - const formData = new FormData(); - formData.append('file', blob); - formData.append('name', `cropped_${props.file.name}`); - formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false'); - if (props.file.comment) { formData.append('comment', props.file.comment);} - formData.append('i', $i!.token); - if (props.uploadFolder) { - formData.append('folderId', props.uploadFolder); - } else if (props.uploadFolder !== null && prefer.s.uploadFolder) { - formData.append('folderId', prefer.s.uploadFolder); - } - - window.fetch(apiUrl + '/drive/files/create', { - method: 'POST', - body: formData, - }) - .then(response => response.json()) - .then(f => { - res(f); - }); + res(blob); }); }); - os.promiseDialog(promise); - const f = await promise; emit('ok', f); @@ -126,8 +99,8 @@ onMounted(() => { const selection = cropper.getCropperSelection()!; selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString(); - selection.aspectRatio = props.aspectRatio; - selection.initialAspectRatio = props.aspectRatio; + if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio; + selection.initialAspectRatio = props.aspectRatio ?? 1; selection.outlined = true; window.setTimeout(() => { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 70ab60cfae..bbb6a7ea2a 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.isSelected]: isSelected }]" draggable="true" :title="title" - @click="onClick" @contextmenu.stop="onContextmenu" @dragstart="onDragstart" @dragend="onDragend" @@ -46,24 +45,18 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; -import { deviceKind } from '@/utility/device-kind.js'; -import { useRouter } from '@/router.js'; - -const router = useRouter(); +import { setDragData } from '@/drag-and-drop.js'; const props = withDefaults(defineProps<{ file: Misskey.entities.DriveFile; folder: Misskey.entities.DriveFolder | null; isSelected?: boolean; - selectMode?: boolean; }>(), { isSelected: false, - selectMode: false, }); const emit = defineEmits<{ - (ev: 'chosen', r: Misskey.entities.DriveFile): void; - (ev: 'dragstart'): void; + (ev: 'dragstart', dragEvent: DragEvent): void; (ev: 'dragend'): void; }>(); @@ -71,18 +64,6 @@ const isDragging = ref(false); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); -function onClick(ev: MouseEvent) { - if (props.selectMode) { - emit('chosen', props.file); - } else { - if (deviceKind === 'desktop') { - router.push(`/my/drive/file/${props.file.id}`); - } else { - os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); - } - } -} - function onContextmenu(ev: MouseEvent) { os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } @@ -90,11 +71,11 @@ function onContextmenu(ev: MouseEvent) { function onDragstart(ev: DragEvent) { if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file)); + setDragData(ev, 'driveFiles', [props.file]); } isDragging.value = true; - emit('dragstart'); + emit('dragstart', ev); } function onDragend() { @@ -114,7 +95,7 @@ function onDragend() { &:hover { background: rgba(#000, 0.05); - > .label { + .label { &::before, &::after { background: #0b65a5; @@ -132,7 +113,7 @@ function onDragend() { &:active { background: rgba(#000, 0.1); - > .label { + .label { &::before, &::after { background: #0b588c; @@ -158,19 +139,19 @@ function onDragend() { background: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); } - > .label { + .label { &::before, &::after { display: none; } } - > .name { - color: #fff; + .name { + color: var(--MI_THEME-fgOnAccent); } - > .thumbnail { - color: #fff; + .thumbnail { + color: var(--MI_THEME-fgOnAccent); } } } @@ -240,8 +221,8 @@ function onDragend() { .name { display: block; - margin: 4px 0 0 0; - font-size: 0.8em; + margin: 8px 0 0 0; + font-size: 82%; text-align: center; word-break: break-all; color: var(--MI_THEME-fg); diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 9c72691d21..83472eec3d 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.draghover]: draghover }]" draggable="true" :title="title" - @click="onClick" @contextmenu.stop="onContextmenu" @mouseover="onMouseover" @mouseout="onMouseout" @@ -19,14 +18,13 @@ SPDX-License-Identifier: AGPL-3.0-only @dragstart="onDragstart" @dragend="onDragend" > - <p :class="$style.name"> - <template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> - <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template> - {{ folder.name }} - </p> - <p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload"> + <svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none"> + <path d="M190,25C195.523,25 200,29.477 200,35C200,58.415 200,116.585 200,140C200,145.523 195.523,150 190,150C155.86,150 44.14,150 10,150C4.477,150 0,145.523 0,140C0,112.727 0,37.273 0,10C0,4.477 4.477,0 10,-0C26.642,0 59.332,0 70.858,0C73.51,-0 76.054,1.054 77.929,2.929C82.74,7.74 92.26,17.26 97.071,22.071C98.946,23.946 101.49,25 104.142,25C118.808,25 168.535,25 190,25Z" style="fill:var(--MI_THEME-accentedBg);"/> + </svg> + <div :class="$style.name">{{ folder.name }}</div> + <div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload"> {{ i18n.ts.uploadFolder }} - </p> + </div> <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> <div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div> </button> @@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/utility/achievements.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; +import { selectDriveFolder } from '@/utility/drive.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: 'unchose', v: Misskey.entities.DriveFolder): void; - (ev: 'move', v: Misskey.entities.DriveFolder): void; - (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder); - (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; - (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; + (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder); (ev: 'dragstart'): void; (ev: 'dragend'): void; }>(); @@ -78,10 +76,6 @@ function checkboxClicked() { } } -function onClick() { - emit('move', props.folder); -} - function onMouseover() { hover.value = true; } @@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) { } 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) { + if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) { // ファイルだったら if (ev.dataTransfer.files.length > 0) { - for (const file of Array.from(ev.dataTransfer.files)) { - emit('upload', file, props.folder); - } + emit('upload', Array.from(ev.dataTransfer.files), props.folder); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - emit('removeFile', file.id); - misskeyApi('drive/files/update', { - fileId: file.id, - folderId: props.folder.id, - }); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + misskeyApi('drive/files/move-bulk', { + fileIds: droppedData.map(f => f.id), + folderId: props.folder.id, + }).then(() => { + globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({ + ...x, + folderId: props.folder.id, + folder: props.folder, + }))); + }); + } } //#endregion //#region ドライブのフォルダ - const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder !== '') { - const folder = JSON.parse(driveFolder); + { + const droppedData = getDragData(ev, 'driveFolders'); + if (droppedData != null) { + const droppedFolder = droppedData[0]; - // 移動先が自分自身ならreject - if (folder.id === props.folder.id) return; + // 移動先が自分自身ならreject + if (droppedFolder.id === props.folder.id) return; - emit('removeFolder', folder.id); - misskeyApi('drive/folders/update', { - folderId: folder.id, - parentId: props.folder.id, - }).then(() => { - // noop - }).catch(err => { - switch (err.code) { - case 'RECURSIVE_NESTING': - claimAchievement('driveFolderCircularReference'); - os.alert({ - type: 'error', - title: i18n.ts.unableToProcess, - text: i18n.ts.circularReferenceFolder, - }); - break; - default: - os.alert({ - type: 'error', - text: i18n.ts.somethingHappened, - }); - } - }); + misskeyApi('drive/folders/update', { + folderId: droppedFolder.id, + parentId: props.folder.id, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({ + ...x, + parentId: props.folder.id, + parent: props.folder, + }))); + }).catch(err => { + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); + os.alert({ + type: 'error', + title: i18n.ts.unableToProcess, + text: i18n.ts.circularReferenceFolder, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + }); + } } //#endregion } @@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) { if (!ev.dataTransfer) return; ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder)); + setDragData(ev, 'driveFolders', [props.folder]); isDragging.value = true; // 親ブラウザに対して、ドラッグが開始されたフラグを立てる @@ -211,10 +211,6 @@ function onDragend() { emit('dragend'); } -function go() { - emit('move', props.folder); -} - function rename() { os.inputText({ title: i18n.ts.renameFolder, @@ -225,17 +221,28 @@ function rename() { misskeyApi('drive/folders/update', { folderId: props.folder.id, name: name, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [{ + ...props.folder, + name: name, + }]); }); }); } function move() { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { if (folder[0] && folder[0].id === props.folder.id) return; misskeyApi('drive/folders/update', { folderId: props.folder.id, parentId: folder[0] ? folder[0].id : null, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [{ + ...props.folder, + parentId: folder[0] ? folder[0].id : null, + parent: folder[0] ?? null, + }]); }); }); } @@ -247,6 +254,7 @@ function deleteFolder() { if (prefer.s.uploadFolder === props.folder.id) { prefer.commit('uploadFolder', null); } + globalEvents.emit('driveFoldersDeleted', [props.folder]); }).catch(err => { switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': @@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) { <style lang="scss" module> .root { position: relative; - padding: 8px; - height: 64px; - background: var(--MI_THEME-driveFolderBg); - border-radius: 4px; + height: 90px; + padding: 24px 16px; + box-sizing: border-box; cursor: pointer; &.draghover { @@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) { } } +.shape { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + .checkboxWrapper { position: absolute; border-radius: 50%; @@ -373,7 +388,6 @@ function onContextmenu(ev: MouseEvent) { } .name { - margin: 0; font-size: 0.9em; } @@ -384,7 +398,6 @@ function onContextmenu(ev: MouseEvent) { } .upload { - margin: 4px 4px; font-size: 0.8em; text-align: right; } diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue index 7433aea061..224aa2dca7 100644 --- a/packages/frontend/src/components/MkDrive.navFolder.vue +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, { [$style.draghover]: draghover }]" - @click="onClick" @dragover.prevent.stop="onDragover" @dragenter="onDragenter" @dragleave="onDragleave" @@ -22,6 +21,8 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const props = defineProps<{ folder?: Misskey.entities.DriveFolder; @@ -29,27 +30,11 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ - (ev: 'move', v?: Misskey.entities.DriveFolder): void; - (ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void; - (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void; - (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void; + (ev: 'upload', files: File[], folder?: Misskey.entities.DriveFolder | null): void; }>(); -const hover = ref(false); const draghover = ref(false); -function onClick() { - emit('move', props.folder); -} - -function onMouseover() { - hover.value = true; -} - -function onMouseout() { - hover.value = false; -} - function onDragover(ev: DragEvent) { if (!ev.dataTransfer) return; @@ -59,10 +44,7 @@ function onDragover(ev: DragEvent) { } 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) { + if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -101,35 +83,46 @@ function onDrop(ev: DragEvent) { // ファイルだったら if (ev.dataTransfer.files.length > 0) { - for (const file of Array.from(ev.dataTransfer.files)) { - emit('upload', file, props.folder); - } + emit('upload', Array.from(ev.dataTransfer.files), props.folder); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - emit('removeFile', file.id); - misskeyApi('drive/files/update', { - fileId: file.id, - folderId: props.folder ? props.folder.id : null, - }); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + misskeyApi('drive/files/move-bulk', { + fileIds: droppedData.map(f => f.id), + folderId: props.folder ? props.folder.id : null, + }).then(() => { + globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({ + ...x, + folderId: props.folder ? props.folder.id : null, + folder: props.folder ?? null, + }))); + }); + } } //#endregion //#region ドライブのフォルダ - const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_); - if (driveFolder != null && driveFolder !== '') { - const folder = JSON.parse(driveFolder); - // 移動先が自分自身ならreject - if (props.folder && folder.id === props.folder.id) return; - emit('removeFolder', folder.id); - misskeyApi('drive/folders/update', { - folderId: folder.id, - parentId: props.folder ? props.folder.id : null, - }); + { + const droppedData = getDragData(ev, 'driveFolders'); + if (droppedData != null) { + const droppedFolder = droppedData[0]; + // 移動先が自分自身ならreject + if (props.folder && droppedFolder.id === props.folder.id) return; + misskeyApi('drive/folders/update', { + folderId: droppedFolder.id, + parentId: props.folder ? props.folder.id : null, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({ + ...x, + parentId: props.folder ? props.folder.id : null, + parent: props.folder ?? null, + }))); + }); + } } //#endregion } diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index a1f76ac563..5604f0226f 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -4,17 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> +<MkStickyContainer style="background: var(--MI_THEME-bg);"> <template #header> <nav :class="$style.nav"> <div :class="$style.navPath" @contextmenu.prevent.stop="() => {}"> <XNavFolder :class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]" :parentFolder="folder" - @move="move" - @upload="upload" - @removeFile="removeFile" - @removeFolder="removeFolder" + @click="cd(null)" + @upload="onUploadRequested" /> <template v-for="f in hierarchyFolders"> <span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> @@ -22,10 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only :folder="f" :parentFolder="folder" :class="[$style.navPathItem]" - @move="move" - @upload="upload" - @removeFile="removeFile" - @removeFolder="removeFolder" + @click="cd(f)" + @upload="onUploadRequested" /> </template> <span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span> @@ -35,20 +31,42 @@ SPDX-License-Identifier: AGPL-3.0-only </nav> </template> - <div - ref="main" - :class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]" - @dragover.prevent.stop="onDragover" - @dragenter="onDragenter" - @dragleave="onDragleave" - @drop.prevent.stop="onDrop" - @contextmenu.stop="onContextmenu" - > - <div ref="contents"> - <MkInfo v-if="!store.r.readDriveTip.value" closable @close="closeTip()"><div v-html="i18n.ts.driveAboutTip"></div></MkInfo> - <div v-show="folders.length > 0" ref="foldersContainer" :class="$style.folders"> + <div> + <div v-if="select === 'folder'"> + <template v-if="folder == null"> + <MkButton v-if="!isRootSelected" @click="isRootSelected = true"> + <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }} + </MkButton> + <MkButton v-else @click="isRootSelected = false"> + <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }} + </MkButton> + </template> + <template v-else> + <MkButton v-if="!selectedFolders.some(f => f.id === folder!.id)" @click="selectedFolders.push(folder)"> + <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }} + </MkButton> + <MkButton v-else @click="selectedFolders = selectedFolders.filter(f => f.id !== folder!.id)"> + <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }} + </MkButton> + </template> + </div> + + <div + ref="main" + :class="[$style.main, { [$style.fetching]: fetching }]" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.stop="onContextmenu" + > + <div v-if="!store.r.readDriveTip.value" style="padding: 8px;"> + <MkInfo closable @close="closeTip()"><div v-html="i18n.ts.driveAboutTip"></div></MkInfo> + </div> + + <div :class="$style.folders"> <XFolder - v-for="(f, i) in folders" + v-for="(f, i) in foldersPaginator.items.value" :key="f.id" v-anim="i" :class="$style.folder" @@ -57,49 +75,64 @@ SPDX-License-Identifier: AGPL-3.0-only :isSelected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" @unchose="unchoseFolder" - @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="$style.padding"></div> - <MkButton v-if="moreFolders" ref="moreFolders" @click="fetchMoreFolders">{{ i18n.ts.loadMore }}</MkButton> - </div> - <div v-show="files.length > 0" ref="filesContainer" :class="$style.files"> - <XFile - v-for="(file, i) in files" - :key="file.id" - v-anim="i" - :class="$style.file" - :file="file" - :folder="folder" - :selectMode="select === 'file'" - :isSelected="selectedFiles.some(x => x.id === file.id)" - @chosen="chooseFile" + @click="cd(f)" + @upload="onUploadRequested" @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="$style.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="$style.empty"> + <MkButton v-if="foldersPaginator.canFetchOlder.value" primary rounded @click="foldersPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton> + + <MkStickyContainer v-for="(item, i) in filesTimeline" :key="`${item.date.getFullYear()}/${item.date.getMonth() + 1}`"> + <template #header> + <div :class="$style.date"> + <span><i class="ti ti-chevron-down"></i> {{ item.date.getFullYear() }}/{{ item.date.getMonth() + 1 }}</span> + </div> + </template> + + <TransitionGroup + tag="div" + :enterActiveClass="prefer.s.animation ? $style.transition_files_enterActive : ''" + :leaveActiveClass="prefer.s.animation ? $style.transition_files_leaveActive : ''" + :enterFromClass="prefer.s.animation ? $style.transition_files_enterFrom : ''" + :leaveToClass="prefer.s.animation ? $style.transition_files_leaveTo : ''" + :moveClass="prefer.s.animation ? $style.transition_files_move : ''" + :class="$style.files" + > + <XFile + v-for="file in item.items" :key="file.id" + :class="$style.file" + :file="file" + :folder="folder" + :isSelected="selectedFiles.some(x => x.id === file.id)" + @click="onFileClick($event, file)" + @dragstart="onFileDragstart(file, $event)" + @dragend="isDragSource = false" + /> + </TransitionGroup> + </MkStickyContainer> + <MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton> + + <div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty"> <div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div> <div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.ts['empty-drive-description'] }}</div> <div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div> </div> </div> <MkLoading v-if="fetching"/> + <div v-if="draghover" :class="$style.dropzone"></div> </div> - <div v-if="draghover" :class="$style.dropzone"></div> + + <template #footer> + <div v-if="isEditMode" :class="$style.footer"> + <MkButton primary rounded @click="moveFilesBulk()"><i class="ti ti-folder-symlink"></i> {{ i18n.ts.move }}...</MkButton> + </div> + </template> </MkStickyContainer> </template> <script lang="ts" setup> -import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue'; +import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch, computed, TransitionGroup } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from './MkButton.vue'; import MkInfo from './MkInfo.vue'; @@ -111,14 +144,18 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; -import { uploadFile, uploads } from '@/utility/upload.js'; import { claimAchievement } from '@/utility/achievements.js'; import { prefer } from '@/preferences.js'; -import { chooseFileFromPc } from '@/utility/select-file.js'; +import { chooseFileFromPcAndUpload, selectDriveFolder } from '@/utility/drive.js'; import { store } from '@/store.js'; +import { isSeparatorNeeded, getSeparatorInfo, makeDateGroupedTimelineComputedRef } from '@/utility/timeline-date-separate.js'; +import { usePagination } from '@/composables/use-pagination.js'; +import { globalEvents, useGlobalEvent } from '@/events.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; const props = withDefaults(defineProps<{ - initialFolder?: Misskey.entities.DriveFolder; + initialFolder?: Misskey.entities.DriveFolder['id'] | null; type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; @@ -128,25 +165,13 @@ const props = withDefaults(defineProps<{ }); 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: 'changeSelectedFiles', v: Misskey.entities.DriveFile[]): void; + (ev: 'changeSelectedFolders', v: (Misskey.entities.DriveFolder | null)[]): void; (ev: 'cd', v: Misskey.entities.DriveFolder | null): void; - (ev: 'open-folder', v: Misskey.entities.DriveFolder): void; }>(); -const loadMoreFiles = useTemplateRef('loadMoreFiles'); - 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 = useStream().useChannel('drive'); // ドロップされようとしているか const draghover = ref(false); @@ -155,51 +180,87 @@ const draghover = ref(false); // (自分自身の階層にドロップできないようにするためのフラグ) const isDragSource = ref(false); -const fetching = ref(true); +const isEditMode = ref(false); + +const selectedFiles = ref<Misskey.entities.DriveFile[]>([]); +const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]); +const isRootSelected = ref(false); -const ilFilesObserver = new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(), -); +watch(selectedFiles, () => { + emit('changeSelectedFiles', selectedFiles.value); +}); + +watch([selectedFolders, isRootSelected], () => { + emit('changeSelectedFolders', isRootSelected.value ? [null, ...selectedFolders.value] : selectedFolders.value); +}); + +const fetching = ref(true); const sortModeSelect = ref<NonNullable<Misskey.entities.DriveFilesRequest['sort']>>('+createdAt'); +const filesPaginator = usePagination({ + ctx: { + endpoint: 'drive/files', + limit: 30, + canFetchDetection: 'limit', + params: computed(() => ({ + folderId: folder.value ? folder.value.id : null, + type: props.type, + sort: sortModeSelect.value, + })), + }, + autoInit: false, + autoReInit: false, +}); + +const foldersPaginator = usePagination({ + ctx: { + endpoint: 'drive/folders', + limit: 30, + canFetchDetection: 'limit', + params: computed(() => ({ + folderId: folder.value ? folder.value.id : null, + })), + }, + autoInit: false, + autoReInit: false, +}); + +const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month'); + watch(folder, () => emit('cd', folder.value)); watch(sortModeSelect, () => { - fetch(); + initialize(); }); -function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { - addFile(file, true); +async function initialize() { + fetching.value = true; + await Promise.all([ + foldersPaginator.init(), + filesPaginator.init(), + ]); + fetching.value = false; } -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 onStreamDriveFileCreated(file: Misskey.entities.DriveFile) { + if (file.folderId === (folder.value?.id ?? null)) { + filesPaginator.prepend(file); } } -function onStreamDriveFileDeleted(fileId: string) { - removeFile(fileId); -} - -function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) { - addFolder(createdFolder, true); -} +function onFileDragstart(file: Misskey.entities.DriveFile, ev: DragEvent) { + if (isEditMode.value) { + if (!selectedFiles.value.some(f => f.id === file.id)) { + selectedFiles.value.push(file); + } -function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) { - const current = folder.value ? folder.value.id : null; - if (current !== updatedFolder.parentId) { - removeFolder(updatedFolder); - } else { - addFolder(updatedFolder, true); + if (ev.dataTransfer) { + ev.dataTransfer.effectAllowed = 'move'; + setDragData(ev, 'driveFiles', selectedFiles.value); + } } -} -function onStreamDriveFolderDeleted(folderId: string) { - removeFolder(folderId); + isDragSource.value = true; } function onDragover(ev: DragEvent) { @@ -213,9 +274,7 @@ function onDragover(ev: DragEvent) { } 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) { + if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) { switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -254,109 +313,123 @@ function onDrop(ev: DragEvent) { // ドロップされてきたものがファイルだったら if (ev.dataTransfer.files.length > 0) { - for (const file of Array.from(ev.dataTransfer.files)) { - upload(file, folder.value); - } + os.launchUploader(Array.from(ev.dataTransfer.files), { + folderId: folder.value?.id ?? null, + }); 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); - misskeyApi('drive/files/update', { - fileId: file.id, - folderId: folder.value ? folder.value.id : null, - }); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + misskeyApi('drive/files/move-bulk', { + fileIds: droppedData.map(f => f.id), + folderId: folder.value ? folder.value.id : null, + }).then(() => { + globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({ + ...x, + folderId: folder.value ? folder.value.id : null, + folder: folder.value, + }))); + }); + } } //#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); - misskeyApi('drive/folders/update', { - folderId: droppedFolder.id, - parentId: folder.value ? folder.value.id : null, - }).then(() => { - // noop - }).catch(err => { - switch (err.code) { - case 'RECURSIVE_NESTING': - claimAchievement('driveFolderCircularReference'); - os.alert({ - type: 'error', - title: i18n.ts.unableToProcess, - text: i18n.ts.circularReferenceFolder, - }); - break; - default: - os.alert({ - type: 'error', - text: i18n.ts.somethingHappened, - }); - } - }); + { + const droppedData = getDragData(ev, 'driveFolders'); + if (droppedData != null) { + const droppedFolder = droppedData[0]; + // 移動先が自分自身ならreject + if (folder.value && droppedFolder.id === folder.value.id) return false; + if (foldersPaginator.items.value.some(f => f.id === droppedFolder.id)) return false; + misskeyApi('drive/folders/update', { + folderId: droppedFolder.id, + parentId: folder.value ? folder.value.id : null, + }).then(() => { + globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({ + ...x, + parentId: folder.value ? folder.value.id : null, + parent: folder.value, + }))); + }).catch(err => { + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); + os.alert({ + type: 'error', + title: i18n.ts.unableToProcess, + text: i18n.ts.circularReferenceFolder, + }); + break; + default: + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } + }); + } } //#endregion } -function urlUpload() { - os.inputText({ +function onUploadRequested(files: File[], folder: Misskey.entities.DriveFolder | null) { + os.launchUploader(files, { + folderId: folder?.id ?? null, + }); +} + +async function urlUpload() { + const { canceled, result: url } = await os.inputText({ title: i18n.ts.uploadFromUrl, type: 'url', placeholder: i18n.ts.uploadFromUrlDescription, - }).then(({ canceled, result: url }) => { - if (canceled || !url) return; - misskeyApi('drive/files/upload-from-url', { - url: url, - folderId: folder.value ? folder.value.id : undefined, - }); + }); + if (canceled || !url) return; - os.alert({ - title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime, - }); + await os.apiWithDialog('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({ +async function createFolder() { + const { canceled, result: name } = await os.inputText({ title: i18n.ts.createFolder, placeholder: i18n.ts.folderName, - }).then(({ canceled, result: name }) => { - if (canceled || name == null) return; - misskeyApi('drive/folders/create', { - name: name, - parentId: folder.value ? folder.value.id : undefined, - }).then(createdFolder => { - addFolder(createdFolder, true); - }); }); + if (canceled || name == null) return; + + const createdFolder = await os.apiWithDialog('drive/folders/create', { + name: name, + parentId: folder.value ? folder.value.id : undefined, + }); + + foldersPaginator.prepend(createdFolder); } -function renameFolder(folderToRename: Misskey.entities.DriveFolder) { - os.inputText({ +async function renameFolder(folderToRename: Misskey.entities.DriveFolder) { + const { canceled, result: name } = await os.inputText({ title: i18n.ts.renameFolder, placeholder: i18n.ts.inputNewFolderName, default: folderToRename.name, - }).then(({ canceled, result: name }) => { - if (canceled) return; - misskeyApi('drive/folders/update', { - folderId: folderToRename.id, - name: name, - }).then(updatedFolder => { - // FIXME: 画面を更新するために自分自身に移動 - move(updatedFolder); - }); }); + if (canceled) return; + + const updatedFolder = await os.apiWithDialog('drive/folders/update', { + folderId: folderToRename.id, + name: name, + }); + + globalEvents.emit('driveFoldersUpdated', [updatedFolder]); } function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { @@ -364,7 +437,8 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { folderId: folderToDelete.id, }).then(() => { // 削除時に親フォルダに移動 - move(folderToDelete.parentId); + cd(folderToDelete.parentId); + globalEvents.emit('driveFoldersDeleted', [folderToDelete]); }).catch(err => { switch (err.id) { case 'b0fc8a17-963c-405d-bfbc-859a487295e1': @@ -383,28 +457,38 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { }); } -function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null, keepOriginal?: boolean) { - uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal).then(res => { - addFile(res, true); - }); -} +function onFileClick(ev: MouseEvent, file: Misskey.entities.DriveFile) { + if (ev.shiftKey) { + isEditMode.value = 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); + if (props.select === 'file' || isEditMode.value) { + const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id); + + if (isEditMode.value) { + if (isAlreadySelected) { + selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); + } else { + selectedFiles.value.push(file); + } + return; } - emit('change-selection', selectedFiles.value); - } else { - if (isAlreadySelected) { - emit('selected', file); + + if (props.multiple) { + if (isAlreadySelected) { + selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id); + } else { + selectedFiles.value.push(file); + } } else { - selectedFiles.value = [file]; - emit('change-selection', [file]); + if (isAlreadySelected) { + //emit('selected', file); + } else { + selectedFiles.value = [file]; + } } + } else { + os.popupMenu(getDriveFileMenu(file, folder.value), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } } @@ -416,23 +500,20 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) { } else { selectedFolders.value.push(folderToChoose); } - emit('change-selection', selectedFolders.value); } else { if (isAlreadySelected) { - emit('selected', folderToChoose); + //emit('selected', folderToChoose); } else { selectedFolders.value = [folderToChoose]; - emit('change-selection', [folderToChoose]); } } } function unchoseFolder(folderToUnchose: Misskey.entities.DriveFolder) { selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToUnchose.id); - emit('change-selection', selectedFolders.value); } -function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { +function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) { if (!target) { goRoot(); return; @@ -455,168 +536,34 @@ function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFold if (folderToMove.parent) dive(folderToMove.parent); - emit('open-folder', folderToMove); - fetch(); + initialize(); }); } -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); -} +async function moveFilesBulk() { + if (selectedFiles.value.length === 0) return; -function appendFile(file: Misskey.entities.DriveFile) { - addFile(file); -} + const toFolder = await selectDriveFolder(folder.value ? folder.value.id : null); -function appendFolder(folderToAppend: Misskey.entities.DriveFolder) { - addFolder(folderToAppend); -} + await os.apiWithDialog('drive/files/move-bulk', { + fileIds: selectedFiles.value.map(f => f.id), + folderId: toFolder[0] ? toFolder[0].id : null, + }); -/* -function prependFile(file: Misskey.entities.DriveFile) { - addFile(file, true); + globalEvents.emit('driveFilesUpdated', selectedFiles.value.map(x => ({ + ...x, + folderId: toFolder[0] ? toFolder[0].id : null, + folder: toFolder[0] ?? null, + }))); } -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 = misskeyApi('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 = misskeyApi('drive/files', { - folderId: folder.value ? folder.value.id : null, - type: props.type, - limit: filesMax + 1, - sort: sortModeSelect.value, - }).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 fetchMoreFolders() { - fetching.value = true; - - const max = 30; - - misskeyApi('drive/folders', { - folderId: folder.value ? folder.value.id : null, - type: props.type, - untilId: folders.value.at(-1)?.id, - limit: max + 1, - }).then(folders => { - if (folders.length === max + 1) { - moreFolders.value = true; - folders.pop(); - } else { - moreFolders.value = false; - } - for (const x of folders) appendFolder(x); - fetching.value = false; - }); -} - -function fetchMoreFiles() { - fetching.value = true; - - const max = 30; - - // ファイル一覧取得 - misskeyApi('drive/files', { - folderId: folder.value ? folder.value.id : null, - type: props.type, - untilId: files.value.at(-1)?.id, - limit: max + 1, - sort: sortModeSelect.value, - }).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; - }); + initialize(); } function getMenu() { @@ -626,16 +573,13 @@ function getMenu() { text: i18n.ts.addFile, type: 'label', }, { - text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', - icon: 'ti ti-upload', - action: () => { - chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false }); - }, - }, { text: i18n.ts.upload, icon: 'ti ti-upload', action: () => { - chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true }); + chooseFileFromPcAndUpload({ + multiple: true, + folderId: folder.value?.id, + }); }, }, { text: i18n.ts.fromUrl, @@ -699,6 +643,11 @@ function getMenu() { text: i18n.ts.createFolder, icon: 'ti ti-folder-plus', action: () => { createFolder(); }, + }, { type: 'divider' }, { + type: 'switch', + text: i18n.ts.edit, + icon: 'ti ti-pointer', + ref: isEditMode, }); return menu; @@ -716,42 +665,95 @@ function closeTip() { store.set('readDriveTip', true); } -onMounted(() => { - if (prefer.s.enableInfiniteScroll && loadMoreFiles.value) { - nextTick(() => { - ilFilesObserver.observe(loadMoreFiles.value?.$el); - }); +useGlobalEvent('driveFileCreated', (file) => { + if (file.folderId === (folder.value?.id ?? null)) { + filesPaginator.prepend(file); + } +}); + +useGlobalEvent('driveFilesUpdated', (files) => { + for (const f of files) { + if (filesPaginator.items.value.some(x => x.id === f.id)) { + if (f.folderId === (folder.value?.id ?? null)) { + filesPaginator.updateItem(f.id, () => f); + } else { + filesPaginator.removeItem(f.id); + } + } else { + if (f.folderId === (folder.value?.id ?? null)) { + filesPaginator.prepend(f); + } + } + } +}); + +useGlobalEvent('driveFilesDeleted', (files) => { + for (const f of files) { + filesPaginator.removeItem(f.id); + } +}); + +useGlobalEvent('driveFoldersUpdated', (folders) => { + for (const f of folders) { + if (foldersPaginator.items.value.some(x => x.id === f.id)) { + if (f.parentId === (folder.value?.id ?? null)) { + foldersPaginator.updateItem(f.id, () => f); + } else { + foldersPaginator.removeItem(f.id); + } + } else { + if (f.parentId === (folder.value?.id ?? null)) { + foldersPaginator.prepend(f); + } + } + } +}); + +useGlobalEvent('driveFoldersDeleted', (folders) => { + for (const f of folders) { + foldersPaginator.removeItem(f.id); } +}); + +let connection: Misskey.ChannelConnection<Misskey.Channels['drive']> | null = null; - connection.on('fileCreated', onStreamDriveFileCreated); - connection.on('fileUpdated', onStreamDriveFileUpdated); - connection.on('fileDeleted', onStreamDriveFileDeleted); - connection.on('folderCreated', onStreamDriveFolderCreated); - connection.on('folderUpdated', onStreamDriveFolderUpdated); - connection.on('folderDeleted', onStreamDriveFolderDeleted); +onMounted(() => { + if (store.s.realtimeMode) { + connection = useStream().useChannel('drive'); + connection.on('fileCreated', onStreamDriveFileCreated); + } if (props.initialFolder) { - move(props.initialFolder); + cd(props.initialFolder); } else { - fetch(); + initialize(); } }); onActivated(() => { - if (prefer.s.enableInfiniteScroll) { - nextTick(() => { - ilFilesObserver.observe(loadMoreFiles.value?.$el); - }); - } }); onBeforeUnmount(() => { - connection.dispose(); - ilFilesObserver.disconnect(); + if (connection != null) { + connection.dispose(); + } }); </script> <style lang="scss" module> +.transition_files_move, +.transition_files_enterActive, +.transition_files_leaveActive { + transition: all 0.2s ease; +} +.transition_files_enterFrom, +.transition_files_leaveTo { + opacity: 0; +} +.transition_files_leaveActive { + position: absolute; +} + .nav { display: flex; width: 100%; @@ -806,9 +808,7 @@ onBeforeUnmount(() => { } .main { - flex: 1; - overflow: auto; - padding: var(--MI-margin); + min-height: 100cqh; user-select: none; &.fetching { @@ -816,30 +816,41 @@ onBeforeUnmount(() => { opacity: 0.5; pointer-events: none; } - - &.uploading { - height: calc(100% - 38px - 100px); - } } .folders, .files { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + grid-gap: 12px; + padding: 16px 32px; } -.folder, -.file { - flex-grow: 1; - width: 128px; - margin: 4px; - box-sizing: border-box; +@container (max-width: 600px) { + .folders, + .files { + padding: 16px; + } } -.padding { - flex-grow: 1; - pointer-events: none; - width: 128px + 8px; +.date { + padding: 8px 16px; + font-size: 90%; + -webkit-backdrop-filter: var(--MI-blur, blur(8px)); + backdrop-filter: var(--MI-blur, blur(8px)); + background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.85); +} + +.loadMore { + margin: 16px auto; +} + +.footer { + padding: 8px 16px; + font-size: 90%; + -webkit-backdrop-filter: var(--MI-blur, blur(8px)); + backdrop-filter: var(--MI-blur, blur(8px)); + background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.85); } .empty { diff --git a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkDriveFileSelectDialog.stories.impl.ts index fe8f705165..a5073337cd 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkDriveFileSelectDialog.stories.impl.ts @@ -3,5 +3,5 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import MkDriveSelectDialog from './MkDriveSelectDialog.vue'; +import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue'; void MkDriveSelectDialog; diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveFileSelectDialog.vue index 1b9455e3f3..50b68b3d0f 100644 --- a/packages/frontend/src/components/MkDriveSelectDialog.vue +++ b/packages/frontend/src/components/MkDriveFileSelectDialog.vue @@ -9,43 +9,41 @@ SPDX-License-Identifier: AGPL-3.0-only :width="800" :height="500" :withOkButton="true" - :okButtonDisabled="(type === 'file') && (selected.length === 0)" + :okButtonDisabled="selected.length === 0" @click="cancel()" @close="cancel()" @ok="ok()" @closed="emit('closed')" > <template #header> - {{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }} - <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span> + {{ multiple ? i18n.ts.selectFiles : i18n.ts.selectFile }} + <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span> </template> - <XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/> + <MkDrive :multiple="multiple" select="file" :initialFolder="initialFolder" @changeSelectedFiles="onChangeSelection"/> </MkModalWindow> </template> <script lang="ts" setup> import { ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from '@/components/MkDrive.vue'; +import MkDrive from '@/components/MkDrive.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; -import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; withDefaults(defineProps<{ - type?: 'file' | 'folder'; + initialFolder?: Misskey.entities.DriveFolder['id'] | null; multiple: boolean; }>(), { - type: 'file', }); const emit = defineEmits<{ - (ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void; + (ev: 'done', r?: Misskey.entities.DriveFile[]): void; (ev: 'closed'): void; }>(); const dialog = useTemplateRef('dialog'); -const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]); +const selected = ref<Misskey.entities.DriveFile[]>([]); function ok() { emit('done', selected.value); @@ -57,7 +55,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) { +function onChangeSelection(v: Misskey.entities.DriveFile[]) { selected.value = v; } </script> diff --git a/packages/frontend/src/components/MkDriveFolderSelectDialog.vue b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue new file mode 100644 index 0000000000..2ebab1088f --- /dev/null +++ b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue @@ -0,0 +1,63 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="800" + :height="500" + :withOkButton="true" + :okButtonDisabled="selected.length === 0" + @click="cancel()" + @close="cancel()" + @ok="ok()" + @closed="emit('closed')" +> + <template #header> + {{ multiple ? i18n.ts.selectFolders : i18n.ts.selectFolder }} + <span v-if="multiple && selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span> + </template> + <MkDrive :multiple="multiple" select="folder" :initialFolder="initialFolder" @changeSelectedFolders="onChangeSelection"/> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { ref, useTemplateRef } from 'vue'; +import * as Misskey from 'misskey-js'; +import MkDrive from '@/components/MkDrive.vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n.js'; + +withDefaults(defineProps<{ + initialFolder?: Misskey.entities.DriveFolder['id'] | null; + multiple?: boolean; +}>(), { + initialFolder: null, + multiple: false, +}); + +const emit = defineEmits<{ + (ev: 'done', r?: Misskey.entities.DriveFolder[]): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +const selected = ref<Misskey.entities.DriveFolder[]>([]); + +function ok() { + emit('done', selected.value); + dialog.value?.close(); +} + +function cancel() { + emit('done'); + dialog.value?.close(); +} + +function onChangeSelection(v: Misskey.entities.DriveFolder[]) { + selected.value = v; +} +</script> diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue index c0142ec76e..0b8d0bfb8a 100644 --- a/packages/frontend/src/components/MkDriveWindow.vue +++ b/packages/frontend/src/components/MkDriveWindow.vue @@ -14,19 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header> {{ i18n.ts.drive }} </template> - <XDrive :initialFolder="initialFolder"/> + <MkDrive :initialFolder="initialFolder"/> </MkWindow> </template> <script lang="ts" setup> import { } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from '@/components/MkDrive.vue'; +import MkDrive from '@/components/MkDrive.vue'; import MkWindow from '@/components/MkWindow.vue'; import { i18n } from '@/i18n.js'; defineProps<{ - initialFolder?: Misskey.entities.DriveFolder; + initialFolder?: Misskey.entities.DriveFolder | null; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue index 0a902f3400..a11075c342 100644 --- a/packages/frontend/src/components/MkFormDialog.file.vue +++ b/packages/frontend/src/components/MkFormDialog.file.vue @@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { misskeyApi } from '@/utility/misskey-api.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index 19989e375b..6b81e353e6 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> <div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }"> - <div ref="headerEl" :class="$style.header"> + <div :class="$style.header"> <button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button> <span :class="$style.title"> <slot name="header"></slot> @@ -15,7 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button> </div> <div :class="$style.body"> - <slot :width="bodyWidth" :height="bodyHeight"></slot> + <slot></slot> + </div> + <div v-if="$slots.footer" :class="$style.footer"> + <slot name="footer"></slot> </div> </div> </MkModal> @@ -48,10 +51,6 @@ const emit = defineEmits<{ }>(); const modal = useTemplateRef('modal'); -const rootEl = useTemplateRef('rootEl'); -const headerEl = useTemplateRef('headerEl'); -const bodyWidth = ref(0); -const bodyHeight = ref(0); function close() { modal.value?.close(); @@ -61,23 +60,6 @@ function onBgClick() { emit('click'); } -const ro = new ResizeObserver((entries, observer) => { - if (rootEl.value == null || headerEl.value == null) return; - bodyWidth.value = rootEl.value.offsetWidth; - bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; -}); - -onMounted(() => { - if (rootEl.value == null || headerEl.value == null) return; - bodyWidth.value = rootEl.value.offsetWidth; - bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; - ro.observe(rootEl.value); -}); - -onUnmounted(() => { - ro.disconnect(); -}); - defineExpose({ close, }); @@ -143,7 +125,14 @@ defineExpose({ .body { flex: 1; overflow: auto; - background: var(--MI_THEME-panel); + background: var(--MI_THEME-bg); container-type: size; } + +.footer { + padding: 8px 16px; + overflow: auto; + background: var(--MI_THEME-bg); + border-top: 1px solid var(--MI_THEME-divider); +} </style> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 5114e98494..d07ee2a978 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> - <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> + <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <div v-if="showingOptions" style="padding: 8px 16px;"> @@ -120,14 +120,13 @@ import { formatTimeString } from '@/utility/format-time-string.js'; import { Autocomplete } from '@/utility/autocomplete.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFiles } from '@/utility/select-file.js'; +import { selectFiles } from '@/utility/drive.js'; import { store } from '@/store.js'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { ensureSignin, notesCount, incNotesCount } from '@/i.js'; import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; -import { uploadFile } from '@/utility/upload.js'; import { deepClone } from '@/utility/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { miLocalStorage } from '@/local-storage.js'; @@ -138,6 +137,7 @@ import { prefer } from '@/preferences.js'; import { getPluginHandlers } from '@/plugin.js'; import { DI } from '@/di.js'; import { globalEvents } from '@/events.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const $i = ensureSignin(); @@ -459,18 +459,6 @@ function updateFileName(file, name) { files.value[files.value.findIndex(x => x.id === file.id)].name = name; } -function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void { - files.value[files.value.findIndex(x => x.id === file.id)] = newFile; -} - -function upload(file: File, name?: string): void { - if (props.mock) return; - - uploadFile(file, prefer.s.uploadFolder, name).then(res => { - files.value.push(res); - }); -} - function setVisibility() { if (props.channel) { visibility.value = 'public'; @@ -651,16 +639,25 @@ async function onPaste(ev: ClipboardEvent) { if (props.mock) return; if (!ev.clipboardData) return; + let pastedFiles: File[] = []; for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) { if (item.kind === 'file') { const file = item.getAsFile(); if (!file) continue; const lio = file.name.lastIndexOf('.'); const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; - upload(file, formatted); + const formattedName = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`; + const renamedFile = new File([file], formattedName, { type: file.type }); + pastedFiles.push(renamedFile); } } + if (pastedFiles.length > 0) { + ev.preventDefault(); + os.launchUploader(pastedFiles, {}).then(driveFiles => { + files.value.push(...driveFiles); + }); + return; + } const paste = ev.clipboardData.getData('text'); @@ -693,7 +690,9 @@ async function onPaste(ev: ClipboardEvent) { const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0'); const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' }); - upload(file, `${fileName}.txt`); + os.launchUploader([file], {}).then(driveFiles => { + files.value.push(...driveFiles); + }); }); } } @@ -701,8 +700,7 @@ async function onPaste(ev: ClipboardEvent) { function onDragover(ev) { if (!ev.dataTransfer.items[0]) return; const isFile = ev.dataTransfer.items[0].kind === 'file'; - const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { + if (isFile || checkDragDataType(ev, ['driveFiles'])) { ev.preventDefault(); draghover.value = true; switch (ev.dataTransfer.effectAllowed) { @@ -738,16 +736,19 @@ function onDrop(ev: DragEvent): void { // ファイルだったら if (ev.dataTransfer && ev.dataTransfer.files.length > 0) { ev.preventDefault(); - for (const x of Array.from(ev.dataTransfer.files)) upload(x); + os.launchUploader(Array.from(ev.dataTransfer.files), {}).then(driveFiles => { + files.value.push(...driveFiles); + }); return; } //#region ドライブのファイル - const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - files.value.push(file); - ev.preventDefault(); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + files.value.push(...droppedData); + ev.preventDefault(); + } } //#endregion } diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue index e8404cbd4f..dd594ef7f1 100644 --- a/packages/frontend/src/components/MkPostFormAttaches.vue +++ b/packages/frontend/src/components/MkPostFormAttaches.vue @@ -43,6 +43,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { globalEvents } from '@/events.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -58,7 +59,6 @@ const emit = defineEmits<{ (ev: 'detach', id: string): void; (ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void; (ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void; - (ev: 'replaceFile', file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void; }>(); let menuShowing = false; @@ -82,12 +82,13 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), }); - if (canceled) return; - os.apiWithDialog('drive/files/delete', { + await os.apiWithDialog('drive/files/delete', { fileId: file.id, }); + + globalEvents.emit('driveFilesDeleted', [file]); } function toggleSensitive(file) { @@ -142,13 +143,6 @@ async function describe(file: Misskey.entities.DriveFile) { }); } -async function crop(file: Misskey.entities.DriveFile): Promise<void> { - if (mock) return; - - const newFile = await os.cropImage(file, { aspectRatio: NaN }); - emit('replaceFile', file, newFile); -} - function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void { if (menuShowing) return; @@ -172,10 +166,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar if (isImage) { menuItems.push({ - text: i18n.ts.cropImage, - icon: 'ti ti-crop', - action: () : void => { crop(file); }, - }, { text: i18n.ts.preview, icon: 'ti ti-photo-search', action: () => { diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index d8dfbd1655..6c7bf6be6b 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio> </div> <div :class="$style.preview__content1__button"> - <MkButton inline>This is</MkButton> - <MkButton inline primary>the button</MkButton> + <MkButton inline>This is</MkButton> + <MkButton inline primary>the button</MkButton> </div> </div> <div :class="$style.preview__content2" style="pointer-events: none;"> @@ -36,14 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref } from 'vue'; +import * as config from '@@/js/config.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkRadio from '@/components/MkRadio.vue'; import * as os from '@/os.js'; -import * as config from '@@/js/config.js'; import { $i } from '@/i.js'; +import { chooseDriveFile } from '@/utility/drive.js'; const text = ref(''); const flag = ref(true); @@ -79,7 +80,9 @@ const openForm = async () => { }; const openDrive = async () => { - await os.selectDriveFile(false); + await chooseDriveFile({ + multiple: false, + }); }; const selectUser = async () => { diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue new file mode 100644 index 0000000000..3a83247d4b --- /dev/null +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -0,0 +1,505 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="800" + :height="500" + @close="cancel()" + @closed="emit('closed')" +> + <template #header> + <i class="ti ti-upload"></i> {{ i18n.tsx.uploadNFiles({ n: files.length }) }} + </template> + + <div :class="$style.root"> + <div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div> + + <div :class="$style.main" class="_gaps_s"> + <div :class="$style.items" class="_gaps_s"> + <div + v-for="ctx in items" + :key="ctx.id" + v-panel + :class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]" + :style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }" + > + <div :class="$style.itemInner"> + <div :class="$style.itemActionWrapper"> + <MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton> + </div> + <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div> + <div :class="$style.itemBody"> + <div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div> + <div :class="$style.itemInfo"> + <span>{{ ctx.file.type }}</span> + <span>{{ bytes(ctx.file.size) }}</span> + <span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span> + </div> + <div> + </div> + </div> + <div :class="$style.itemIconWrapper"> + <MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/> + <MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/> + <MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/> + </div> + </div> + </div> + </div> + + <div v-if="props.multiple"> + <MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton> + </div> + + <MkSelect + v-if="items.length > 0" + v-model="compressionLevel" + :items="[ + { value: 0, label: i18n.ts.none }, + { value: 1, label: i18n.ts.low }, + { value: 2, label: i18n.ts.middle }, + { value: 3, label: i18n.ts.high }, + ]" + > + <template #label>{{ i18n.ts.compress }}</template> + </MkSelect> + + <div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div> + </div> + </div> + + <template #footer> + <div class="_buttonsCenter"> + <MkButton v-if="isUploading" rounded @click="cancel()"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton> + <MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> + + <MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton> + <MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton> + </div> + </template> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import { v4 as uuid } from 'uuid'; +import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; +import isAnimated from 'is-file-animated'; +import type { MenuItem } from '@/types/menu.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import MkButton from '@/components/MkButton.vue'; +import bytes from '@/filters/bytes.js'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import { isWebpSupported } from '@/utility/isWebpSupported.js'; +import { uploadFile } from '@/utility/drive.js'; +import * as os from '@/os.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const COMPRESSION_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/svg+xml', +]; + +const CROPPING_SUPPORTED_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/webp', +]; + +const mimeTypeMap = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +} as const; + +const props = withDefaults(defineProps<{ + files: File[]; + folderId?: string | null; + multiple?: boolean; +}>(), { + multiple: true, +}); + +const emit = defineEmits<{ + (ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void; + (ev: 'canceled'): void; + (ev: 'closed'): void; +}>(); + +const items = ref([] as { + id: string; + name: string; + progress: { max: number; value: number } | null; + thumbnail: string; + waiting: boolean; + uploading: boolean; + uploaded: Misskey.entities.DriveFile | null; + uploadFailed: boolean; + compressedSize?: number | null; + compressedImage?: Blob | null; + file: File; +}[]); + +const dialog = useTemplateRef('dialog'); + +const firstUploadAttempted = ref(false); +const isUploading = computed(() => items.value.some(item => item.uploading)); +const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null)); +const canDone = computed(() => items.value.some(item => item.uploaded != null)); +const overallProgress = computed(() => { + const max = items.value.length; + if (max === 0) return 0; + const v = items.value.reduce((acc, item) => { + if (item.uploaded) return acc + 1; + if (item.progress) return acc + (item.progress.value / item.progress.max); + return acc; + }, 0); + return Math.round((v / max) * 100); +}); + +const compressionLevel = ref<0 | 1 | 2 | 3>(2); +const compressionSettings = computed(() => { + if (compressionLevel.value === 1) { + return { + maxWidth: 2000, + maxHeight: 2000, + }; + } else if (compressionLevel.value === 2) { + return { + maxWidth: 2000 * 0.75, // =1500 + maxHeight: 2000 * 0.75, // =1500 + }; + } else if (compressionLevel.value === 3) { + return { + maxWidth: 2000 * 0.75 * 0.75, // =1125 + maxHeight: 2000 * 0.75 * 0.75, // =1125 + }; + } else { + return null; + } +}); + +watch(items, () => { + if (items.value.length === 0) { + emit('canceled'); + dialog.value?.close(); + return; + } + + if (items.value.every(item => item.uploaded)) { + emit('done', items.value.map(item => item.uploaded!)); + dialog.value?.close(); + } +}, { deep: true }); + +async function cancel() { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._uploader.abortConfirm, + okText: i18n.ts.yes, + cancelText: i18n.ts.no, + }); + if (canceled) return; + + emit('canceled'); + dialog.value?.close(); +} + +async function done() { + if (items.value.some(item => item.uploaded == null)) { + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts._uploader.doneConfirm, + okText: i18n.ts.yes, + cancelText: i18n.ts.no, + }); + if (canceled) return; + } + + emit('done', items.value.filter(item => item.uploaded != null).map(item => item.uploaded!)); + dialog.value?.close(); +} + +function showMenu(ev: MouseEvent, item: typeof items.value[0]) { + const menu: MenuItem[] = []; + + if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) { + menu.push({ + icon: 'ti ti-crop', + text: i18n.ts.cropImage, + action: async () => { + const cropped = await os.cropImageFile(item.file, { aspectRatio: null }); + items.value.splice(items.value.indexOf(item), 1, { + ...item, + file: markRaw(cropped), + thumbnail: window.URL.createObjectURL(cropped), + }); + }, + }); + } + + if (!item.waiting && !item.uploading && !item.uploaded) { + menu.push({ + icon: 'ti ti-x', + text: i18n.ts.remove, + action: () => { + items.value.splice(items.value.indexOf(item), 1); + }, + }); + } + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + +async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる + firstUploadAttempted.value = true; + + for (const item of items.value.filter(item => item.uploaded == null)) { + item.waiting = true; + item.uploadFailed = false; + + const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file)); + + if (shouldCompress) { + const config = { + mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', + maxWidth: compressionSettings.value.maxWidth, + maxHeight: compressionSettings.value.maxHeight, + quality: isWebpSupported() ? 0.85 : 0.8, + }; + + try { + const result = await readAndCompressImage(item.file, config); + if (result.size < item.file.size || item.file.type === 'image/webp') { + // The compression may not always reduce the file size + // (and WebP is not browser safe yet) + item.compressedImage = markRaw(result); + item.compressedSize = result.size; + item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; + } + } catch (err) { + console.error('Failed to resize image', err); + } + } + + item.uploading = true; + + const driveFile = await uploadFile(item.compressedImage ?? item.file, { + name: item.name, + folderId: props.folderId, + onProgress: (progress) => { + item.waiting = false; + if (item.progress == null) { + item.progress = { max: progress.total, value: progress.loaded }; + } else { + item.progress.value = progress.loaded; + item.progress.max = progress.total; + } + }, + }).catch(err => { + item.uploadFailed = true; + item.progress = null; + throw err; + }).finally(() => { + item.uploading = false; + item.waiting = false; + }); + + item.uploaded = driveFile; + } +} + +async function chooseFile(ev: MouseEvent) { + const newFiles = await os.chooseFileFromPc({ multiple: true }); + + for (const file of newFiles) { + initializeFile(file); + } +} + +function initializeFile(file: File) { + const id = uuid(); + const filename = file.name ?? 'untitled'; + const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; + items.value.push({ + id, + name: prefer.s.keepOriginalFilename ? filename : id + extension, + progress: null, + thumbnail: window.URL.createObjectURL(file), + waiting: false, + uploading: false, + uploaded: null, + uploadFailed: false, + file: markRaw(file), + }); +} + +onMounted(() => { + for (const file of props.files) { + initializeFile(file); + } +}); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.overallProgress { + position: absolute; + top: 0; + left: 0; + width: var(--op); + height: 4px; + background: var(--MI_THEME-accent); + border-radius: 0 999px 999px 0; + transition: width 0.2s ease; + + &.overallProgressError { + background: var(--MI_THEME-warn); + } +} + +.main { + padding: 12px; +} + +.items { +} + +.item { + position: relative; + border-radius: 10px; + overflow: clip; + + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: var(--p); + height: 100%; + background: color(from var(--MI_THEME-accent) srgb r g b / 0.5); + transition: width 0.2s ease, left 0.2s ease; + } + + &.itemWaiting { + &::after { + --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25); + + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); + background-size: 25px 25px; + animation: stripe .8s infinite linear; + } + } + + &.itemCompleted { + &::before { + left: 100%; + width: var(--p); + } + + .itemBody { + color: var(--MI_THEME-accent); + } + } + + &.itemFailed { + .itemBody { + color: var(--MI_THEME-error); + } + } +} + +@keyframes stripe { + 0% { background-position-x: 0; } + 100% { background-position-x: -25px; } +} + +.itemInner { + position: relative; + z-index: 1; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.itemThumbnail { + width: 70px; + height: 70px; + background-color: var(--MI_THEME-bg); + background-size: contain; + background-position: center; + background-repeat: no-repeat; + border-radius: 6px; +} + +.itemBody { + flex: 1; + min-width: 0; +} + +.itemInfo { + opacity: 0.7; + margin-top: 4px; + font-size: 90%; + display: flex; + gap: 8px; +} + +.itemIcon { + width: 35px; +} + +@container (max-width: 500px) { + .itemInner { + flex-direction: column; + gap: 8px; + } + + .itemBody { + font-size: 90%; + text-align: center; + width: 100%; + min-width: 0; + } + + .itemActionWrapper { + position: absolute; + top: 8px; + left: 8px; + } + + .itemInfo { + justify-content: center; + } + + .itemIconWrapper { + position: absolute; + top: 8px; + right: 8px; + } +} +</style> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 30925b854c..4e96eff82e 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -37,7 +37,6 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FormSlot from '@/components/form/slot.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { chooseFileFromPc } from '@/utility/select-file.js'; import * as os from '@/os.js'; import { ensureSignin } from '@/i.js'; @@ -49,7 +48,7 @@ const description = ref($i.description ?? ''); watch(name, () => { os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: name.value || null, }, undefined, { '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': { @@ -62,36 +61,37 @@ watch(name, () => { watch(description, () => { os.apiWithDialog('i/update', { // 空文字列をnullにしたいので??は使うな - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + description: description.value || null, }); }); -function setAvatar(ev) { - chooseFileFromPc(false).then(async (files) => { - const file = files[0]; +async function setAvatar(ev) { + const files = await os.chooseFileFromPc({ multiple: false }); + const file = files[0]; - let originalOrCropped = file; + let originalOrCropped = file; - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.cropImageAsk, - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.ts.cropImageAsk, + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); + + if (!canceled) { + originalOrCropped = await os.cropImageFile(file, { + aspectRatio: 1, }); + } - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 1, - }); - } + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; - const i = await os.apiWithDialog('i/update', { - avatarId: originalOrCropped.id, - }); - $i.avatarId = i.avatarId; - $i.avatarUrl = i.avatarUrl; + const i = await os.apiWithDialog('i/update', { + avatarId: driveFile.id, }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; } </script> diff --git a/packages/frontend/src/components/global/MkSystemIcon.vue b/packages/frontend/src/components/global/MkSystemIcon.vue index 3454cdc9f2..d2ef0fb2d8 100644 --- a/packages/frontend/src/components/global/MkSystemIcon.vue +++ b/packages/frontend/src/components/global/MkSystemIcon.vue @@ -28,13 +28,17 @@ SPDX-License-Identifier: AGPL-3.0-only <path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/> <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/> </svg> +<svg v-else-if="type === 'waiting'" :class="[$style.icon, $style.waiting]" viewBox="0 0 160 160"> + <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleWaiting]"/> + <circle cx="80" cy="80" r="56" style="opacity: 0.25;" :class="[$style.line]"/> +</svg> </template> <script lang="ts" setup> import {} from 'vue'; const props = defineProps<{ - type: 'info' | 'question' | 'success' | 'warn' | 'error'; + type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting'; }>(); </script> @@ -62,6 +66,10 @@ const props = defineProps<{ &.error { color: var(--MI_THEME-error); } + + &.waiting { + color: var(--MI_THEME-accent); + } } .line { @@ -87,6 +95,13 @@ const props = defineProps<{ transform: rotate(-90deg); } +.animCircleWaiting { + stroke-dasharray: var(--l); + stroke-dashoffset: calc(var(--l) / 1.5); + animation: waiting 0.75s linear infinite; + transform-origin: center; +} + .animFade { opacity: 0; animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; @@ -104,6 +119,15 @@ const props = defineProps<{ } } +@keyframes waiting { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + @keyframes fade-in { 0% { opacity: 0; |