enhance(frontend): ファイルのアップロードを中止できるように (#16069)
* enhance(frontend): ファイルのアップロードを中止できるように * Update Changelog * fix: ダイアログを閉じたり、中断ボタンが押されたりしたときはその後のアップロードをすべて中止するように * fix
This commit is contained in:
parent
e61b5abb05
commit
ccf5bd337e
|
@ -22,6 +22,7 @@
|
||||||
- アップロードに失敗したときに再試行できるようになりました
|
- アップロードに失敗したときに再試行できるようになりました
|
||||||
- アップロード前に画像のクロッピングを行えるようになりました
|
- アップロード前に画像のクロッピングを行えるようになりました
|
||||||
- ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました
|
- ファイルサイズのチェックは圧縮後の実際にアップロードされるサイズで行われるようになりました
|
||||||
|
- ファイルのアップロードを中断できるようになりました
|
||||||
- Feat: サーバー初期設定ウィザードが実装されました
|
- Feat: サーバー初期設定ウィザードが実装されました
|
||||||
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
|
- 簡単なウィザードに従うだけで、サーバーに最適な設定が適用されます
|
||||||
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)
|
- Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta)
|
||||||
|
|
|
@ -5449,6 +5449,10 @@ export interface Locale extends ILocale {
|
||||||
* {x}のミュートを解除
|
* {x}のミュートを解除
|
||||||
*/
|
*/
|
||||||
"unmuteX": ParameterizedString<"x">;
|
"unmuteX": ParameterizedString<"x">;
|
||||||
|
/**
|
||||||
|
* 中止
|
||||||
|
*/
|
||||||
|
"abort": string;
|
||||||
"_chat": {
|
"_chat": {
|
||||||
/**
|
/**
|
||||||
* まだメッセージはありません
|
* まだメッセージはありません
|
||||||
|
|
|
@ -1357,6 +1357,7 @@ emojiMute: "絵文字ミュート"
|
||||||
emojiUnmute: "絵文字ミュート解除"
|
emojiUnmute: "絵文字ミュート解除"
|
||||||
muteX: "{x}をミュート"
|
muteX: "{x}をミュート"
|
||||||
unmuteX: "{x}のミュートを解除"
|
unmuteX: "{x}のミュートを解除"
|
||||||
|
abort: "中止"
|
||||||
|
|
||||||
_chat:
|
_chat:
|
||||||
noMessagesYet: "まだメッセージはありません"
|
noMessagesYet: "まだメッセージはありません"
|
||||||
|
|
|
@ -130,7 +130,7 @@ defineExpose({
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
padding: 8px 16px;
|
padding: 12px 16px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--MI_THEME-bg);
|
background: var(--MI_THEME-bg);
|
||||||
border-top: 1px solid var(--MI_THEME-divider);
|
border-top: 1px solid var(--MI_THEME-divider);
|
||||||
|
|
|
@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.root">
|
<div :class="$style.root">
|
||||||
<div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div>
|
<div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div>
|
||||||
|
|
||||||
<div :class="$style.main" class="_gaps_s">
|
<div class="_gaps_s _spacer">
|
||||||
<div :class="$style.items" class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<div
|
<div
|
||||||
v-for="ctx in items"
|
v-for="ctx in items"
|
||||||
:key="ctx.id"
|
:key="ctx.id"
|
||||||
|
@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="_buttonsCenter">
|
<div class="_buttonsCenter">
|
||||||
<MkButton v-if="isUploading" rounded @click="cancel()"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
|
<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-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="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
|
||||||
|
@ -96,10 +96,9 @@ import { i18n } from '@/i18n.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import bytes from '@/filters/bytes.js';
|
import bytes from '@/filters/bytes.js';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||||
import { uploadFile } from '@/utility/drive.js';
|
import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { ensureSignin } from '@/i.js';
|
import { ensureSignin } from '@/i.js';
|
||||||
|
|
||||||
|
@ -138,7 +137,7 @@ const emit = defineEmits<{
|
||||||
(ev: 'closed'): void;
|
(ev: 'closed'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const items = ref([] as {
|
const items = ref<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
progress: { max: number; value: number } | null;
|
progress: { max: number; value: number } | null;
|
||||||
|
@ -147,10 +146,12 @@ const items = ref([] as {
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
uploaded: Misskey.entities.DriveFile | null;
|
uploaded: Misskey.entities.DriveFile | null;
|
||||||
uploadFailed: boolean;
|
uploadFailed: boolean;
|
||||||
|
aborted: boolean;
|
||||||
compressedSize?: number | null;
|
compressedSize?: number | null;
|
||||||
compressedImage?: Blob | null;
|
compressedImage?: Blob | null;
|
||||||
file: File;
|
file: File;
|
||||||
}[]);
|
abort?: (() => void) | null;
|
||||||
|
}[]>([]);
|
||||||
|
|
||||||
const dialog = useTemplateRef('dialog');
|
const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
|
@ -213,10 +214,23 @@ async function cancel() {
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
|
abortAll();
|
||||||
emit('canceled');
|
emit('canceled');
|
||||||
dialog.value?.close();
|
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() {
|
async function done() {
|
||||||
if (items.value.some(item => item.uploaded == null)) {
|
if (items.value.some(item => item.uploaded == null)) {
|
||||||
const { canceled } = await os.confirm({
|
const { canceled } = await os.confirm({
|
||||||
|
@ -258,6 +272,17 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
items.value.splice(items.value.indexOf(item), 1);
|
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);
|
os.popupMenu(menu, ev.currentTarget ?? ev.target);
|
||||||
|
@ -266,7 +291,20 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
|
async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
|
||||||
firstUploadAttempted.value = true;
|
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)) {
|
for (const item of items.value.filter(item => item.uploaded == null)) {
|
||||||
|
// アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック
|
||||||
|
if (item.aborted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
item.waiting = true;
|
item.waiting = true;
|
||||||
item.uploadFailed = false;
|
item.uploadFailed = false;
|
||||||
|
|
||||||
|
@ -296,7 +334,7 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
|
|
||||||
item.uploading = true;
|
item.uploading = true;
|
||||||
|
|
||||||
const driveFile = await uploadFile(item.compressedImage ?? item.file, {
|
const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
folderId: props.folderId,
|
folderId: props.folderId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
|
@ -308,16 +346,43 @@ async function upload() { // エラーハンドリングなどを考慮してシ
|
||||||
item.progress.max = progress.total;
|
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 => {
|
}).catch(err => {
|
||||||
item.uploadFailed = true;
|
item.uploadFailed = true;
|
||||||
item.progress = null;
|
item.progress = null;
|
||||||
throw err;
|
if (!(err instanceof UploadAbortedError)) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
item.uploading = false;
|
item.uploading = false;
|
||||||
item.waiting = false;
|
item.waiting = false;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item.uploaded = driveFile;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -340,6 +405,7 @@ function initializeFile(file: File) {
|
||||||
thumbnail: window.URL.createObjectURL(file),
|
thumbnail: window.URL.createObjectURL(file),
|
||||||
waiting: false,
|
waiting: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
|
aborted: false,
|
||||||
uploaded: null,
|
uploaded: null,
|
||||||
uploadFailed: false,
|
uploadFailed: false,
|
||||||
file: markRaw(file),
|
file: markRaw(file),
|
||||||
|
@ -373,13 +439,6 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items {
|
|
||||||
}
|
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
|
@ -16,12 +16,27 @@ import { instance } from '@/instance.js';
|
||||||
import { globalEvents } from '@/events.js';
|
import { globalEvents } from '@/events.js';
|
||||||
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
||||||
|
|
||||||
|
type UploadReturnType = {
|
||||||
|
filePromise: Promise<Misskey.entities.DriveFile>;
|
||||||
|
abort: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UploadAbortedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Upload aborted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function uploadFile(file: File | Blob, options: {
|
export function uploadFile(file: File | Blob, options: {
|
||||||
name?: string;
|
name?: string;
|
||||||
folderId?: string | null;
|
folderId?: string | null;
|
||||||
onProgress?: (ctx: { total: number; loaded: number; }) => void;
|
onProgress?: (ctx: { total: number; loaded: number; }) => void;
|
||||||
} = {}): Promise<Misskey.entities.DriveFile> {
|
} = {}): UploadReturnType {
|
||||||
return new Promise((resolve, reject) => {
|
const xhr = new XMLHttpRequest();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const { signal } = abortController;
|
||||||
|
|
||||||
|
const filePromise = new Promise<Misskey.entities.DriveFile>((resolve, reject) => {
|
||||||
if ($i == null) return reject();
|
if ($i == null) return reject();
|
||||||
|
|
||||||
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
|
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
|
||||||
|
@ -33,7 +48,10 @@ export function uploadFile(file: File | Blob, options: {
|
||||||
return reject();
|
return reject();
|
||||||
}
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
signal.addEventListener('abort', () => {
|
||||||
|
reject(new UploadAbortedError());
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
xhr.open('POST', apiUrl + '/drive/files/create', true);
|
xhr.open('POST', apiUrl + '/drive/files/create', true);
|
||||||
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
|
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
|
||||||
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
|
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
|
||||||
|
@ -83,7 +101,7 @@ export function uploadFile(file: File | Blob, options: {
|
||||||
|
|
||||||
if (options.onProgress) {
|
if (options.onProgress) {
|
||||||
xhr.upload.onprogress = ev => {
|
xhr.upload.onprogress = ev => {
|
||||||
if (ev.lengthComputable) {
|
if (ev.lengthComputable && options.onProgress != null) {
|
||||||
options.onProgress({
|
options.onProgress({
|
||||||
total: ev.total,
|
total: ev.total,
|
||||||
loaded: ev.loaded,
|
loaded: ev.loaded,
|
||||||
|
@ -96,11 +114,18 @@ export function uploadFile(file: File | Blob, options: {
|
||||||
formData.append('i', $i.token);
|
formData.append('i', $i.token);
|
||||||
formData.append('force', 'true');
|
formData.append('force', 'true');
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('name', options.name ?? file.name ?? 'untitled');
|
formData.append('name', options.name ?? (file instanceof File ? file.name : 'untitled'));
|
||||||
if (options.folderId) formData.append('folderId', options.folderId);
|
if (options.folderId) formData.append('folderId', options.folderId);
|
||||||
|
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const abort = () => {
|
||||||
|
xhr.abort();
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { filePromise, abort };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function chooseFileFromPcAndUpload(
|
export function chooseFileFromPcAndUpload(
|
||||||
|
@ -126,7 +151,7 @@ export function chooseDriveFile(options: {
|
||||||
} = {}): Promise<Misskey.entities.DriveFile[]> {
|
} = {}): Promise<Misskey.entities.DriveFile[]> {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), {
|
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveFileSelectDialog.vue')), {
|
||||||
multiple: options.multiple,
|
multiple: options.multiple ?? false,
|
||||||
}, {
|
}, {
|
||||||
done: files => {
|
done: files => {
|
||||||
if (files) {
|
if (files) {
|
||||||
|
@ -204,7 +229,7 @@ export function selectFiles(src: HTMLElement | EventTarget | null, label: string
|
||||||
export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {
|
export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFile: Misskey.entities.DriveFile, options: {
|
||||||
aspectRatio: number | null;
|
aspectRatio: number | null;
|
||||||
}): Promise<Misskey.entities.DriveFile> {
|
}): Promise<Misskey.entities.DriveFile> {
|
||||||
return new Promise(resolve => {
|
return new Promise((resolve, reject) => {
|
||||||
const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true);
|
const imgUrl = getProxiedImageUrl(imageDriveFile.url, undefined, true);
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = imgUrl;
|
image.src = imgUrl;
|
||||||
|
@ -215,13 +240,20 @@ export async function createCroppedImageDriveFileFromImageDriveFile(imageDriveFi
|
||||||
canvas.height = image.height;
|
canvas.height = image.height;
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
canvas.toBlob(blob => {
|
canvas.toBlob(blob => {
|
||||||
|
if (blob == null) {
|
||||||
|
reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
os.cropImageFile(blob, {
|
os.cropImageFile(blob, {
|
||||||
aspectRatio: options.aspectRatio,
|
aspectRatio: options.aspectRatio,
|
||||||
}).then(croppedImageFile => {
|
}).then(croppedImageFile => {
|
||||||
uploadFile(croppedImageFile, {
|
const { filePromise } = uploadFile(croppedImageFile, {
|
||||||
name: imageDriveFile.name,
|
name: imageDriveFile.name,
|
||||||
folderId: imageDriveFile.folderId,
|
folderId: imageDriveFile.folderId,
|
||||||
}).then(driveFile => {
|
});
|
||||||
|
|
||||||
|
filePromise.then(driveFile => {
|
||||||
resolve(driveFile);
|
resolve(driveFile);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in New Issue