feat(frontend): upload dialog (#16032)

* wip

* wip

* Update MkUploadDialog.vue

* wip

* wip

* wip

* wip

* wip

* Update MkUploadDialog.vue

* wip

* wip

* Update MkDrive.vue
This commit is contained in:
syuilo 2025-05-13 13:38:29 +09:00 committed by GitHub
parent 0dd224266c
commit 73a83c0064
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 523 additions and 372 deletions

18
locales/index.d.ts vendored
View File

@ -1210,6 +1210,10 @@ export interface Locale extends ILocale {
* *
*/ */
"uploadFromUrlMayTakeTime": string; "uploadFromUrlMayTakeTime": string;
/**
* {n}
*/
"uploadNFiles": ParameterizedString<"n">;
/** /**
* *
*/ */
@ -11898,6 +11902,20 @@ export interface Locale extends ILocale {
"text3": string; "text3": string;
}; };
}; };
"_uploader": {
/**
* {x}
*/
"compressedToX": ParameterizedString<"x">;
/**
* {x}%
*/
"savedXPercent": ParameterizedString<"x">;
/**
*
*/
"abortConfirm": string;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード"
uploadFromUrlDescription: "アップロードしたいファイルのURL" uploadFromUrlDescription: "アップロードしたいファイルのURL"
uploadFromUrlRequested: "アップロードをリクエストしました" uploadFromUrlRequested: "アップロードをリクエストしました"
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。" uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
uploadNFiles: "{n}個のファイルをアップロード"
explore: "みつける" explore: "みつける"
messageRead: "既読" messageRead: "既読"
noMoreHistory: "これより過去の履歴はありません" noMoreHistory: "これより過去の履歴はありません"
@ -3181,3 +3182,8 @@ _serverSetupWizard:
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。" text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。" text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
text3: "支援者向け特典もあります!" text3: "支援者向け特典もあります!"
_uploader:
compressedToX: "{x}に圧縮"
savedXPercent: "{x}%節約"
abortConfirm: "アップロードされていないファイルがありますが、中止しますか?"

View File

@ -15,8 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@closed="emit('closed')" @closed="emit('closed')"
> >
<template #header>{{ i18n.ts.cropImage }}</template> <template #header>{{ i18n.ts.cropImage }}</template>
<template #default="{ width, height }"> <div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
<Transition name="fade"> <Transition name="fade">
<div v-if="loading" class="loading"> <div v-if="loading" class="loading">
<MkLoading/> <MkLoading/>
@ -26,7 +25,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad"> <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div> </div>
</div> </div>
</template>
</MkModalWindow> </MkModalWindow>
</template> </template>

View File

@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div <div
ref="main" ref="main"
:class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]" :class="[$style.main, { [$style.fetching]: fetching }]"
@dragover.prevent.stop="onDragover" @dragover.prevent.stop="onDragover"
@dragenter="onDragenter" @dragenter="onDragenter"
@dragleave="onDragleave" @dragleave="onDragleave"
@ -146,7 +146,6 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { uploadFile, uploads } from '@/utility/upload.js';
import { claimAchievement } from '@/utility/achievements.js'; import { claimAchievement } from '@/utility/achievements.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { chooseFileFromPc } from '@/utility/select-file.js'; import { chooseFileFromPc } from '@/utility/select-file.js';
@ -176,7 +175,6 @@ const emit = defineEmits<{
const folder = ref<Misskey.entities.DriveFolder | null>(null); const folder = ref<Misskey.entities.DriveFolder | null>(null);
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]); const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
const uploadings = uploads;
// //
const draghover = ref(false); const draghover = ref(false);
@ -561,12 +559,6 @@ function getMenu() {
menu.push({ menu.push({
text: i18n.ts.addFile, text: i18n.ts.addFile,
type: 'label', 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, text: i18n.ts.upload,
icon: 'ti ti-upload', icon: 'ti ti-upload',
@ -766,10 +758,6 @@ onBeforeUnmount(() => {
opacity: 0.5; opacity: 0.5;
pointer-events: none; pointer-events: none;
} }
&.uploading {
height: calc(100% - 38px - 100px);
}
} }
.folders, .folders,

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')"> <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="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> <button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
<span :class="$style.title"> <span :class="$style.title">
<slot name="header"></slot> <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> <button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
</div> </div>
<div :class="$style.body"> <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>
</div> </div>
</MkModal> </MkModal>
@ -48,10 +51,6 @@ const emit = defineEmits<{
}>(); }>();
const modal = useTemplateRef('modal'); const modal = useTemplateRef('modal');
const rootEl = useTemplateRef('rootEl');
const headerEl = useTemplateRef('headerEl');
const bodyWidth = ref(0);
const bodyHeight = ref(0);
function close() { function close() {
modal.value?.close(); modal.value?.close();
@ -61,23 +60,6 @@ function onBgClick() {
emit('click'); 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({ defineExpose({
close, close,
}); });
@ -143,7 +125,14 @@ defineExpose({
.body { .body {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
background: var(--MI_THEME-panel); background: var(--MI_THEME-bg);
container-type: size; container-type: size;
} }
.footer {
padding: 8px 16px;
overflow: auto;
background: var(--MI_THEME-bg);
border-top: 1px solid var(--MI_THEME-divider);
}
</style> </style>

View File

@ -127,7 +127,6 @@ import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { ensureSignin, notesCount, incNotesCount } from '@/i.js'; import { ensureSignin, notesCount, incNotesCount } from '@/i.js';
import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js'; import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
import { uploadFile } from '@/utility/upload.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';

View File

@ -0,0 +1,375 @@
<!--
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" 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.progressValue !== null ? `${ctx.progressValue / ctx.progressMax * 100}%` : '0%' }"
>
<div :class="$style.itemInner">
<div>
<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>{{ ctx.name }}</div>
<div :class="$style.itemInfo">
<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>
<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>
<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>
<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-else-if="canRetry" primary rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</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 { apiUrl } from '@@/js/config.js';
import isAnimated from 'is-file-animated';
import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { ensureSignin } from '@/i.js';
import { instance } from '@/instance.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/upload.js';
import * as os from '@/os.js';
const $i = ensureSignin();
const compressionSupportedTypes = [
'image/jpeg',
'image/png',
'image/webp',
'image/svg+xml',
] as const;
const mimeTypeMap = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
} as const;
const props = withDefaults(defineProps<{
files: File[];
folderId?: string | null;
}>(), {
});
const emit = defineEmits<{
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
(ev: 'closed'): void;
}>();
const items = ref([] as {
id: string;
name: string;
progressMax: number | null;
progressValue: 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 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) {
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('done', items.value.filter(item => item.uploaded != null).map(item => item.uploaded!));
dialog.value?.close();
}
function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
}
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 && compressionSupportedTypes.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;
item.progressMax = progress.total;
item.progressValue = progress.loaded;
},
}).catch(err => {
item.uploadFailed = true;
item.progressMax = null;
item.progressValue = null;
throw err;
}).finally(() => {
item.uploading = false;
});
item.uploaded = driveFile;
}
}
onMounted(() => {
for (const file of props.files) {
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,
progressMax: null,
progressValue: null,
thumbnail: window.URL.createObjectURL(file),
waiting: false,
uploading: false,
uploaded: null,
uploadFailed: false,
file: markRaw(file),
});
}
});
</script>
<style lang="scss" module>
.root {
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;
}
@container (max-width: 500px) {
.itemBody {
font-size: 90%;
}
}
.itemInfo {
opacity: 0.7;
margin-top: 4px;
font-size: 90%;
display: flex;
gap: 8px;
}
.itemIcon {
width: 35px;
}
</style>

View File

@ -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]"/> <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]"/> <circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
</svg> </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> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {} from 'vue'; import {} from 'vue';
const props = defineProps<{ const props = defineProps<{
type: 'info' | 'question' | 'success' | 'warn' | 'error'; type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting';
}>(); }>();
</script> </script>
@ -62,6 +66,10 @@ const props = defineProps<{
&.error { &.error {
color: var(--MI_THEME-error); color: var(--MI_THEME-error);
} }
&.waiting {
color: var(--MI_THEME-accent);
}
} }
.line { .line {
@ -87,6 +95,13 @@ const props = defineProps<{
transform: rotate(-90deg); 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 { .animFade {
opacity: 0; opacity: 0;
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards; 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 { @keyframes fade-in {
0% { 0% {
opacity: 0; opacity: 0;

View File

@ -95,7 +95,6 @@ import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { validators } from '@/components/grid/cell-validators.js'; import { validators } from '@/components/grid/cell-validators.js';
import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js'; import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js';
import { uploadFile } from '@/utility/upload.js';
import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';

View File

@ -41,7 +41,6 @@ import { formatTimeString } from '@/utility/format-time-string.js';
import { selectFile } from '@/utility/select-file.js'; import { selectFile } from '@/utility/select-file.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { uploadFile } from '@/utility/upload.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';

View File

@ -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 === 'success'" type="success" style="width: 150px;"/>
<MkSystemIcon v-if="iconType === 'warn'" type="warn" 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 === 'error'" type="error" style="width: 150px;"/>
<MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/>
<MkSelect <MkSelect
v-model="iconType" :items="[ v-model="iconType" :items="[
{ label: 'info', value: 'info' }, { label: 'info', value: 'info' },
@ -30,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{ label: 'success', value: 'success' }, { label: 'success', value: 'success' },
{ label: 'warn', value: 'warn' }, { label: 'warn', value: 'warn' },
{ label: 'error', value: 'error' }, { label: 'error', value: 'error' },
{ label: 'waiting', value: 'waiting' },
]" ]"
></MkSelect> ></MkSelect>

View File

@ -65,8 +65,6 @@ SPDX-License-Identifier: AGPL-3.0-only
v-on="popup.events" v-on="popup.events"
/> />
<XUpload v-if="uploads.length > 0"/>
<component <component
:is="prefer.s.animation ? TransitionGroup : 'div'" :is="prefer.s.animation ? TransitionGroup : 'div'"
tag="div" tag="div"
@ -105,7 +103,6 @@ import { swInject } from './sw-inject.js';
import XNotification from './notification.vue'; import XNotification from './notification.vue';
import { popups } from '@/os.js'; import { popups } from '@/os.js';
import { pendingApiRequestsCount } from '@/utility/misskey-api.js'; import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
import { uploads } from '@/utility/upload.js';
import * as sound from '@/utility/sound.js'; import * as sound from '@/utility/sound.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
@ -116,7 +113,6 @@ import { store } from '@/store.js';
import XNavbar from '@/ui/_common_/navbar.vue'; import XNavbar from '@/ui/_common_/navbar.vue';
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue')); const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
const XUpload = defineAsyncComponent(() => import('./upload.vue'));
const XWidgets = defineAsyncComponent(() => import('./widgets.vue')); const XWidgets = defineAsyncComponent(() => import('./widgets.vue'));
const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing'); const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing');

View File

@ -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>

View File

@ -3,25 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { ref } from 'vue'; import { defineAsyncComponent, markRaw, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { uploadFile } from '@/utility/upload.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
export function chooseFileFromPc( export function chooseFileFromPc(
multiple: boolean, multiple: boolean,
options?: { options?: {
uploadFolder?: string | null; uploadFolder?: string | null;
keepOriginal?: boolean;
nameConverter?: (file: File) => string | undefined; nameConverter?: (file: File) => string | undefined;
}, },
): Promise<Misskey.entities.DriveFile[]> { ): Promise<Misskey.entities.DriveFile[]> {
const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder; const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
const keepOriginal = options?.keepOriginal ?? false;
const nameConverter = options?.nameConverter ?? (() => undefined); const nameConverter = options?.nameConverter ?? (() => undefined);
return new Promise((res, rej) => { return new Promise((res, rej) => {
@ -30,15 +27,15 @@ export function chooseFileFromPc(
input.multiple = multiple; input.multiple = multiple;
input.onchange = () => { input.onchange = () => {
if (!input.files) return res([]); if (!input.files) return res([]);
const promises = Array.from(
input.files,
file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
);
Promise.all(promises).then(driveFiles => { const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUploadDialog.vue')), {
files: markRaw(Array.from(input.files)),
folderId: uploadFolder,
}, {
done: driveFiles => {
res(driveFiles); res(driveFiles);
}).catch(err => { },
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない closed: () => dispose(),
}); });
// 一応廃棄 // 一応廃棄
@ -100,10 +97,6 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul
text: label, text: label,
type: 'label', type: 'label',
} : undefined, { } : 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, text: i18n.ts.upload,
icon: 'ti ti-upload', icon: 'ti ti-upload',
action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)), action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)),

View File

@ -3,107 +3,36 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { reactive, ref } from 'vue';
import * as Misskey from 'misskey-js'; 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 { apiUrl } from '@@/js/config.js';
import { getCompressionConfig } from './upload/compress-config.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { alert } from '@/os.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
type Uploading = { export function uploadFile(file: File | Blob, options: {
id: string; name?: string;
name: string; folderId?: string | null;
progressMax: number | undefined; onProgress?: (ctx: { total: number; loaded: number; }) => void;
progressValue: number | undefined; } = {}): Promise<Misskey.entities.DriveFile> {
img: string; return new Promise((resolve, reject) => {
}; if ($i == null) return reject();
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))) { if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.failedToUpload, title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
}); });
return Promise.reject(); return 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(); const xhr = new XMLHttpRequest();
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) {
// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
uploads.value = uploads.value.filter(x => x.id !== id);
if (xhr.status === 413) { if (xhr.status === 413) {
alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.failedToUpload, title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
@ -111,26 +40,26 @@ export function uploadFile(
} else if (ev.target?.response) { } else if (ev.target?.response) {
const res = JSON.parse(ev.target.response); const res = JSON.parse(ev.target.response);
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.failedToUpload, title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseInappropriate, text: i18n.ts.cannotUploadBecauseInappropriate,
}); });
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.failedToUpload, title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseNoFreeSpace, text: i18n.ts.cannotUploadBecauseNoFreeSpace,
}); });
} else { } else {
alert({ os.alert({
type: 'error', type: 'error',
title: i18n.ts.failedToUpload, title: i18n.ts.failedToUpload,
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
}); });
} }
} else { } else {
alert({ os.alert({
type: 'error', type: 'error',
title: 'Failed to upload', title: 'Failed to upload',
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
@ -142,21 +71,27 @@ export function uploadFile(
} }
const driveFile = JSON.parse(ev.target.response); const driveFile = JSON.parse(ev.target.response);
resolve(driveFile); resolve(driveFile);
uploads.value = uploads.value.filter(x => x.id !== id);
}) as (ev: ProgressEvent<EventTarget>) => any; }) as (ev: ProgressEvent<EventTarget>) => any;
if (options.onProgress) {
xhr.upload.onprogress = ev => { xhr.upload.onprogress = ev => {
if (ev.lengthComputable) { if (ev.lengthComputable) {
ctx.progressMax = ev.total; options.onProgress({
ctx.progressValue = ev.loaded; 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); xhr.send(formData);
};
reader.readAsArrayBuffer(file);
}); });
} }

View File

@ -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,
};
}