570 lines
14 KiB
Vue
570 lines
14 KiB
Vue
<!--
|
||
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>
|