diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-05-21 07:31:24 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-21 07:31:24 +0900 |
| commit | 9480120eba1db238072b0bdfc9e6d01b2494cb3b (patch) | |
| tree | 3a4d7963e7dd9e540713d6f2b26fc98e2c586223 /packages | |
| parent | enhance(frontend): URLプレビューをユーザーサイドで無効化で... (diff) | |
| download | misskey-9480120eba1db238072b0bdfc9e6d01b2494cb3b.tar.gz misskey-9480120eba1db238072b0bdfc9e6d01b2494cb3b.tar.bz2 misskey-9480120eba1db238072b0bdfc9e6d01b2494cb3b.zip | |
Feat: ドライブ周りのUIの強化 (#16011)
* wip
* wip
* Update MkDrive.vue
* wip
* Update MkDrive.vue
* Update MkDrive.vue
* wip
* Update MkDrive.vue
* Update MkDrive.vue
* wip
* Update MkDrive.vue
* wip
* wip
* wip
* wip
* Update MkDrive.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* feat(frontend): upload dialog (#16032)
* wip
* wip
* Update MkUploadDialog.vue
* wip
* wip
* wip
* wip
* wip
* Update MkUploadDialog.vue
* wip
* wip
* Update MkDrive.vue
* wip
* wip
* Update MkPostForm.vue
* wip
* Update room.form.vue
* Update os.ts
* wiop
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update select-file.ts
* wip
* wip
* Update MkDrive.vue
* Update drag-and-drop.ts
* wip
* wip
* wop
* wip
* wip
* Update MkDrive.vue
* Update CHANGELOG.md
* wipo
* Update MkDrive.folder.vue
* wip
* Update MkUploaderDialog.vue
* wip
* wip
* Update MkUploaderDialog.vue
* wip
* Update MkDrive.vue
* Update MkDrive.vue
* wip
* wip
Diffstat (limited to 'packages')
69 files changed, 1924 insertions, 1433 deletions
diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 5f1e373429..0d5ac022aa 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import sharp from 'sharp'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; -import { IsNull } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; @@ -721,6 +721,21 @@ export class DriveService { } @bindThis + public async moveFiles(fileIds: MiDriveFile['id'][], folderId: MiDriveFolder['id'] | null, userId: MiUser['id']) { + const folder = folderId ? await this.driveFoldersRepository.findOneByOrFail({ + id: folderId, + userId: userId, + }) : null; + + await this.driveFilesRepository.update({ + id: In(fileIds), + userId: userId, + }, { + folderId: folder ? folder.id : null, + }); + } + + @bindThis public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) { if (file.storedInternal) { this.internalStorageService.del(file.accessKey!); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index bd466b3cad..1fdd000fdf 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -175,6 +175,7 @@ export * as 'drive/files/find' from './endpoints/drive/files/find.js'; export * as 'drive/files/find-by-hash' from './endpoints/drive/files/find-by-hash.js'; export * as 'drive/files/show' from './endpoints/drive/files/show.js'; export * as 'drive/files/update' from './endpoints/drive/files/update.js'; +export * as 'drive/files/move-bulk' from './endpoints/drive/files/move-bulk.js'; export * as 'drive/files/upload-from-url' from './endpoints/drive/files/upload-from-url.js'; export * as 'drive/folders' from './endpoints/drive/folders.js'; export * as 'drive/folders/create' from './endpoints/drive/folders/create.js'; diff --git a/packages/backend/src/server/api/endpoints/drive/files/move-bulk.ts b/packages/backend/src/server/api/endpoints/drive/files/move-bulk.ts new file mode 100644 index 0000000000..c8500895eb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files/move-bulk.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { DriveService } from '@/core/DriveService.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['drive'], + + requireCredential: true, + + kind: 'write:drive', + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + fileIds: { type: 'array', uniqueItems: true, minItems: 1, maxItems: 100, items: { type: 'string', format: 'misskey:id' } }, + folderId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: ['fileIds'], +} as const; + +@Injectable() +export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export + constructor( + private driveService: DriveService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.driveService.moveFiles(ps.fileIds, ps.folderId ?? null, me.id); + }); + } +} diff --git a/packages/frontend-embed/@types/global.d.ts b/packages/frontend-embed/@types/global.d.ts index 1025d1bedb..8a067a78ec 100644 --- a/packages/frontend-embed/@types/global.d.ts +++ b/packages/frontend-embed/@types/global.d.ts @@ -10,9 +10,6 @@ declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index 7805256fd4..2aef311e2e 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -30,9 +30,6 @@ export default [ _VERSION_: false, _ENV_: false, _PERF_PREFIX_: false, - _DATA_TRANSFER_DRIVE_FILE_: false, - _DATA_TRANSFER_DRIVE_FOLDER_: false, - _DATA_TRANSFER_DECK_COLUMN_: false, }, parser, parserOptions: { diff --git a/packages/frontend-shared/@types/global.d.ts b/packages/frontend-shared/@types/global.d.ts index 4b8d679e75..52081d07b3 100644 --- a/packages/frontend-shared/@types/global.d.ts +++ b/packages/frontend-shared/@types/global.d.ts @@ -11,9 +11,6 @@ declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; diff --git a/packages/frontend-shared/eslint.config.js b/packages/frontend-shared/eslint.config.js index ac5c67d0b6..f6fd64153c 100644 --- a/packages/frontend-shared/eslint.config.js +++ b/packages/frontend-shared/eslint.config.js @@ -35,9 +35,6 @@ export default [ _VERSION_: false, _ENV_: false, _PERF_PREFIX_: false, - _DATA_TRANSFER_DRIVE_FILE_: false, - _DATA_TRANSFER_DRIVE_FOLDER_: false, - _DATA_TRANSFER_DECK_COLUMN_: false, }, parser, parserOptions: { diff --git a/packages/frontend-shared/themes/_dark.json5 b/packages/frontend-shared/themes/_dark.json5 index 8ebaf20b64..0d719ef35c 100644 --- a/packages/frontend-shared/themes/_dark.json5 +++ b/packages/frontend-shared/themes/_dark.json5 @@ -61,7 +61,6 @@ switchOnFg: '@accent', inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', - driveFolderBg: ':alpha<0.3<@accent', badge: '#31b1ce', messageBg: '@bg', success: '#86b300', diff --git a/packages/frontend-shared/themes/_light.json5 b/packages/frontend-shared/themes/_light.json5 index 63ad95ff84..51fb697922 100644 --- a/packages/frontend-shared/themes/_light.json5 +++ b/packages/frontend-shared/themes/_light.json5 @@ -61,7 +61,6 @@ switchOnFg: '@fgOnAccent', inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorderHover: 'rgba(0, 0, 0, 0.2)', - driveFolderBg: ':alpha<0.3<@accent', badge: '#31b1ce', messageBg: '@bg', success: '#86b300', diff --git a/packages/frontend-shared/themes/d-astro.json5 b/packages/frontend-shared/themes/d-astro.json5 index 6d34665528..8ddedbbb07 100644 --- a/packages/frontend-shared/themes/d-astro.json5 +++ b/packages/frontend-shared/themes/d-astro.json5 @@ -38,7 +38,6 @@ navIndicator: '@accent', buttonGradateA: '@accent', buttonGradateB: ':hue<-20<@accent', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', diff --git a/packages/frontend-shared/themes/d-u0.json5 b/packages/frontend-shared/themes/d-u0.json5 index 4f6c04b906..8ce6d25328 100644 --- a/packages/frontend-shared/themes/d-u0.json5 +++ b/packages/frontend-shared/themes/d-u0.json5 @@ -47,7 +47,6 @@ inputBorder: 'rgba(255, 255, 255, 0.1)', panelBorder: '" solid 1px var(--MI_THEME-divider)', navIndicator: '@indicator', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', diff --git a/packages/frontend-shared/themes/l-u0.json5 b/packages/frontend-shared/themes/l-u0.json5 index 35241986df..f685dfbb42 100644 --- a/packages/frontend-shared/themes/l-u0.json5 +++ b/packages/frontend-shared/themes/l-u0.json5 @@ -49,7 +49,6 @@ panelBorder: '" solid 1px var(--MI_THEME-divider)', navIndicator: '@indicator', buttonHoverBg: '#0000001a', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':lighten<3<@fg', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', diff --git a/packages/frontend-shared/themes/l-vivid.json5 b/packages/frontend-shared/themes/l-vivid.json5 index 5ad8d60728..487393ea7c 100644 --- a/packages/frontend-shared/themes/l-vivid.json5 +++ b/packages/frontend-shared/themes/l-vivid.json5 @@ -39,7 +39,6 @@ inputBorderHover: 'rgba(0, 0, 0, 0.2)', panelBorder: '" solid 1px var(--MI_THEME-divider)', navIndicator: '@accent', - driveFolderBg: ':alpha<0.3<@accent', fgHighlighted: ':darken<3<@fg', fgOnWhite: '@accent', panelHeaderBg: ':lighten<3<@panel', diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts index 1025d1bedb..8a067a78ec 100644 --- a/packages/frontend/@types/global.d.ts +++ b/packages/frontend/@types/global.d.ts @@ -10,9 +10,6 @@ declare const _VERSION_: string; declare const _ENV_: string; declare const _DEV_: boolean; declare const _PERF_PREFIX_: string; -declare const _DATA_TRANSFER_DRIVE_FILE_: string; -declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; -declare const _DATA_TRANSFER_DECK_COLUMN_: string; // for dev-mode declare const _LANGS_FULL_: string[][]; diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index 1b9a9b68c0..8f835975a8 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -30,9 +30,6 @@ export default [ _VERSION_: false, _ENV_: false, _PERF_PREFIX_: false, - _DATA_TRANSFER_DRIVE_FILE_: false, - _DATA_TRANSFER_DRIVE_FOLDER_: false, - _DATA_TRANSFER_DECK_COLUMN_: false, }, parser, parserOptions: { 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; diff --git a/packages/frontend/src/drag-and-drop.ts b/packages/frontend/src/drag-and-drop.ts new file mode 100644 index 0000000000..3c6f22f24b --- /dev/null +++ b/packages/frontend/src/drag-and-drop.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +type DragDataMap = { + driveFiles: Misskey.entities.DriveFile[]; + driveFolders: Misskey.entities.DriveFolder[]; + deckColumn: string; +}; + +// NOTE: dataTransfer の format は大文字小文字区別されないっぽいので toLowerCase が必要 + +export function setDragData<T extends keyof DragDataMap>( + event: DragEvent, + type: T, + data: DragDataMap[T], +) { + if (event.dataTransfer == null) return; + + event.dataTransfer.setData(`misskey/${type}`.toLowerCase(), JSON.stringify(data)); +} + +export function getDragData<T extends keyof DragDataMap>( + event: DragEvent, + type: T, +): DragDataMap[T] | null { + if (event.dataTransfer == null) return null; + + const data = event.dataTransfer.getData(`misskey/${type}`.toLowerCase()); + if (data == null || data === '') return null; + + return JSON.parse(data); +} + +export function checkDragDataType( + event: DragEvent, + types: (keyof DragDataMap)[], +): boolean { + if (event.dataTransfer == null) return false; + + const dataType = event.dataTransfer.types[0]; + if (dataType == null || dataType === '') return false; + + return types.some((type) => `misskey/${type}`.toLowerCase() === dataType.toLowerCase()); +} diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts index 26b1881d15..649561cd75 100644 --- a/packages/frontend/src/events.ts +++ b/packages/frontend/src/events.ts @@ -13,6 +13,11 @@ type Events = { clientNotification: (notification: Misskey.entities.Notification) => void; notePosted: (note: Misskey.entities.Note) => void; noteDeleted: (noteId: Misskey.entities.Note['id']) => void; + driveFileCreated: (file: Misskey.entities.DriveFile) => void; + driveFilesUpdated: (files: Misskey.entities.DriveFile[]) => void; + driveFilesDeleted: (files: Misskey.entities.DriveFile[]) => void; + driveFoldersUpdated: (folders: Misskey.entities.DriveFolder[]) => void; + driveFoldersDeleted: (folders: Misskey.entities.DriveFolder[]) => void; }; export const globalEvents = new EventEmitter<Events>(); diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index d891525782..6d49408f26 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -592,38 +592,6 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool }); } -export async function selectDriveFile(multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise(resolve => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { - type: 'file', - multiple, - }, { - done: files => { - if (files) { - resolve(files); - } - }, - closed: () => dispose(), - }); - }); -} - -export async function selectDriveFolder(multiple: boolean): Promise<Misskey.entities.DriveFolder[]> { - return new Promise(resolve => { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { - type: 'folder', - multiple, - }, { - done: folders => { - if (folders) { - resolve(folders); - } - }, - closed: () => dispose(), - }); - }); -} - export async function selectRole(params: ComponentProps<typeof MkRoleSelectDialog_TypeReferenceOnly>): Promise< { canceled: true; result: undefined; } | { canceled: false; result: Misskey.entities.Role[] } @@ -655,15 +623,13 @@ export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof Mk }); } -export async function cropImage(image: Misskey.entities.DriveFile, options: { - aspectRatio: number; - uploadFolder?: string | null; -}): Promise<Misskey.entities.DriveFile> { +export async function cropImageFile(imageFile: File | Blob, options: { + aspectRatio: number | null; +}): Promise<File> { return new Promise(resolve => { const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { - file: image, + imageFile: imageFile, aspectRatio: options.aspectRatio, - uploadFolder: options.uploadFolder, }, { ok: x => { resolve(x); @@ -775,3 +741,52 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> { }); }); }*/ + +export function chooseFileFromPc( + options: { + multiple?: boolean; + } = {}, +): Promise<File[]> { + return new Promise((res, rej) => { + const input = window.document.createElement('input'); + input.type = 'file'; + input.multiple = options.multiple ?? false; + input.onchange = () => { + if (!input.files) return res([]); + + res(Array.from(input.files)); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); + }); +} + +export function launchUploader( + files: File[], + options?: { + folderId?: string | null; + multiple?: boolean; + }, +): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + if (files.length === 0) return rej(); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUploaderDialog.vue')), { + files: markRaw(files), + folderId: options?.folderId, + multiple: options?.multiple, + }, { + done: driveFiles => { + if (driveFiles.length === 0) return rej(); + res(driveFiles); + }, + closed: () => dispose(), + }); + }); +} diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue index 0ff251abb8..68c7048ae1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue @@ -87,7 +87,7 @@ import MkButton from '@/components/MkButton.vue'; import { validators } from '@/components/grid/cell-validators.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import MkPagingButtons from '@/components/MkPagingButtons.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js'; import { useLoading } from '@/composables/use-loading.js'; diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue index e8e944df32..e1dabe549f 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -35,20 +35,9 @@ SPDX-License-Identifier: AGPL-3.0-only <XRegisterLogs :logs="requestLogs"/> </MkFolder> - <div - :class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]" - @dragover.prevent="isDragOver = true" - @dragleave.prevent="isDragOver = false" - @drop.prevent.stop="onDrop" - > - <div style="margin-top: 1em"> - {{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }} - </div> - <ul> - <li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li> - <li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li> - <li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li> - </ul> + <div class="_buttonsCenter"> + <MkButton primary rounded @click="onFileSelectClicked">{{ i18n.ts.uplaod }}</MkButton> + <MkButton primary rounded @click="onDriveSelectClicked">{{ i18n.ts.fromDrive }}</MkButton> </div> <div v-if="gridItems.length > 0" :class="$style.gridArea"> @@ -94,8 +83,7 @@ import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { validators } from '@/components/grid/cell-validators.js'; -import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; -import { uploadFile } from '@/utility/upload.js'; +import { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; @@ -311,75 +299,21 @@ async function onClearClicked() { } } -async function onDrop(ev: DragEvent) { - isDragOver.value = false; - - const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it)); - const confirm = await os.confirm({ - type: 'info', - text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }), - }); - if (confirm.canceled) { - return; - } - - const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>(); - try { - uploadedItems.push( - ...await os.promiseDialog( - Promise.all( - droppedFiles.map(async (it) => ({ - droppedFile: it, - driveFile: await uploadFile( - it.file, - selectedFolderId.value, - it.file.name.replace(/\.[^.]+$/, ''), - true, - ), - }), - ), - ), - () => { - }, - () => { - }, - ), - ); - } catch (err) { - // ダイアログは共通部品側で出ているはずなので何もしない - return; - } - - const items = uploadedItems.map(({ droppedFile, driveFile }) => { - const item = fromDriveFile(driveFile); - if (directoryToCategory.value) { - item.category = droppedFile.path - .replace(/^\//, '') - .replace(/\/[^/]+$/, '') - .replace(droppedFile.file.name, ''); - } - return item; - }); - - gridItems.value.push(...items); -} - async function onFileSelectClicked() { - const driveFiles = await chooseFileFromPc( - true, - { - uploadFolder: selectedFolderId.value, - keepOriginal: true, - // 拡張子は消す - nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), - }, - ); + const driveFiles = await chooseFileFromPcAndUpload({ + multiple: true, + folderId: selectedFolderId.value, + // 拡張子は消す + nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), + }); gridItems.value.push(...driveFiles.map(fromDriveFile)); } async function onDriveSelectClicked() { - const driveFiles = await chooseFileFromDrive(true); + const driveFiles = await chooseDriveFile({ + multiple: true, + }); gridItems.value.push(...driveFiles.map(fromDriveFile)); } @@ -436,23 +370,6 @@ onMounted(async () => { background-color: var(--MI_THEME-infoWarnBg); } -.uploadBox { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: auto; - border: 0.5px dotted var(--MI_THEME-accentedBg); - border-radius: var(--MI-radius); - background-color: var(--MI_THEME-accentedBg); - box-sizing: border-box; - - &.dragOver { - cursor: copy; - } -} - .gridArea { padding-top: 8px; padding-bottom: 8px; diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 009514cdc8..355b5464a1 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -73,7 +73,7 @@ import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/chat/room.form.vue b/packages/frontend/src/pages/chat/room.form.vue index 9389b16ce7..7e3be67230 100644 --- a/packages/frontend/src/pages/chat/room.form.vue +++ b/packages/frontend/src/pages/chat/room.form.vue @@ -38,15 +38,15 @@ import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly, onBefo import * as Misskey from 'misskey-js'; //import insertTextAtCursor from 'insert-text-at-cursor'; import { formatTimeString } from '@/utility/format-time-string.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { uploadFile } from '@/utility/upload.js'; import { miLocalStorage } from '@/local-storage.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { prefer } from '@/preferences.js'; import { Autocomplete } from '@/utility/autocomplete.js'; import { emojiPicker } from '@/utility/emoji-picker.js'; +import { checkDragDataType, getDragData } from '@/drag-and-drop.js'; const props = defineProps<{ user?: Misskey.entities.UserDetailed | null; @@ -84,8 +84,11 @@ async function onPaste(ev: ClipboardEvent) { if (!pastedFile) return; const lio = pastedFile.name.lastIndexOf('.'); const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; - const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; - if (formatted) upload(pastedFile, formatted); + const formattedName = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext; + const renamedFile = new File([pastedFile], formattedName, { type: pastedFile.type }); + os.launchUploader([renamedFile], { multiple: false }).then(driveFiles => { + file.value = driveFiles[0]; + }); } } else { if (items[0].kind === 'file') { @@ -101,8 +104,7 @@ function onDragover(ev: DragEvent) { if (!ev.dataTransfer) 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(); switch (ev.dataTransfer.effectAllowed) { case 'all': @@ -129,7 +131,7 @@ function onDrop(ev: DragEvent): void { // ファイルだったら if (ev.dataTransfer.files.length === 1) { ev.preventDefault(); - upload(ev.dataTransfer.files[0]); + os.launchUploader([Array.from(ev.dataTransfer.files)[0]], { multiple: false }); return; } else if (ev.dataTransfer.files.length > 1) { ev.preventDefault(); @@ -141,10 +143,12 @@ function onDrop(ev: DragEvent): void { } //#region ドライブのファイル - const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - file.value = JSON.parse(driveFile); - ev.preventDefault(); + { + const droppedData = getDragData(ev, 'driveFiles'); + if (droppedData != null) { + file.value = droppedData[0]; + ev.preventDefault(); + } } //#endregion } @@ -172,13 +176,11 @@ function chooseFile(ev: MouseEvent) { function onChangeFile() { if (fileEl.value == null || fileEl.value.files == null) return; - if (fileEl.value.files[0]) upload(fileEl.value.files[0]); -} - -function upload(fileToUpload: File, name?: string) { - uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => { - file.value = res; - }); + if (fileEl.value.files[0]) { + os.launchUploader(Array.from(fileEl.value.files), { multiple: false }).then(driveFiles => { + file.value = driveFiles[0]; + }); + } } function send() { diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 46e494e6f6..c2bc621f6a 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -78,7 +78,7 @@ import MkPagination from '@/components/MkPagination.vue'; import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSplit from '@/components/form/split.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { getProxiedImageUrl } from '@/utility/media-proxy.js'; diff --git a/packages/frontend/src/pages/debug.vue b/packages/frontend/src/pages/debug.vue index 4a28d513f5..5cd68c2c3a 100644 --- a/packages/frontend/src/pages/debug.vue +++ b/packages/frontend/src/pages/debug.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/> <MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/> <MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/> + <MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/> <MkSelect v-model="iconType" :items="[ { label: 'info', value: 'info' }, @@ -30,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only { label: 'success', value: 'success' }, { label: 'warn', value: 'warn' }, { label: 'error', value: 'error' }, + { label: 'waiting', value: 'waiting' }, ]" ></MkSelect> diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue index 21be0b18a9..e8ac13c223 100644 --- a/packages/frontend/src/pages/drive.file.info.vue +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -20,9 +20,6 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()"> <i class="ti ti-pencil"></i> </button> - <button v-if="isImage" v-tooltip="i18n.ts.cropImage" class="_button" :class="$style.fileQuickActionsOthersButton" @click="crop()"> - <i class="ti ti-crop"></i> - </button> <button v-if="file.isSensitive" v-tooltip="i18n.ts.unmarkAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()"> <i class="ti ti-eye"></i> </button> @@ -83,6 +80,8 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useRouter } from '@/router.js'; +import { selectDriveFolder } from '@/utility/drive.js'; +import { globalEvents } from '@/events.js'; const router = useRouter(); @@ -127,19 +126,10 @@ function postThis() { }); } -function crop() { - if (!file.value) return; - - os.cropImage(file.value, { - aspectRatio: NaN, - uploadFolder: file.value.folderId ?? null, - }); -} - function move() { if (!file.value) return; - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { misskeyApi('drive/files/update', { fileId: file.value.id, folderId: folder[0] ? folder[0].id : null, @@ -210,12 +200,14 @@ async function deleteFile() { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.value.name }), }); - if (canceled) return; + await os.apiWithDialog('drive/files/delete', { fileId: file.value.id, }); + globalEvents.emit('driveFilesDeleted', [file.value]); + router.push('/my/drive'); } diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue index bee54f3fd2..38939f9503 100644 --- a/packages/frontend/src/pages/drive.vue +++ b/packages/frontend/src/pages/drive.vue @@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <XDrive @cd="x => folder = x"/> + <MkDrive @cd="x => folder = x"/> </div> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; -import XDrive from '@/components/MkDrive.vue'; +import MkDrive from '@/components/MkDrive.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 0479ed6f6c..41de457427 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -91,7 +91,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { customEmojiCategories } from '@/custom-emojis.js'; import MkSwitch from '@/components/MkSwitch.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; const props = defineProps<{ diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index caae30f9fd..1b8c14a156 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormSuspense from '@/components/form/suspense.vue'; -import { selectFiles } from '@/utility/select-file.js'; +import { selectFiles } from '@/utility/drive.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index 1b98425719..13dedeafb2 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -20,14 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -/* eslint-disable vue/no-mutating-props */ + import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import XContainer from '../page-editor.container.vue'; import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; -import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { chooseDriveFile } from '@/utility/drive.js'; const props = defineProps<{ modelValue: Misskey.entities.PageBlock & { type: 'image' }; @@ -41,7 +41,7 @@ const emit = defineEmits<{ const file = ref<Misskey.entities.DriveFile | null>(null); async function choose() { - os.selectDriveFile(false).then((fileResponse) => { + chooseDriveFile({ multiple: false }).then((fileResponse) => { file.value = fileResponse[0]; emit('update:modelValue', { ...props.modelValue, diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index f1b1c2f1d8..49d9150852 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -71,7 +71,7 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { $i } from '@/i.js'; diff --git a/packages/frontend/src/pages/settings/account-data.vue b/packages/frontend/src/pages/settings/account-data.vue index 14bea577a3..d175c0dc32 100644 --- a/packages/frontend/src/pages/settings/account-data.vue +++ b/packages/frontend/src/pages/settings/account-data.vue @@ -164,7 +164,7 @@ import MkFolder from '@/components/MkFolder.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { $i } from '@/i.js'; diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue index 39055268d4..22bd8cbc80 100644 --- a/packages/frontend/src/pages/settings/deck.vue +++ b/packages/frontend/src/pages/settings/deck.vue @@ -98,7 +98,7 @@ import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import { reloadAsk } from '@/utility/reload-ask.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; const navWindow = prefer.model('deck.navWindow'); const useSimpleUiForNonRootPages = prefer.model('deck.useSimpleUiForNonRootPages'); diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index 2130cbc868..d62e487341 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -99,6 +99,7 @@ import { ensureSignin } from '@/i.js'; import { prefer } from '@/preferences.js'; import MkPreferenceContainer from '@/components/MkPreferenceContainer.vue'; import MkFeatureBanner from '@/components/MkFeatureBanner.vue'; +import { selectDriveFolder } from '@/utility/drive.js'; const $i = ensureSignin(); @@ -138,7 +139,7 @@ if (prefer.s.uploadFolder) { } function chooseUploadFolder() { - os.selectDriveFolder(false).then(async folder => { + selectDriveFolder(null).then(async folder => { prefer.commit('uploadFolder', folder[0] ? folder[0].id : null); os.success(); if (prefer.s.uploadFolder) { diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 30b7cf9a86..cd1565f39e 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -161,7 +161,7 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; -import { selectFile } from '@/utility/select-file.js'; +import { chooseDriveFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { ensureSignin } from '@/i.js'; @@ -257,54 +257,100 @@ function save() { } function changeAvatar(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { - let originalOrCropped = file; - - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.cropImageAsk, - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, - }); - - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 1, - }); - } - + async function done(driveFile) { const i = await os.apiWithDialog('i/update', { - avatarId: originalOrCropped.id, + avatarId: driveFile.id, }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; claimAchievement('profileFilled'); - }); -} + } -function changeBanner(ev) { - selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { - let originalOrCropped = file; + os.popupMenu([{ + text: i18n.ts.avatar, + type: 'label', + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: async () => { + const files = await os.chooseFileFromPc({ multiple: false }); + const file = files[0]; - const { canceled } = await os.confirm({ - type: 'question', - text: i18n.ts.cropImageAsk, - okText: i18n.ts.cropYes, - cancelText: i18n.ts.cropNo, - }); + let originalOrCropped = file; - if (!canceled) { - originalOrCropped = await os.cropImage(file, { - aspectRatio: 2, + 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, + }); + } + + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; + done(driveFile); + }, + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => { + chooseDriveFile({ multiple: false }).then(files => { + done(files[0]); }); - } + }, + }], ev.currentTarget ?? ev.target); +} +function changeBanner(ev) { + async function done(driveFile) { const i = await os.apiWithDialog('i/update', { - bannerId: originalOrCropped.id, + bannerId: driveFile.id, }); $i.bannerId = i.bannerId; $i.bannerUrl = i.bannerUrl; - }); + } + + os.popupMenu([{ + text: i18n.ts.banner, + type: 'label', + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: async () => { + const files = await os.chooseFileFromPc({ multiple: false }); + const file = files[0]; + + let originalOrCropped = file; + + 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: 2, + }); + } + + const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0]; + done(driveFile); + }, + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => { + chooseDriveFile({ multiple: false }).then(files => { + done(files[0]); + }); + }, + }], ev.currentTarget ?? ev.target); } const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 1bac19fe47..ffbbefa122 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -40,7 +40,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; -import { selectFile } from '@/utility/select-file.js'; +import { selectFile } from '@/utility/drive.js'; const props = defineProps<{ type: SoundType; diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index fcf9fb234d..da20d23cfd 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -65,8 +65,6 @@ SPDX-License-Identifier: AGPL-3.0-only v-on="popup.events" /> -<XUpload v-if="uploads.length > 0"/> - <component :is="prefer.s.animation ? TransitionGroup : 'div'" tag="div" @@ -105,7 +103,6 @@ import { swInject } from './sw-inject.js'; import XNotification from './notification.vue'; import { popups } from '@/os.js'; import { pendingApiRequestsCount } from '@/utility/misskey-api.js'; -import { uploads } from '@/utility/upload.js'; import * as sound from '@/utility/sound.js'; import { $i } from '@/i.js'; import { useStream } from '@/stream.js'; @@ -116,7 +113,6 @@ import { store } from '@/store.js'; import XNavbar from '@/ui/_common_/navbar.vue'; const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); -const XUpload = defineAsyncComponent(() => import('./upload.vue')); const XWidgets = defineAsyncComponent(() => import('./widgets.vue')); const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue deleted file mode 100644 index 3e5653e46d..0000000000 --- a/packages/frontend/src/ui/_common_/upload.vue +++ /dev/null @@ -1,134 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div class="mk-uploader _acrylic" :style="{ zIndex }"> - <ol v-if="uploads.length > 0"> - <li v-for="ctx in uploads" :key="ctx.id"> - <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div> - <div class="top"> - <p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p> - <p class="status"> - <span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span> - <span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span> - <span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span> - </p> - </div> - <progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress> - </li> - </ol> -</div> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import * as os from '@/os.js'; -import { uploads } from '@/utility/upload.js'; -import { i18n } from '@/i18n.js'; - -const zIndex = os.claimZIndex('high'); -</script> - -<style lang="scss" scoped> -.mk-uploader { - position: fixed; - right: 16px; - width: 260px; - top: 32px; - padding: 16px 20px; - pointer-events: none; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); - border-radius: 8px; -} -.mk-uploader:empty { - display: none; -} -.mk-uploader > ol { - display: block; - margin: 0; - padding: 0; - list-style: none; -} -.mk-uploader > ol > li { - display: grid; - margin: 8px 0 0 0; - padding: 0; - height: 36px; - width: 100%; - border-top: solid 8px transparent; - grid-template-columns: 36px calc(100% - 44px); - grid-template-rows: 1fr 8px; - column-gap: 8px; - box-sizing: content-box; -} -.mk-uploader > ol > li:first-child { - margin: 0; - box-shadow: none; - border-top: none; -} -.mk-uploader > ol > li > .img { - display: block; - background-size: cover; - background-position: center center; - grid-column: 1/2; - grid-row: 1/3; -} -.mk-uploader > ol > li > .top { - display: flex; - grid-column: 2/3; - grid-row: 1/2; -} -.mk-uploader > ol > li > .top > .name { - display: block; - padding: 0 8px 0 0; - margin: 0; - font-size: 0.8em; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - flex-shrink: 1; -} -.mk-uploader > ol > li > .top > .name > i { - margin-right: 4px; -} -.mk-uploader > ol > li > .top > .status { - display: block; - margin: 0 0 0 auto; - padding: 0; - font-size: 0.8em; - flex-shrink: 0; -} -.mk-uploader > ol > li > .top > .status > .initing { -} -.mk-uploader > ol > li > .top > .status > .kb { -} -.mk-uploader > ol > li > .top > .status > .percentage { - display: inline-block; - width: 48px; - text-align: right; -} -.mk-uploader > ol > li > .top > .status > .percentage:after { - content: '%'; -} -.mk-uploader > ol > li > progress { - display: block; - background: transparent; - border: none; - border-radius: 4px; - overflow: hidden; - grid-column: 2/3; - grid-row: 2/3; - z-index: 2; - width: 100%; - height: 8px; -} -.mk-uploader > ol > li > progress::-webkit-progress-value { - background: var(--MI_THEME-accent); -} -.mk-uploader > ol > li > progress::-webkit-progress-bar { - //background: var(--MI_THEME-accentAlpha01); - background: transparent; -} -</style> diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 2085c73e03..4e79b301e3 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -51,6 +51,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { prefer } from '@/preferences.js'; import { DI } from '@/di.js'; +import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js'; provide('shouldHeaderThin', true); provide('shouldOmitHeaderTitle', true); @@ -262,7 +263,7 @@ function goTop() { function onDragstart(ev) { ev.dataTransfer.effectAllowed = 'move'; - ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id); + setDragData(ev, 'deckColumn', props.column.id); // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately @@ -281,7 +282,7 @@ function onDragover(ev) { // 自分自身にはドロップさせない ev.dataTransfer.dropEffect = 'none'; } else { - const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_; + const isDeckColumn = checkDragDataType(ev, ['deckColumn']); ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; @@ -297,8 +298,8 @@ function onDrop(ev) { draghover.value = false; os.deckGlobalEvents.emit('column.dragEnd'); - const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); - if (id != null && id !== '') { + const id = getDragData(ev, 'deckColumn'); + if (id != null) { swapColumn(props.column.id, id); } } diff --git a/packages/frontend/src/utility/drive.ts b/packages/frontend/src/utility/drive.ts new file mode 100644 index 0000000000..e29b010c81 --- /dev/null +++ b/packages/frontend/src/utility/drive.ts @@ -0,0 +1,246 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import * as Misskey from 'misskey-js'; +import { apiUrl } from '@@/js/config.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import { prefer } from '@/preferences.js'; +import { $i } from '@/i.js'; +import { instance } from '@/instance.js'; +import { globalEvents } from '@/events.js'; +import { getProxiedImageUrl } from '@/utility/media-proxy.js'; + +export function uploadFile(file: File | Blob, options: { + name?: string; + folderId?: string | null; + onProgress?: (ctx: { total: number; loaded: number; }) => void; +} = {}): Promise<Misskey.entities.DriveFile> { + return new Promise((resolve, reject) => { + if ($i == null) return reject(); + + if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + return reject(); + } + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + if (xhr.status === 413) { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + } else if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + os.alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + os.alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } + + reject(); + return; + } + + const driveFile = JSON.parse(ev.target.response); + globalEvents.emit('driveFileCreated', driveFile); + resolve(driveFile); + }) as (ev: ProgressEvent<EventTarget>) => any; + + if (options.onProgress) { + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + options.onProgress({ + total: ev.total, + loaded: ev.loaded, + }); + } + }; + } + + const formData = new FormData(); + formData.append('i', $i.token); + formData.append('force', 'true'); + formData.append('file', file); + formData.append('name', options.name ?? file.name ?? 'untitled'); + if (options.folderId) formData.append('folderId', options.folderId); + + xhr.send(formData); + }); +} + +export function chooseFileFromPcAndUpload( + options: { + multiple?: boolean; + folderId?: string | null; + } = {}, +): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + os.chooseFileFromPc({ multiple: options.multiple }).then(files => { + if (files.length === 0) return; + os.launchUploader(files, { + folderId: options.folderId, + }).then(driveFiles => { + res(driveFiles); + }); + }); + }); +} + +export function chooseDriveFile(options: { + multiple?: boolean; +} = {}): Promise<Misskey.entities.DriveFile[]> { + return new Promise(resolve => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), { + multiple: options.multiple, + }, { + done: files => { + if (files) { + resolve(files); + } + }, + closed: () => dispose(), + }); + }); +} + +export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { + return new Promise((res, rej) => { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + placeholder: i18n.ts.uploadFromUrlDescription, + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + // TODO: no websocketモード対応 + const connection = useStream().useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(urlResponse.file); + connection.dispose(); + } + }); + + misskeyApi('drive/files/upload-from-url', { + url: url, + folderId: prefer.s.uploadFolder, + marker, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); + }); +} + +function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + os.popupMenu([label ? { + text: label, + type: 'label', + } : undefined, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: () => chooseFileFromPcAndUpload({ multiple }).then(files => res(files)), + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => chooseDriveFile({ multiple }).then(files => res(files)), + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: () => chooseFileFromUrl().then(file => res([file])), + }], src); + }); +} + +export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> { + return select(src, label, false).then(files => files[0]); +} + +export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { + return select(src, label, true); +} + +export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: { + aspectRatio: number | null; +}): Promise<Misskey.entities.DriveFile> { + return new Promise(resolve => { + const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true); + const image = new Image(); + image.src = imgUrl; + image.onload = () => { + const canvas = window.document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + canvas.width = image.width; + canvas.height = image.height; + ctx.drawImage(image, 0, 0); + canvas.toBlob(blob => { + os.cropImageFile(blob, { + aspectRatio: options.aspectRatio, + }).then(croppedImageFile => { + uploadFile(croppedImageFile, { + name: imageDriveFile.name, + folderId: imageDriveFile.folderId, + }).then(driveFile => { + resolve(driveFile); + }); + }); + }); + }; + }); +} + +export async function selectDriveFolder(initialFolder: Misskey.entities.DriveFolder['id'] | null): Promise<Misskey.entities.DriveFolder[]> { + return new Promise(resolve => { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFolderSelectDialog.vue')), { + initialFolder, + }, { + done: folders => { + if (folders) { + resolve(folders); + } + }, + closed: () => dispose(), + }); + }); +} diff --git a/packages/frontend/src/utility/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts index 3c6cbba002..4b4bab3125 100644 --- a/packages/frontend/src/utility/get-drive-file-menu.ts +++ b/packages/frontend/src/utility/get-drive-file-menu.ts @@ -5,12 +5,14 @@ import * as Misskey from 'misskey-js'; import { defineAsyncComponent } from 'vue'; +import { selectDriveFolder } from './drive.js'; import type { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { prefer } from '@/preferences.js'; +import { globalEvents } from '@/events.js'; function rename(file: Misskey.entities.DriveFile) { os.inputText({ @@ -42,7 +44,7 @@ function describe(file: Misskey.entities.DriveFile) { } function move(file: Misskey.entities.DriveFile) { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { misskeyApi('drive/files/update', { fileId: file.id, folderId: folder[0] ? folder[0].id : null, @@ -77,11 +79,13 @@ async function deleteFile(file: Misskey.entities.DriveFile) { type: 'warning', text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), }); - if (canceled) return; - misskeyApi('drive/files/delete', { + + await os.apiWithDialog('drive/files/delete', { fileId: file.id, }); + + globalEvents.emit('driveFilesDeleted', [file]); } export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] { @@ -112,17 +116,6 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss action: () => describe(file), }); - if (isImage) { - menuItems.push({ - text: i18n.ts.cropImage, - icon: 'ti ti-crop', - action: () => os.cropImage(file, { - aspectRatio: NaN, - uploadFolder: folder ? folder.id : folder, - }), - }); - } - menuItems.push({ type: 'divider' }, { text: i18n.ts.createNoteFromTheFile, icon: 'ti ti-pencil', diff --git a/packages/frontend/src/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/isWebpSupported.ts index affd81fd57..affd81fd57 100644 --- a/packages/frontend/src/utility/upload/isWebpSupported.ts +++ b/packages/frontend/src/utility/isWebpSupported.ts diff --git a/packages/frontend/src/utility/select-file.ts b/packages/frontend/src/utility/select-file.ts deleted file mode 100644 index 731ef58302..0000000000 --- a/packages/frontend/src/utility/select-file.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { ref } from 'vue'; -import * as Misskey from 'misskey-js'; -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 } from '@/utility/upload.js'; -import { prefer } from '@/preferences.js'; - -export function chooseFileFromPc( - multiple: boolean, - options?: { - uploadFolder?: string | null; - keepOriginal?: boolean; - nameConverter?: (file: File) => string | undefined; - }, -): Promise<Misskey.entities.DriveFile[]> { - const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder; - const keepOriginal = options?.keepOriginal ?? false; - const nameConverter = options?.nameConverter ?? (() => undefined); - - return new Promise((res, rej) => { - const input = window.document.createElement('input'); - input.type = 'file'; - input.multiple = multiple; - input.onchange = () => { - if (!input.files) return res([]); - const promises = Array.from( - input.files, - file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), - ); - - Promise.all(promises).then(driveFiles => { - res(driveFiles); - }).catch(err => { - // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない - }); - - // 一応廃棄 - (window as any).__misskey_input_ref__ = null; - }; - - // https://qiita.com/fukasawah/items/b9dc732d95d99551013d - // iOS Safari で正常に動かす為のおまじない - (window as any).__misskey_input_ref__ = input; - - input.click(); - }); -} - -export function chooseFileFromDrive(multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise((res, rej) => { - os.selectDriveFile(multiple).then(files => { - res(files); - }); - }); -} - -export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { - return new Promise((res, rej) => { - os.inputText({ - title: i18n.ts.uploadFromUrl, - type: 'url', - placeholder: i18n.ts.uploadFromUrlDescription, - }).then(({ canceled, result: url }) => { - if (canceled) return; - - const marker = Math.random().toString(); // TODO: UUIDとか使う - - const connection = useStream().useChannel('main'); - connection.on('urlUploadFinished', urlResponse => { - if (urlResponse.marker === marker) { - res(urlResponse.file); - connection.dispose(); - } - }); - - misskeyApi('drive/files/upload-from-url', { - url: url, - folderId: prefer.s.uploadFolder, - marker, - }); - - os.alert({ - title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime, - }); - }); - }); -} - -function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { - return new Promise((res, rej) => { - os.popupMenu([label ? { - text: label, - type: 'label', - } : undefined, { - text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', - icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, { keepOriginal: false }).then(files => res(files)), - }, { - text: i18n.ts.upload, - icon: 'ti ti-upload', - action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)), - }, { - text: i18n.ts.fromDrive, - icon: 'ti ti-cloud', - action: () => chooseFileFromDrive(multiple).then(files => res(files)), - }, { - text: i18n.ts.fromUrl, - icon: 'ti ti-link', - action: () => chooseFileFromUrl().then(file => res([file])), - }], src); - }); -} - -export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> { - return select(src, label, false).then(files => files[0]); -} - -export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { - return select(src, label, true); -} diff --git a/packages/frontend/src/utility/timeline-date-separate.ts b/packages/frontend/src/utility/timeline-date-separate.ts index 1071a80962..33ddea048b 100644 --- a/packages/frontend/src/utility/timeline-date-separate.ts +++ b/packages/frontend/src/utility/timeline-date-separate.ts @@ -4,7 +4,7 @@ */ import { computed } from 'vue'; -import type { Ref } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; export function getDateText(dateInstance: Date) { const date = dateInstance.getDate(); @@ -12,19 +12,6 @@ export function getDateText(dateInstance: Date) { return `${month.toString()}/${date.toString()}`; } -export type DateSeparetedTimelineItem<T> = { - id: string; - type: 'item'; - data: T; -} | { - id: string; - type: 'date'; - prev: Date; - prevText: string; - next: Date; - nextText: string; -}; - // TODO: いちいちDateインスタンス作成するのは無駄感あるから文字列のまま解析したい export function isSeparatorNeeded( prev: string | null, @@ -56,7 +43,20 @@ export function getSeparatorInfo( }; } -export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]>) { +export type DateSeparetedTimelineItem<T> = { + id: string; + type: 'item'; + data: T; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date; + nextText: string; +}; + +export function makeDateSeparatedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ShallowRef<T[]>) { return computed<DateSeparetedTimelineItem<T>[]>(() => { const tl: DateSeparetedTimelineItem<T>[] = []; for (let i = 0; i < items.value.length; i++) { @@ -92,3 +92,35 @@ export function makeDateSeparatedTimelineComputedRef<T extends { id: string; cre return tl; }); } + +export type DateGroupedTimelineItem<T> = { + date: Date; + items: T[]; +}; + +export function makeDateGroupedTimelineComputedRef<T extends { id: string; createdAt: string; }>(items: Ref<T[]> | ShallowRef<T[]>, span: 'day' | 'month' = 'day') { + return computed<DateGroupedTimelineItem<T>[]>(() => { + const tl: DateGroupedTimelineItem<T>[] = []; + for (let i = 0; i < items.value.length; i++) { + const item = items.value[i]; + const date = new Date(item.createdAt); + const nextDate = items.value[i + 1] ? new Date(items.value[i + 1].createdAt) : null; + + if (tl.length === 0 || ( + span === 'day' && tl[tl.length - 1].date.getTime() !== date.getTime() + ) || ( + span === 'month' && ( + tl[tl.length - 1].date.getFullYear() !== date.getFullYear() || + tl[tl.length - 1].date.getMonth() !== date.getMonth() + ) + )) { + tl.push({ + date, + items: [], + }); + } + tl[tl.length - 1].items.push(item); + } + return tl; + }); +} diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts deleted file mode 100644 index 03240749e9..0000000000 --- a/packages/frontend/src/utility/upload.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { reactive, ref } from 'vue'; -import * as Misskey from 'misskey-js'; -import { v4 as uuid } from 'uuid'; -import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; -import { apiUrl } from '@@/js/config.js'; -import { getCompressionConfig } from './upload/compress-config.js'; -import { $i } from '@/i.js'; -import { alert } from '@/os.js'; -import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; -import { prefer } from '@/preferences.js'; - -type Uploading = { - id: string; - name: string; - progressMax: number | undefined; - progressValue: number | undefined; - img: string; -}; -export const uploads = ref<Uploading[]>([]); - -const mimeTypeMap = { - 'image/webp': 'webp', - 'image/jpeg': 'jpg', - 'image/png': 'png', -} as const; - -export function uploadFile( - file: File, - folder?: string | Misskey.entities.DriveFolder | null, - name?: string, - keepOriginal = false, -): Promise<Misskey.entities.DriveFile> { - if ($i == null) throw new Error('Not logged in'); - - const _folder = typeof folder === 'string' ? folder : folder?.id; - - if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, - }); - return Promise.reject(); - } - - return new Promise((resolve, reject) => { - const id = uuid(); - - const reader = new FileReader(); - reader.onload = async (): Promise<void> => { - const filename = name ?? file.name ?? 'untitled'; - const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - - const ctx = reactive<Uploading>({ - id, - name: prefer.s.keepOriginalFilename ? filename : id + extension, - progressMax: undefined, - progressValue: undefined, - img: window.URL.createObjectURL(file), - }); - - uploads.value.push(ctx); - - const config = !keepOriginal ? await getCompressionConfig(file) : undefined; - let resizedImage: Blob | undefined; - if (config) { - try { - const resized = await readAndCompressImage(file, config); - if (resized.size < file.size || file.type === 'image/webp') { - // The compression may not always reduce the file size - // (and WebP is not browser safe yet) - resizedImage = resized; - } - if (_DEV_) { - const saved = ((1 - resized.size / file.size) * 100).toFixed(2); - console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); - } - - ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; - } catch (err) { - console.error('Failed to resize image', err); - } - } - - const formData = new FormData(); - formData.append('i', $i!.token); - formData.append('force', 'true'); - formData.append('file', resizedImage ?? file); - formData.append('name', ctx.name); - if (_folder) formData.append('folderId', _folder); - - const xhr = new XMLHttpRequest(); - xhr.open('POST', apiUrl + '/drive/files/create', true); - xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { - if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { - // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい - uploads.value = uploads.value.filter(x => x.id !== id); - - if (xhr.status === 413) { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, - }); - } else if (ev.target?.response) { - const res = JSON.parse(ev.target.response); - if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseInappropriate, - }); - } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: i18n.ts.cannotUploadBecauseNoFreeSpace, - }); - } else { - alert({ - type: 'error', - title: i18n.ts.failedToUpload, - text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, - }); - } - } else { - alert({ - type: 'error', - title: 'Failed to upload', - text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, - }); - } - - reject(); - return; - } - - const driveFile = JSON.parse(ev.target.response); - - resolve(driveFile); - - uploads.value = uploads.value.filter(x => x.id !== id); - }) as (ev: ProgressEvent<EventTarget>) => any; - - xhr.upload.onprogress = ev => { - if (ev.lengthComputable) { - ctx.progressMax = ev.total; - ctx.progressValue = ev.loaded; - } - }; - - xhr.send(formData); - }; - reader.readAsArrayBuffer(file); - }); -} diff --git a/packages/frontend/src/utility/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts deleted file mode 100644 index 3046b7f518..0000000000 --- a/packages/frontend/src/utility/upload/compress-config.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import isAnimated from 'is-file-animated'; -import { isWebpSupported } from './isWebpSupported.js'; -import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer'; - -const compressTypeMap = { - 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' }, - 'image/png': { quality: 1, mimeType: 'image/webp' }, - 'image/webp': { quality: 0.90, mimeType: 'image/webp' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/webp' }, -} as const; - -const compressTypeMapFallback = { - 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/png': { quality: 1, mimeType: 'image/png' }, - 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, - 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, -} as const; - -export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> { - const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; - if (!imgConfig || await isAnimated(file)) { - return; - } - - return { - maxWidth: 2048, - maxHeight: 2048, - debug: true, - ...imgConfig, - }; -} diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue index 2ccbb7a28f..3fe8cfa7e6 100644 --- a/packages/frontend/src/widgets/WidgetSlideshow.vue +++ b/packages/frontend/src/widgets/WidgetSlideshow.vue @@ -26,6 +26,7 @@ import type { GetFormResultType } from '@/utility/form.js'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { selectDriveFolder } from '@/utility/drive.js'; const name = 'slideshow'; @@ -93,7 +94,7 @@ const fetch = () => { }; const choose = () => { - os.selectDriveFolder(false).then(folder => { + selectDriveFolder(null).then(folder => { if (folder[0] == null) { return; } diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index aa7bf24174..71c133acc8 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -148,9 +148,6 @@ export function getConfig(): UserConfig { _ENV_: JSON.stringify(process.env.NODE_ENV), _DEV_: process.env.NODE_ENV !== 'production', _PERF_PREFIX_: JSON.stringify('Misskey:'), - _DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'), - _DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'), - _DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'), __VUE_OPTIONS_API__: true, __VUE_PROD_DEVTOOLS__: false, }, diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index f085240f84..a305087fdb 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1248,6 +1248,9 @@ type DriveFilesFindRequest = operations['drive___files___find']['requestBody'][' type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json']; // @public (undocumented) +type DriveFilesMoveBulkRequest = operations['drive___files___move-bulk']['requestBody']['content']['application/json']; + +// @public (undocumented) type DriveFilesRequest = operations['drive___files']['requestBody']['content']['application/json']; // @public (undocumented) @@ -1732,6 +1735,7 @@ declare namespace entities { DriveFilesFindResponse, DriveFilesFindByHashRequest, DriveFilesFindByHashResponse, + DriveFilesMoveBulkRequest, DriveFilesShowRequest, DriveFilesShowResponse, DriveFilesUpdateRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 12b51a4ac0..537118b9cd 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -2074,6 +2074,17 @@ declare module '../api.js' { ): Promise<SwitchCaseResponseType<E, P>>; /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:drive* + */ + request<E extends 'drive/files/move-bulk', P extends Endpoints[E]['req']>( + endpoint: E, + params: P, + credential?: string | null, + ): Promise<SwitchCaseResponseType<E, P>>; + + /** * Show the properties of a drive file. * * **Credential required**: *Yes* / **Permission**: *read:drive* diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 13adaa9efd..a108cba7c1 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -282,6 +282,7 @@ import type { DriveFilesFindResponse, DriveFilesFindByHashRequest, DriveFilesFindByHashResponse, + DriveFilesMoveBulkRequest, DriveFilesShowRequest, DriveFilesShowResponse, DriveFilesUpdateRequest, @@ -823,6 +824,7 @@ export type Endpoints = { 'drive/files/delete': { req: DriveFilesDeleteRequest; res: EmptyResponse }; 'drive/files/find': { req: DriveFilesFindRequest; res: DriveFilesFindResponse }; 'drive/files/find-by-hash': { req: DriveFilesFindByHashRequest; res: DriveFilesFindByHashResponse }; + 'drive/files/move-bulk': { req: DriveFilesMoveBulkRequest; res: EmptyResponse }; 'drive/files/show': { req: DriveFilesShowRequest; res: DriveFilesShowResponse }; 'drive/files/update': { req: DriveFilesUpdateRequest; res: DriveFilesUpdateResponse }; 'drive/files/upload-from-url': { req: DriveFilesUploadFromUrlRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 2030e8ae5d..4b18cda5d8 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -285,6 +285,7 @@ export type DriveFilesFindRequest = operations['drive___files___find']['requestB export type DriveFilesFindResponse = operations['drive___files___find']['responses']['200']['content']['application/json']; export type DriveFilesFindByHashRequest = operations['drive___files___find-by-hash']['requestBody']['content']['application/json']; export type DriveFilesFindByHashResponse = operations['drive___files___find-by-hash']['responses']['200']['content']['application/json']; +export type DriveFilesMoveBulkRequest = operations['drive___files___move-bulk']['requestBody']['content']['application/json']; export type DriveFilesShowRequest = operations['drive___files___show']['requestBody']['content']['application/json']; export type DriveFilesShowResponse = operations['drive___files___show']['responses']['200']['content']['application/json']; export type DriveFilesUpdateRequest = operations['drive___files___update']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 1abc721997..4e2c03c784 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1799,6 +1799,15 @@ export type paths = { */ post: operations['drive___files___find-by-hash']; }; + '/drive/files/move-bulk': { + /** + * drive/files/move-bulk + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:drive* + */ + post: operations['drive___files___move-bulk']; + }; '/drive/files/show': { /** * drive/files/show @@ -16846,6 +16855,59 @@ export type operations = { }; }; /** + * drive/files/move-bulk + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:drive* + */ + 'drive___files___move-bulk': { + requestBody: { + content: { + 'application/json': { + fileIds: string[]; + /** Format: misskey:id */ + folderId?: string | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** * drive/files/show * @description Show the properties of a drive file. * |