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:
parent
0dd224266c
commit
73a83c0064
|
@ -1210,6 +1210,10 @@ export interface Locale extends ILocale {
|
|||
* アップロードが完了するまで時間がかかる場合があります。
|
||||
*/
|
||||
"uploadFromUrlMayTakeTime": string;
|
||||
/**
|
||||
* {n}個のファイルをアップロード
|
||||
*/
|
||||
"uploadNFiles": ParameterizedString<"n">;
|
||||
/**
|
||||
* みつける
|
||||
*/
|
||||
|
@ -11898,6 +11902,20 @@ export interface Locale extends ILocale {
|
|||
"text3": string;
|
||||
};
|
||||
};
|
||||
"_uploader": {
|
||||
/**
|
||||
* {x}に圧縮
|
||||
*/
|
||||
"compressedToX": ParameterizedString<"x">;
|
||||
/**
|
||||
* {x}%節約
|
||||
*/
|
||||
"savedXPercent": ParameterizedString<"x">;
|
||||
/**
|
||||
* アップロードされていないファイルがありますが、中止しますか?
|
||||
*/
|
||||
"abortConfirm": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
[lang: string]: Locale;
|
||||
|
|
|
@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード"
|
|||
uploadFromUrlDescription: "アップロードしたいファイルのURL"
|
||||
uploadFromUrlRequested: "アップロードをリクエストしました"
|
||||
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
|
||||
uploadNFiles: "{n}個のファイルをアップロード"
|
||||
explore: "みつける"
|
||||
messageRead: "既読"
|
||||
noMoreHistory: "これより過去の履歴はありません"
|
||||
|
@ -3181,3 +3182,8 @@ _serverSetupWizard:
|
|||
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
|
||||
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
|
||||
text3: "支援者向け特典もあります!"
|
||||
|
||||
_uploader:
|
||||
compressedToX: "{x}に圧縮"
|
||||
savedXPercent: "{x}%節約"
|
||||
abortConfirm: "アップロードされていないファイルがありますが、中止しますか?"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div
|
||||
ref="main"
|
||||
:class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]"
|
||||
:class="[$style.main, { [$style.fetching]: fetching }]"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
|
@ -146,7 +146,6 @@ 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';
|
||||
|
@ -176,7 +175,6 @@ const emit = defineEmits<{
|
|||
|
||||
const folder = ref<Misskey.entities.DriveFolder | null>(null);
|
||||
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const uploadings = uploads;
|
||||
|
||||
// ドロップされようとしているか
|
||||
const draghover = ref(false);
|
||||
|
@ -561,12 +559,6 @@ function getMenu() {
|
|||
menu.push({
|
||||
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',
|
||||
|
@ -766,10 +758,6 @@ onBeforeUnmount(() => {
|
|||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
height: calc(100% - 38px - 100px);
|
||||
}
|
||||
}
|
||||
|
||||
.folders,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -127,7 +127,6 @@ 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';
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -95,7 +95,6 @@ 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 { 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';
|
||||
|
|
|
@ -41,7 +41,6 @@ import { formatTimeString } from '@/utility/format-time-string.js';
|
|||
import { selectFile } from '@/utility/select-file.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';
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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>
|
|
@ -3,25 +3,22 @@
|
|||
* 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 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) => {
|
||||
|
@ -30,15 +27,15 @@ export function chooseFileFromPc(
|
|||
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 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUploadDialog.vue')), {
|
||||
files: markRaw(Array.from(input.files)),
|
||||
folderId: uploadFolder,
|
||||
}, {
|
||||
done: driveFiles => {
|
||||
res(driveFiles);
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
// 一応廃棄
|
||||
|
@ -100,10 +97,6 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul
|
|||
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)),
|
||||
|
|
|
@ -3,160 +3,95 @@
|
|||
* 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();
|
||||
}
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.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) => {
|
||||
const id = uuid();
|
||||
if ($i == null) return reject();
|
||||
|
||||
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),
|
||||
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();
|
||||
}
|
||||
|
||||
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({
|
||||
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.cannotUploadBecauseExceedsFileSizeLimit,
|
||||
text: i18n.ts.cannotUploadBecauseInappropriate,
|
||||
});
|
||||
} 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({
|
||||
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: 'Failed to upload',
|
||||
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
reject();
|
||||
return;
|
||||
} else {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: 'Failed to upload',
|
||||
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const driveFile = JSON.parse(ev.target.response);
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(driveFile);
|
||||
|
||||
uploads.value = uploads.value.filter(x => x.id !== id);
|
||||
}) as (ev: ProgressEvent<EventTarget>) => any;
|
||||
const driveFile = JSON.parse(ev.target.response);
|
||||
resolve(driveFile);
|
||||
}) as (ev: ProgressEvent<EventTarget>) => any;
|
||||
|
||||
if (options.onProgress) {
|
||||
xhr.upload.onprogress = ev => {
|
||||
if (ev.lengthComputable) {
|
||||
ctx.progressMax = ev.total;
|
||||
ctx.progressValue = ev.loaded;
|
||||
options.onProgress({
|
||||
total: ev.total,
|
||||
loaded: ev.loaded,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
Loading…
Reference in New Issue