<!-- 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="_gaps_s _spacer"> <MkTip k="uploader"> {{ i18n.ts._uploader.tip }} </MkTip> <div 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>{{ i18n.ts._uploader.allowedTypes }}: {{ $i.policies.uploadableFileTypes.join(', ') }}</div> </div> </div> <template #footer> <div class="_buttonsCenter"> <MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</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 MkSelect from '@/components/MkSelect.vue'; import { isWebpSupported } from '@/utility/isWebpSupported.js'; import { uploadFile, UploadAbortedError } 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<{ id: string; name: string; progress: { max: number; value: number } | null; thumbnail: string; waiting: boolean; uploading: boolean; uploaded: Misskey.entities.DriveFile | null; uploadFailed: boolean; aborted: boolean; compressedSize?: number | null; compressedImage?: Blob | null; file: File; abort?: (() => void) | null; }[]>([]); 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; abortAll(); emit('canceled'); dialog.value?.close(); } async function abortWithConfirm() { const { canceled } = await os.confirm({ type: 'question', text: i18n.ts._uploader.abortConfirm, okText: i18n.ts.yes, cancelText: i18n.ts.no, }); if (canceled) return; abortAll(); } 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); }, }); } else if (item.uploading) { menu.push({ icon: 'ti ti-cloud-pause', text: i18n.ts.abort, danger: true, action: () => { if (item.abort != null) { item.abort(); } }, }); } os.popupMenu(menu, ev.currentTarget ?? ev.target); } async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる firstUploadAttempted.value = true; items.value = items.value.map(item => ({ ...item, aborted: false, uploadFailed: false, waiting: false, uploading: false, })); for (const item of items.value.filter(item => item.uploaded == null)) { // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック if (item.aborted) { continue; } 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 { filePromise, abort } = 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; } }, }); item.abort = () => { item.abort = null; abort(); item.uploading = false; item.waiting = false; item.uploadFailed = true; }; await filePromise.then((file) => { item.uploaded = file; item.abort = null; }).catch(err => { item.uploadFailed = true; item.progress = null; if (!(err instanceof UploadAbortedError)) { throw err; } }).finally(() => { item.uploading = false; item.waiting = false; }); } } function abortAll() { for (const item of items.value) { if (item.uploaded != null) { continue; } if (item.abort != null) { item.abort(); } item.aborted = true; item.uploadFailed = true; } } 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, aborted: 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); } } .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>