summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-06-07 07:47:43 +0900
committerGitHub <noreply@github.com>2025-06-07 07:47:43 +0900
commit9bd5f887de6515f93c7db48d7d1370898b2d7b78 (patch)
treea18590e7d5214958d5dc77e179747ed3bff0d5a8 /packages
parentrefactor(frontend): refactor tips (diff)
downloadmisskey-9bd5f887de6515f93c7db48d7d1370898b2d7b78.tar.gz
misskey-9bd5f887de6515f93c7db48d7d1370898b2d7b78.tar.bz2
misskey-9bd5f887de6515f93c7db48d7d1370898b2d7b78.zip
enhance(frontend): 投稿フォームにアップローダーを埋め込み (#16173)
* wip * Update MkPostForm.vue * wip * wip * Update MkPostForm.vue * wip * wip * add tip * Update tips.ts * Update MkPostForm.vue
Diffstat (limited to 'packages')
-rw-r--r--packages/frontend/src/components/MkPostForm.vue97
-rw-r--r--packages/frontend/src/components/MkPostFormDialog.vue15
-rw-r--r--packages/frontend/src/components/MkUploaderDialog.vue674
-rw-r--r--packages/frontend/src/components/MkUploaderItems.vue196
-rw-r--r--packages/frontend/src/composables/use-uploader.ts535
-rw-r--r--packages/frontend/src/tips.ts1
6 files changed, 858 insertions, 660 deletions
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index cd4fabea02..46893a0752 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -72,24 +72,29 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
+ <div v-if="uploader.items.value.length > 0" style="padding: 12px;">
+ <MkTip k="postFormUploader">
+ {{ i18n.ts._postForm.uploaderTip }}
+ </MkTip>
+ <MkUploaderItems :items="uploader.items.value" @showMenu="(item, ev) => showPerUploadItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerUploadItemMenuViaContextmenu(item, ev)"/>
+ </div>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
</div>
<footer :class="$style.footer">
<div :class="$style.footerLeft">
- <button v-tooltip="i18n.ts.attachFile" class="_button" :class="$style.footerButton" @click="chooseFileFrom"><i class="ti ti-photo-plus"></i></button>
+ <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.upload + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromPc"><i class="ti ti-photo-plus"></i></button>
+ <button v-tooltip="i18n.ts.attachFile + ' (' + i18n.ts.fromDrive + ')'" class="_button" :class="$style.footerButton" @click="chooseFileFromDrive"><i class="ti ti-cloud-download"></i></button>
<button v-tooltip="i18n.ts.poll" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: poll }]" @click="togglePoll"><i class="ti ti-chart-arrows"></i></button>
<button v-tooltip="i18n.ts.useCw" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: useCw }]" @click="useCw = !useCw"><i class="ti ti-eye-off"></i></button>
- <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
<button v-tooltip="i18n.ts.hashtags" class="_button" :class="[$style.footerButton, { [$style.footerButtonActive]: withHashtags }]" @click="withHashtags = !withHashtags"><i class="ti ti-hash"></i></button>
- <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
- <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
+ <button v-tooltip="i18n.ts.mention" class="_button" :class="$style.footerButton" @click="insertMention"><i class="ti ti-at"></i></button>
<button v-if="showAddMfmFunction" v-tooltip="i18n.ts.addMfmFunction" :class="['_button', $style.footerButton]" @click="insertMfmFunction"><i class="ti ti-palette"></i></button>
+ <button v-if="postFormActions.length > 0" v-tooltip="i18n.ts.plugins" class="_button" :class="$style.footerButton" @click="showActions"><i class="ti ti-plug"></i></button>
</div>
<div :class="$style.footerRight">
- <button v-tooltip="i18n.ts.previewNoteText" class="_button" :class="[$style.footerButton, { [$style.previewButtonActive]: showPreview }]" @click="showPreview = !showPreview"><i class="ti ti-eye"></i></button>
- <!--<button v-tooltip="i18n.ts.more" class="_button" :class="$style.footerButton" @click="showingOptions = !showingOptions"><i class="ti ti-dots"></i></button>-->
+ <button v-tooltip="i18n.ts.emoji" :class="['_button', $style.footerButton]" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
</div>
</footer>
<datalist id="hashtags">
@@ -105,10 +110,12 @@ import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode.js';
import { host, url } from '@@/js/config.js';
+import MkUploaderItems from './MkUploaderItems.vue';
import type { ShallowRef } from 'vue';
import type { PostFormProps } from '@/types/post-form.js';
import type { MenuItem } from '@/types/menu.js';
import type { PollEditorModelValue } from '@/components/MkPollEditor.vue';
+import type { UploaderItem } from '@/composables/use-uploader.js';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import XTextCounter from '@/components/MkPostForm.TextCounter.vue';
@@ -120,7 +127,7 @@ import { formatTimeString } from '@/utility/format-time-string.js';
import { Autocomplete } from '@/utility/autocomplete.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
-import { selectFile } from '@/utility/drive.js';
+import { chooseDriveFile } from '@/utility/drive.js';
import { store } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n.js';
@@ -138,6 +145,7 @@ import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
+import { useUploader } from '@/composables/use-uploader.js';
const $i = ensureSignin();
@@ -201,6 +209,15 @@ const justEndedComposition = ref(false);
const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
const postFormActions = getPluginHandlers('post_form_action');
+const uploader = useUploader({
+ multiple: true,
+});
+
+uploader.events.on('itemUploaded', ctx => {
+ files.value.push(ctx.item.uploaded!);
+ uploader.removeItem(ctx.item);
+});
+
const draftKey = computed((): string => {
let key = props.channel ? `channel:${props.channel.id}` : '';
@@ -258,10 +275,11 @@ const cwTextLength = computed((): number => {
const maxCwTextLength = 100;
const canPost = computed((): boolean => {
- return !props.mock && !posting.value && !posted.value &&
+ return !props.mock && !posting.value && !posted.value && !uploader.uploading.value && (uploader.items.value.length === 0 || uploader.readyForUpload.value) &&
(
1 <= textLength.value ||
1 <= files.value.length ||
+ 1 <= uploader.items.value.length ||
poll.value != null ||
renoteTargetNote.value != null ||
quoteId.value != null
@@ -434,17 +452,20 @@ function focus() {
}
}
-function chooseFileFrom(ev) {
+function chooseFileFromPc(ev: MouseEvent) {
if (props.mock) return;
- selectFile({
- anchorElement: ev.currentTarget ?? ev.target,
- multiple: true,
- label: i18n.ts.attachFile,
- }).then(files_ => {
- for (const file of files_) {
- files.value.push(file);
- }
+ os.chooseFileFromPc({ multiple: true }).then(files => {
+ if (files.length === 0) return;
+ uploader.addFiles(files);
+ });
+}
+
+function chooseFileFromDrive(ev: MouseEvent) {
+ if (props.mock) return;
+
+ chooseDriveFile({ multiple: true }).then(driveFiles => {
+ files.value.push(...driveFiles);
});
}
@@ -571,6 +592,10 @@ function showOtherSettings() {
toggleReactionAcceptance();
},
}, { type: 'divider' }, {
+ type: 'switch',
+ text: i18n.ts.preview,
+ ref: showPreview,
+ }, {
icon: 'ti ti-trash',
text: i18n.ts.reset,
danger: true,
@@ -797,6 +822,15 @@ function isAnnoying(text: string): boolean {
text.includes('$[position');
}
+async function uploadFiles() {
+ await uploader.upload();
+
+ for (const uploadedItem of uploader.items.value.filter(x => x.uploaded != null)) {
+ files.value.push(uploadedItem.uploaded!);
+ uploader.removeItem(uploadedItem);
+ }
+}
+
async function post(ev?: MouseEvent) {
if (ev) {
const el = (ev.currentTarget ?? ev.target) as HTMLElement | null;
@@ -840,6 +874,10 @@ async function post(ev?: MouseEvent) {
}
}
+ if (uploader.items.value.some(x => x.uploaded == null)) {
+ await uploadFiles();
+ }
+
let postData = {
text: text.value === '' ? null : text.value,
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
@@ -1043,6 +1081,16 @@ function openAccountMenu(ev: MouseEvent) {
}, ev);
}
+function showPerUploadItemMenu(item: UploaderItem, ev: MouseEvent) {
+ const menu = uploader.getMenu(item);
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
+}
+
+function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) {
+ const menu = uploader.getMenu(item);
+ os.contextMenu(menu, ev);
+}
+
onMounted(() => {
if (props.autofocus) {
focus();
@@ -1111,8 +1159,23 @@ onMounted(() => {
});
});
+async function canClose() {
+ if (!uploader.allItemsUploaded.value) {
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.ts._postForm.quitInspiteOfThereAreUnuploadedFilesConfirm,
+ okText: i18n.ts.yes,
+ cancelText: i18n.ts.no,
+ });
+ if (canceled) return false;
+ }
+
+ return true;
+}
+
defineExpose({
clear,
+ canClose,
});
</script>
diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue
index c467e29df6..0a655bab99 100644
--- a/packages/frontend/src/components/MkPostFormDialog.vue
+++ b/packages/frontend/src/components/MkPostFormDialog.vue
@@ -7,9 +7,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModal
ref="modal"
:preferType="'dialog'"
- @click="modal?.close()"
+ @click="_close()"
@closed="onModalClosed()"
- @esc="modal?.close()"
+ @esc="_close()"
>
<MkPostForm
ref="form"
@@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
autofocus
freezeAfterPosted
@posted="onPosted"
- @cancel="modal?.close()"
- @esc="modal?.close()"
+ @cancel="_close()"
+ @esc="_close()"
/>
</MkModal>
</template>
@@ -43,6 +43,7 @@ const emit = defineEmits<{
}>();
const modal = useTemplateRef('modal');
+const form = useTemplateRef('form');
function onPosted() {
modal.value?.close({
@@ -50,6 +51,12 @@ function onPosted() {
});
}
+async function _close() {
+ const canClose = await form.value?.canClose();
+ if (!canClose) return;
+ modal.value?.close();
+}
+
function onModalClosed() {
emit('closed');
}
diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue
index 6a0909bded..ce098d71e4 100644
--- a/packages/frontend/src/components/MkUploaderDialog.vue
+++ b/packages/frontend/src/components/MkUploaderDialog.vue
@@ -23,37 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts._uploader.tip }}
</MkTip>
- <div class="_gaps_s">
- <div
- v-for="ctx in items"
- :key="ctx.id"
- v-panel
- :class="[$style.item, ctx.preprocessing ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
- :style="{ '--p': ctx.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
- >
- <div :class="$style.itemInner">
- <div :class="$style.itemActionWrapper">
- <MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton>
- </div>
- <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
- <div :class="$style.itemBody">
- <div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div>
- <div :class="$style.itemInfo">
- <span>{{ ctx.file.type }}</span>
- <span 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>
- <span v-else>{{ bytes(ctx.file.size) }}</span>
- </div>
- <div>
- </div>
- </div>
- <div :class="$style.itemIconWrapper">
- <MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/>
- <MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/>
- <MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/>
- </div>
- </div>
- </div>
- </div>
+ <MkUploaderItems :items="items" @showMenu="(item, ev) => showPerItemMenu(item, ev)" @showMenuViaContextmenu="(item, ev) => showPerItemMenuViaContextmenu(item, ev)"/>
<div v-if="props.multiple">
<MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
@@ -69,8 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #footer>
<div class="_buttonsCenter">
- <MkButton v-if="isUploading" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton>
- <MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
+ <MkButton v-if="uploader.uploading.value" rounded @click="abortWithConfirm()"><i class="ti ti-x"></i> {{ i18n.ts.abort }}</MkButton>
+ <MkButton v-else-if="!firstUploadAttempted" primary rounded :disabled="!uploader.readyForUpload.value" @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
<MkButton v-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
<MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</MkButton>
@@ -79,110 +49,51 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkModalWindow>
</template>
-<script lang="ts">
-export type UploaderDialogFeatures = {
- effect?: boolean;
- watermark?: boolean;
- crop?: boolean;
-};
-</script>
-
<script lang="ts" setup>
-import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue';
+import { computed, onMounted, ref, useTemplateRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
-import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
-import isAnimated from 'is-file-animated';
-import type { MenuItem } from '@/types/menu.js';
-import { genId } from '@/utility/id.js';
+import type { UploaderFeatures, UploaderItem } from '@/composables/use-uploader.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
-import { prefer } from '@/preferences.js';
import MkButton from '@/components/MkButton.vue';
-import bytes from '@/filters/bytes.js';
-import { isWebpSupported } from '@/utility/isWebpSupported.js';
-import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
-import { WatermarkRenderer } from '@/utility/watermark.js';
+import { useUploader } from '@/composables/use-uploader.js';
+import MkUploaderItems from '@/components/MkUploaderItems.vue';
const $i = ensureSignin();
-const COMPRESSION_SUPPORTED_TYPES = [
- 'image/jpeg',
- 'image/png',
- 'image/webp',
- 'image/svg+xml',
-];
-
-const CROPPING_SUPPORTED_TYPES = [
- 'image/jpeg',
- 'image/png',
- 'image/webp',
-];
-
-const IMAGE_EDITING_SUPPORTED_TYPES = [
- 'image/jpeg',
- 'image/png',
- 'image/webp',
-];
-
-const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
-
-const mimeTypeMap = {
- 'image/webp': 'webp',
- 'image/jpeg': 'jpg',
- 'image/png': 'png',
-} as const;
-
const props = withDefaults(defineProps<{
files: File[];
folderId?: string | null;
multiple?: boolean;
- features?: UploaderDialogFeatures;
+ features?: UploaderFeatures;
}>(), {
multiple: true,
});
-const uploaderFeatures = computed<Required<UploaderDialogFeatures>>(() => {
- return {
- effect: props.features?.effect ?? true,
- watermark: props.features?.watermark ?? true,
- crop: props.features?.crop ?? true,
- };
-});
-
const emit = defineEmits<{
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
(ev: 'canceled'): void;
(ev: 'closed'): void;
}>();
-type UploaderItem = {
- id: string;
- name: string;
- uploadName?: string;
- progress: { max: number; value: number } | null;
- thumbnail: string;
- preprocessing: boolean;
- uploading: boolean;
- uploaded: Misskey.entities.DriveFile | null;
- uploadFailed: boolean;
- aborted: boolean;
- compressionLevel: 0 | 1 | 2 | 3;
- compressedSize?: number | null;
- preprocessedFile?: Blob | null;
- file: File;
- watermarkPresetId: string | null;
- abort?: (() => void) | null;
-};
+const dialog = useTemplateRef('dialog');
+
+const uploader = useUploader({
+ multiple: props.multiple,
+ folderId: props.folderId,
+ features: props.features,
+});
-const items = ref<UploaderItem[]>([]);
+onMounted(() => {
+ uploader.addFiles(props.files);
+});
-const dialog = useTemplateRef('dialog');
+const items = uploader.items;
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.preprocessing) && items.value.some(item => item.uploaded == null));
+const canRetry = computed(() => firstUploadAttempted.value && uploader.readyForUpload.value);
const canDone = computed(() => items.value.some(item => item.uploaded != null));
const overallProgress = computed(() => {
const max = items.value.length;
@@ -195,27 +106,6 @@ const overallProgress = computed(() => {
return Math.round((v / max) * 100);
});
-function getCompressionSettings(level: 0 | 1 | 2 | 3) {
- if (level === 1) {
- return {
- maxWidth: 2000,
- maxHeight: 2000,
- };
- } else if (level === 2) {
- return {
- maxWidth: 2000 * 0.75, // =1500
- maxHeight: 2000 * 0.75, // =1500
- };
- } else if (level === 3) {
- return {
- maxWidth: 2000 * 0.75 * 0.75, // =1125
- maxHeight: 2000 * 0.75 * 0.75, // =1125
- };
- } else {
- return null;
- }
-}
-
watch(items, () => {
if (items.value.length === 0) {
emit('canceled');
@@ -238,11 +128,16 @@ async function cancel() {
});
if (canceled) return;
- abortAll();
+ uploader.abortAll();
emit('canceled');
dialog.value?.close();
}
+function upload() {
+ firstUploadAttempted.value = true;
+ uploader.upload();
+}
+
async function abortWithConfirm() {
const { canceled } = await os.confirm({
type: 'question',
@@ -252,11 +147,11 @@ async function abortWithConfirm() {
});
if (canceled) return;
- abortAll();
+ uploader.abortAll();
}
async function done() {
- if (items.value.some(item => item.uploaded == null)) {
+ if (!uploader.allItemsUploaded.value) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts._uploader.doneConfirm,
@@ -270,396 +165,20 @@ async function done() {
dialog.value?.close();
}
-function showMenu(ev: MouseEvent, item: UploaderItem) {
- const menu: MenuItem[] = [];
-
- menu.push({
- icon: 'ti ti-cursor-text',
- text: i18n.ts.rename,
- action: async () => {
- const { result, canceled } = await os.inputText({
- type: 'text',
- title: i18n.ts.rename,
- placeholder: item.name,
- default: item.name,
- });
- if (canceled) return;
- if (result.trim() === '') return;
-
- item.name = result;
- },
- });
-
- if (
- uploaderFeatures.value.crop &&
- CROPPING_SUPPORTED_TYPES.includes(item.file.type) &&
- !item.preprocessing &&
- !item.uploading &&
- !item.uploaded
- ) {
- menu.push({
- icon: 'ti ti-crop',
- text: i18n.ts.cropImage,
- action: async () => {
- const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
- URL.revokeObjectURL(item.thumbnail);
- const newItem = {
- ...item,
- file: markRaw(cropped),
- thumbnail: window.URL.createObjectURL(cropped),
- };
- items.value.splice(items.value.indexOf(item), 1, newItem);
- preprocess(newItem).then(() => {
- triggerRef(items);
- });
- },
- });
- }
-
- if (
- uploaderFeatures.value.effect &&
- IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
- !item.preprocessing &&
- !item.uploading &&
- !item.uploaded
- ) {
- menu.push({
- icon: 'ti ti-sparkles',
- text: i18n.ts._imageEffector.title + ' (BETA)',
- action: async () => {
- const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
- image: item.file,
- }, {
- ok: (file) => {
- URL.revokeObjectURL(item.thumbnail);
- const newItem = {
- ...item,
- file: markRaw(file),
- thumbnail: window.URL.createObjectURL(file),
- };
- items.value.splice(items.value.indexOf(item), 1, newItem);
- preprocess(newItem).then(() => {
- triggerRef(items);
- });
- },
- closed: () => dispose(),
- });
- },
- });
- }
-
- if (
- uploaderFeatures.value.watermark &&
- WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
- !item.preprocessing &&
- !item.uploading &&
- !item.uploaded
- ) {
- function changeWatermarkPreset(presetId: string | null) {
- item.watermarkPresetId = presetId;
- preprocess(item).then(() => {
- triggerRef(items);
- });
- }
-
- menu.push({
- icon: 'ti ti-copyright',
- text: i18n.ts.watermark,
- caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name),
- type: 'parent',
- children: [{
- type: 'radioOption',
- text: i18n.ts.none,
- active: computed(() => item.watermarkPresetId == null),
- action: () => changeWatermarkPreset(null),
- }, {
- type: 'divider',
- }, ...prefer.s.watermarkPresets.map(preset => ({
- type: 'radioOption' as const,
- text: preset.name,
- active: computed(() => item.watermarkPresetId === preset.id),
- action: () => changeWatermarkPreset(preset.id),
- })), ...(prefer.s.watermarkPresets.length > 0 ? [{
- type: 'divider' as const,
- }] : []), {
- type: 'button',
- icon: 'ti ti-plus',
- text: i18n.ts.add,
- action: async () => {
- const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
- image: item.file,
- }, {
- ok: (preset) => {
- prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
- changeWatermarkPreset(preset.id);
- },
- closed: () => dispose(),
- });
- },
- }],
- });
- }
-
- if (COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !item.preprocessing && !item.uploading && !item.uploaded) {
- function changeCompressionLevel(level: 0 | 1 | 2 | 3) {
- item.compressionLevel = level;
- preprocess(item).then(() => {
- triggerRef(items);
- });
- }
-
- menu.push({
- icon: 'ti ti-leaf',
- text: computed(() => {
- let text = i18n.ts.compress;
-
- if (item.compressionLevel === 0 || item.compressionLevel == null) {
- text += `: ${i18n.ts.none}`;
- } else if (item.compressionLevel === 1) {
- text += `: ${i18n.ts.low}`;
- } else if (item.compressionLevel === 2) {
- text += `: ${i18n.ts.medium}`;
- } else if (item.compressionLevel === 3) {
- text += `: ${i18n.ts.high}`;
- }
-
- return text;
- }),
- type: 'parent',
- children: [{
- type: 'radioOption',
- text: i18n.ts.none,
- active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null),
- action: () => changeCompressionLevel(0),
- }, {
- type: 'divider',
- }, {
- type: 'radioOption',
- text: i18n.ts.low,
- active: computed(() => item.compressionLevel === 1),
- action: () => changeCompressionLevel(1),
- }, {
- type: 'radioOption',
- text: i18n.ts.medium,
- active: computed(() => item.compressionLevel === 2),
- action: () => changeCompressionLevel(2),
- }, {
- type: 'radioOption',
- text: i18n.ts.high,
- active: computed(() => item.compressionLevel === 3),
- action: () => changeCompressionLevel(3),
- }],
- });
- }
-
- if (!item.preprocessing && !item.uploading && !item.uploaded) {
- menu.push({
- type: 'divider',
- }, {
- icon: 'ti ti-x',
- text: i18n.ts.remove,
- action: () => {
- URL.revokeObjectURL(item.thumbnail);
- items.value.splice(items.value.indexOf(item), 1);
- },
- });
- } else if (item.uploading) {
- menu.push({
- type: 'divider',
- }, {
- icon: 'ti ti-cloud-pause',
- text: i18n.ts.abort,
- danger: true,
- action: () => {
- if (item.abort != null) {
- item.abort();
- }
- },
- });
- }
-
- os.popupMenu(menu, ev.currentTarget ?? ev.target);
-}
-
-async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
- firstUploadAttempted.value = true;
-
- items.value = items.value.map(item => ({
- ...item,
- aborted: false,
- uploadFailed: false,
- uploading: false,
- }));
-
- for (const item of items.value.filter(item => item.uploaded == null)) {
- // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック
- if (item.aborted) {
- continue;
- }
-
- item.uploadFailed = false;
- item.uploading = true;
-
- const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
- name: item.uploadName ?? item.name,
- folderId: props.folderId,
- onProgress: (progress) => {
- if (item.progress == null) {
- item.progress = { max: progress.total, value: progress.loaded };
- } else {
- item.progress.value = progress.loaded;
- item.progress.max = progress.total;
- }
- },
- });
-
- item.abort = () => {
- item.abort = null;
- abort();
- item.uploading = false;
- item.uploadFailed = true;
- };
-
- await filePromise.then((file) => {
- item.uploaded = file;
- item.abort = null;
- }).catch(err => {
- item.uploadFailed = true;
- item.progress = null;
- if (!(err instanceof UploadAbortedError)) {
- throw err;
- }
- }).finally(() => {
- item.uploading = false;
- });
- }
-}
-
-function abortAll() {
- for (const item of items.value) {
- if (item.uploaded != null) {
- continue;
- }
-
- if (item.abort != null) {
- item.abort();
- }
- item.aborted = true;
- item.uploadFailed = true;
- }
-}
-
async function chooseFile(ev: MouseEvent) {
const newFiles = await os.chooseFileFromPc({ multiple: true });
-
- for (const file of newFiles) {
- initializeFile(file);
- }
+ uploader.addFiles(newFiles);
}
-async function preprocess(item: (typeof items)['value'][number]): Promise<void> {
- item.preprocessing = true;
-
- let file: Blob | File = item.file;
- const imageBitmap = await window.createImageBitmap(file);
-
- const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type);
- const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
- if (needsWatermark && preset != null) {
- const canvas = window.document.createElement('canvas');
- const renderer = new WatermarkRenderer({
- canvas: canvas,
- renderWidth: imageBitmap.width,
- renderHeight: imageBitmap.height,
- image: imageBitmap,
- });
-
- await renderer.setLayers(preset.layers);
-
- renderer.render();
-
- file = await new Promise<Blob>((resolve) => {
- canvas.toBlob((blob) => {
- if (blob == null) {
- throw new Error('Failed to convert canvas to blob');
- }
- resolve(blob);
- renderer.destroy();
- }, 'image/png');
- });
- }
-
- const compressionSettings = getCompressionSettings(item.compressionLevel);
- const needsCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(file.type) && !(await isAnimated(file));
-
- if (needsCompress) {
- const config = {
- mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
- maxWidth: compressionSettings.maxWidth,
- maxHeight: compressionSettings.maxHeight,
- quality: isWebpSupported() ? 0.85 : 0.8,
- };
-
- try {
- const result = await readAndCompressImage(file, config);
- if (result.size < file.size || file.type === 'image/webp') {
- // The compression may not always reduce the file size
- // (and WebP is not browser safe yet)
- file = result;
- item.compressedSize = result.size;
- item.uploadName = file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
- }
- } catch (err) {
- console.error('Failed to resize image', err);
- }
- } else {
- item.compressedSize = null;
- item.uploadName = item.name;
- }
-
- URL.revokeObjectURL(item.thumbnail);
- item.thumbnail = window.URL.createObjectURL(file);
- item.preprocessedFile = markRaw(file);
- item.preprocessing = false;
-
- imageBitmap.close();
+function showPerItemMenu(item: UploaderItem, ev: MouseEvent) {
+ const menu = uploader.getMenu(item);
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
-function initializeFile(file: File) {
- const id = genId();
- const filename = file.name ?? 'untitled';
- const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
- const item = {
- id,
- name: prefer.s.keepOriginalFilename ? filename : id + extension,
- progress: null,
- thumbnail: window.URL.createObjectURL(file),
- preprocessing: false,
- uploading: false,
- aborted: false,
- uploaded: null,
- uploadFailed: false,
- compressionLevel: prefer.s.defaultImageCompressionLevel,
- watermarkPresetId: uploaderFeatures.value.watermark ? prefer.s.defaultWatermarkPresetId : null,
- file: markRaw(file),
- } satisfies UploaderItem;
- items.value.push(item);
- preprocess(item).then(() => {
- triggerRef(items);
- });
+function showPerItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent) {
+ const menu = uploader.getMenu(item);
+ os.contextMenu(menu, ev);
}
-
-onMounted(() => {
- for (const file of props.files) {
- initializeFile(file);
- }
-});
-
-onUnmounted(() => {
- for (const item of items.value) {
- URL.revokeObjectURL(item.thumbnail);
- }
-});
</script>
<style lang="scss" module>
@@ -681,127 +200,4 @@ onUnmounted(() => {
background: var(--MI_THEME-warn);
}
}
-
-.item {
- position: relative;
- border-radius: 10px;
- overflow: clip;
-
- &::before {
- content: '';
- display: block;
- position: absolute;
- top: 0;
- left: 0;
- width: var(--p);
- height: 100%;
- background: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
- transition: width 0.2s ease, left 0.2s ease;
- }
-
- &.itemWaiting {
- &::after {
- --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
-
- content: '';
- display: block;
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c));
- background-size: 25px 25px;
- animation: stripe .8s infinite linear;
- }
- }
-
- &.itemCompleted {
- &::before {
- left: 100%;
- width: var(--p);
- }
-
- .itemBody {
- color: var(--MI_THEME-accent);
- }
- }
-
- &.itemFailed {
- .itemBody {
- color: var(--MI_THEME-error);
- }
- }
-}
-
-@keyframes stripe {
- 0% { background-position-x: 0; }
- 100% { background-position-x: -25px; }
-}
-
-.itemInner {
- position: relative;
- z-index: 1;
- padding: 8px 16px;
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.itemThumbnail {
- width: 70px;
- height: 70px;
- background-color: var(--MI_THEME-bg);
- background-size: contain;
- background-position: center;
- background-repeat: no-repeat;
- border-radius: 6px;
-}
-
-.itemBody {
- flex: 1;
- min-width: 0;
-}
-
-.itemInfo {
- opacity: 0.7;
- margin-top: 4px;
- font-size: 90%;
- display: flex;
- gap: 8px;
-}
-
-.itemIcon {
- width: 35px;
-}
-
-@container (max-width: 500px) {
- .itemInner {
- flex-direction: column;
- gap: 8px;
- }
-
- .itemBody {
- font-size: 90%;
- text-align: center;
- width: 100%;
- min-width: 0;
- }
-
- .itemActionWrapper {
- position: absolute;
- top: 8px;
- left: 8px;
- }
-
- .itemInfo {
- justify-content: center;
- }
-
- .itemIconWrapper {
- position: absolute;
- top: 8px;
- right: 8px;
- }
-}
</style>
diff --git a/packages/frontend/src/components/MkUploaderItems.vue b/packages/frontend/src/components/MkUploaderItems.vue
new file mode 100644
index 0000000000..2d624cf344
--- /dev/null
+++ b/packages/frontend/src/components/MkUploaderItems.vue
@@ -0,0 +1,196 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root" class="_gaps_s">
+ <div
+ v-for="item in props.items"
+ :key="item.id"
+ v-panel
+ :class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]"
+ :style="{ '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%' }"
+ @contextmenu.prevent.stop="onContextmenu(item, $event)"
+ >
+ <div :class="$style.itemInner">
+ <div :class="$style.itemActionWrapper">
+ <MkButton :iconOnly="true" rounded @click="emit('showMenu', item, $event)"><i class="ti ti-dots"></i></MkButton>
+ </div>
+ <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div>
+ <div :class="$style.itemBody">
+ <div><MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine></div>
+ <div :class="$style.itemInfo">
+ <span>{{ item.file.type }}</span>
+ <span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span>
+ <span v-else>{{ bytes(item.file.size) }}</span>
+ </div>
+ <div>
+ </div>
+ </div>
+ <div :class="$style.itemIconWrapper">
+ <MkSystemIcon v-if="item.uploading" :class="$style.itemIcon" type="waiting"/>
+ <MkSystemIcon v-else-if="item.uploaded" :class="$style.itemIcon" type="success"/>
+ <MkSystemIcon v-else-if="item.uploadFailed" :class="$style.itemIcon" type="error"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { isLink } from '@@/js/is-link.js';
+import type { UploaderItem } from '@/composables/use-uploader.js';
+import { i18n } from '@/i18n.js';
+import MkButton from '@/components/MkButton.vue';
+import bytes from '@/filters/bytes.js';
+
+const props = defineProps<{
+ items: UploaderItem[];
+}>();
+
+const emit = defineEmits<{
+ (ev: 'showMenu', item: UploaderItem, event: MouseEvent): void;
+ (ev: 'showMenuViaContextmenu', item: UploaderItem, event: MouseEvent): void;
+}>();
+
+function onContextmenu(item: UploaderItem, ev: MouseEvent) {
+ if (ev.target && isLink(ev.target as HTMLElement)) return;
+ if (window.getSelection()?.toString() !== '') return;
+
+ emit('showMenuViaContextmenu', item, ev);
+}
+
+function onThumbnailClick(item: UploaderItem, ev: MouseEvent) {
+ // TODO: preview when item is image
+}
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+}
+
+.item {
+ position: relative;
+ border-radius: 10px;
+ overflow: clip;
+
+ &::before {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: var(--p);
+ height: 100%;
+ background: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
+ transition: width 0.2s ease, left 0.2s ease;
+ }
+
+ &.itemWaiting {
+ &::after {
+ --c: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
+
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c));
+ background-size: 25px 25px;
+ animation: stripe .8s infinite linear;
+ }
+ }
+
+ &.itemCompleted {
+ &::before {
+ left: 100%;
+ width: var(--p);
+ }
+
+ .itemBody {
+ color: var(--MI_THEME-accent);
+ }
+ }
+
+ &.itemFailed {
+ .itemBody {
+ color: var(--MI_THEME-error);
+ }
+ }
+}
+
+@keyframes stripe {
+ 0% { background-position-x: 0; }
+ 100% { background-position-x: -25px; }
+}
+
+.itemInner {
+ position: relative;
+ z-index: 1;
+ padding: 8px 16px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.itemThumbnail {
+ width: 70px;
+ height: 70px;
+ background-color: var(--MI_THEME-bg);
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ border-radius: 6px;
+}
+
+.itemBody {
+ flex: 1;
+ min-width: 0;
+}
+
+.itemInfo {
+ opacity: 0.7;
+ margin-top: 4px;
+ font-size: 90%;
+ display: flex;
+ gap: 8px;
+}
+
+.itemIcon {
+ width: 35px;
+}
+
+@container (max-width: 500px) {
+ .itemInner {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .itemBody {
+ font-size: 90%;
+ text-align: center;
+ width: 100%;
+ min-width: 0;
+ }
+
+ .itemActionWrapper {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ }
+
+ .itemInfo {
+ justify-content: center;
+ }
+
+ .itemIconWrapper {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts
new file mode 100644
index 0000000000..3f105dc201
--- /dev/null
+++ b/packages/frontend/src/composables/use-uploader.ts
@@ -0,0 +1,535 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
+import isAnimated from 'is-file-animated';
+import { EventEmitter } from 'eventemitter3';
+import { computed, markRaw, onMounted, onUnmounted, ref, triggerRef } from 'vue';
+import type { MenuItem } from '@/types/menu.js';
+import { genId } from '@/utility/id.js';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.js';
+import { isWebpSupported } from '@/utility/isWebpSupported.js';
+import { uploadFile, UploadAbortedError } from '@/utility/drive.js';
+import * as os from '@/os.js';
+import { ensureSignin } from '@/i.js';
+import { WatermarkRenderer } from '@/utility/watermark.js';
+
+export type UploaderFeatures = {
+ effect?: boolean;
+ watermark?: boolean;
+ crop?: boolean;
+};
+
+const COMPRESSION_SUPPORTED_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+ 'image/svg+xml',
+];
+
+const CROPPING_SUPPORTED_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+];
+
+const IMAGE_EDITING_SUPPORTED_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+];
+
+const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
+
+const mimeTypeMap = {
+ 'image/webp': 'webp',
+ 'image/jpeg': 'jpg',
+ 'image/png': 'png',
+} as const;
+
+export type UploaderItem = {
+ id: string;
+ name: string;
+ uploadName?: string;
+ progress: { max: number; value: number } | null;
+ thumbnail: string;
+ preprocessing: boolean;
+ uploading: boolean;
+ uploaded: Misskey.entities.DriveFile | null;
+ uploadFailed: boolean;
+ aborted: boolean;
+ compressionLevel: 0 | 1 | 2 | 3;
+ compressedSize?: number | null;
+ preprocessedFile?: Blob | null;
+ file: File;
+ watermarkPresetId: string | null;
+ abort?: (() => void) | null;
+};
+
+function getCompressionSettings(level: 0 | 1 | 2 | 3) {
+ if (level === 1) {
+ return {
+ maxWidth: 2000,
+ maxHeight: 2000,
+ };
+ } else if (level === 2) {
+ return {
+ maxWidth: 2000 * 0.75, // =1500
+ maxHeight: 2000 * 0.75, // =1500
+ };
+ } else if (level === 3) {
+ return {
+ maxWidth: 2000 * 0.75 * 0.75, // =1125
+ maxHeight: 2000 * 0.75 * 0.75, // =1125
+ };
+ } else {
+ return null;
+ }
+}
+
+export function useUploader(options: {
+ folderId?: string | null;
+ multiple?: boolean;
+ features?: UploaderFeatures;
+} = {}) {
+ const $i = ensureSignin();
+
+ const events = new EventEmitter<{
+ 'itemUploaded': (ctx: { item: UploaderItem; }) => void;
+ }>();
+
+ const uploaderFeatures = computed<Required<UploaderFeatures>>(() => {
+ return {
+ effect: options.features?.effect ?? true,
+ watermark: options.features?.watermark ?? true,
+ crop: options.features?.crop ?? true,
+ };
+ });
+
+ const items = ref<UploaderItem[]>([]);
+
+ function initializeFile(file: File) {
+ const id = genId();
+ const filename = file.name ?? 'untitled';
+ const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
+ items.value.push({
+ id,
+ name: prefer.s.keepOriginalFilename ? filename : id + extension,
+ progress: null,
+ thumbnail: window.URL.createObjectURL(file),
+ preprocessing: false,
+ uploading: false,
+ aborted: false,
+ uploaded: null,
+ uploadFailed: false,
+ compressionLevel: prefer.s.defaultImageCompressionLevel,
+ watermarkPresetId: uploaderFeatures.value.watermark ? prefer.s.defaultWatermarkPresetId : null,
+ file: markRaw(file),
+ });
+ const reactiveItem = items.value.at(-1)!;
+ preprocess(reactiveItem).then(() => {
+ triggerRef(items);
+ });
+ }
+
+ function addFiles(newFiles: File[]) {
+ for (const file of newFiles) {
+ initializeFile(file);
+ }
+ }
+
+ function removeItem(item: UploaderItem) {
+ URL.revokeObjectURL(item.thumbnail);
+ items.value.splice(items.value.indexOf(item), 1);
+ }
+
+ function getMenu(item: UploaderItem): MenuItem[] {
+ const menu: MenuItem[] = [];
+
+ if (
+ !item.preprocessing &&
+ !item.uploading &&
+ !item.uploaded
+ ) {
+ menu.push({
+ icon: 'ti ti-cursor-text',
+ text: i18n.ts.rename,
+ action: async () => {
+ const { result, canceled } = await os.inputText({
+ type: 'text',
+ title: i18n.ts.rename,
+ placeholder: item.name,
+ default: item.name,
+ });
+ if (canceled) return;
+ if (result.trim() === '') return;
+
+ item.name = result;
+ },
+ });
+ }
+
+ if (
+ uploaderFeatures.value.crop &&
+ CROPPING_SUPPORTED_TYPES.includes(item.file.type) &&
+ !item.preprocessing &&
+ !item.uploading &&
+ !item.uploaded
+ ) {
+ menu.push({
+ icon: 'ti ti-crop',
+ text: i18n.ts.cropImage,
+ action: async () => {
+ const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
+ URL.revokeObjectURL(item.thumbnail);
+ items.value.splice(items.value.indexOf(item), 1, {
+ ...item,
+ file: markRaw(cropped),
+ thumbnail: window.URL.createObjectURL(cropped),
+ });
+ const reactiveItem = items.value.find(x => x.id === item.id)!;
+ preprocess(reactiveItem).then(() => {
+ triggerRef(items);
+ });
+ },
+ });
+ }
+
+ if (
+ uploaderFeatures.value.effect &&
+ IMAGE_EDITING_SUPPORTED_TYPES.includes(item.file.type) &&
+ !item.preprocessing &&
+ !item.uploading &&
+ !item.uploaded
+ ) {
+ menu.push({
+ icon: 'ti ti-sparkles',
+ text: i18n.ts._imageEffector.title + ' (BETA)',
+ action: async () => {
+ const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageEffectorDialog.vue').then(x => x.default), {
+ image: item.file,
+ }, {
+ ok: (file) => {
+ URL.revokeObjectURL(item.thumbnail);
+ items.value.splice(items.value.indexOf(item), 1, {
+ ...item,
+ file: markRaw(file),
+ thumbnail: window.URL.createObjectURL(file),
+ });
+ const reactiveItem = items.value.find(x => x.id === item.id)!;
+ preprocess(reactiveItem).then(() => {
+ triggerRef(items);
+ });
+ },
+ closed: () => dispose(),
+ });
+ },
+ });
+ }
+
+ if (
+ uploaderFeatures.value.watermark &&
+ WATERMARK_SUPPORTED_TYPES.includes(item.file.type) &&
+ !item.preprocessing &&
+ !item.uploading &&
+ !item.uploaded
+ ) {
+ function changeWatermarkPreset(presetId: string | null) {
+ item.watermarkPresetId = presetId;
+ preprocess(item).then(() => {
+ triggerRef(items);
+ });
+ }
+
+ menu.push({
+ icon: 'ti ti-copyright',
+ text: i18n.ts.watermark,
+ caption: computed(() => item.watermarkPresetId == null ? null : prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId)?.name),
+ type: 'parent',
+ children: [{
+ type: 'radioOption',
+ text: i18n.ts.none,
+ active: computed(() => item.watermarkPresetId == null),
+ action: () => changeWatermarkPreset(null),
+ }, {
+ type: 'divider',
+ }, ...prefer.s.watermarkPresets.map(preset => ({
+ type: 'radioOption' as const,
+ text: preset.name,
+ active: computed(() => item.watermarkPresetId === preset.id),
+ action: () => changeWatermarkPreset(preset.id),
+ })), ...(prefer.s.watermarkPresets.length > 0 ? [{
+ type: 'divider' as const,
+ }] : []), {
+ type: 'button',
+ icon: 'ti ti-plus',
+ text: i18n.ts.add,
+ action: async () => {
+ const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkWatermarkEditorDialog.vue').then(x => x.default), {
+ image: item.file,
+ }, {
+ ok: (preset) => {
+ prefer.commit('watermarkPresets', [...prefer.s.watermarkPresets, preset]);
+ changeWatermarkPreset(preset.id);
+ },
+ closed: () => dispose(),
+ });
+ },
+ }],
+ });
+ }
+
+ if (
+ COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) &&
+ !item.preprocessing &&
+ !item.uploading &&
+ !item.uploaded
+ ) {
+ function changeCompressionLevel(level: 0 | 1 | 2 | 3) {
+ item.compressionLevel = level;
+ preprocess(item).then(() => {
+ triggerRef(items);
+ });
+ }
+
+ menu.push({
+ icon: 'ti ti-leaf',
+ text: computed(() => {
+ let text = i18n.ts.compress;
+
+ if (item.compressionLevel === 0 || item.compressionLevel == null) {
+ text += `: ${i18n.ts.none}`;
+ } else if (item.compressionLevel === 1) {
+ text += `: ${i18n.ts.low}`;
+ } else if (item.compressionLevel === 2) {
+ text += `: ${i18n.ts.medium}`;
+ } else if (item.compressionLevel === 3) {
+ text += `: ${i18n.ts.high}`;
+ }
+
+ return text;
+ }),
+ type: 'parent',
+ children: [{
+ type: 'radioOption',
+ text: i18n.ts.none,
+ active: computed(() => item.compressionLevel === 0 || item.compressionLevel == null),
+ action: () => changeCompressionLevel(0),
+ }, {
+ type: 'divider',
+ }, {
+ type: 'radioOption',
+ text: i18n.ts.low,
+ active: computed(() => item.compressionLevel === 1),
+ action: () => changeCompressionLevel(1),
+ }, {
+ type: 'radioOption',
+ text: i18n.ts.medium,
+ active: computed(() => item.compressionLevel === 2),
+ action: () => changeCompressionLevel(2),
+ }, {
+ type: 'radioOption',
+ text: i18n.ts.high,
+ active: computed(() => item.compressionLevel === 3),
+ action: () => changeCompressionLevel(3),
+ }],
+ });
+ }
+
+ if (!item.preprocessing && !item.uploading && !item.uploaded) {
+ menu.push({
+ type: 'divider',
+ }, {
+ icon: 'ti ti-upload',
+ text: i18n.ts.upload,
+ action: () => {
+ uploadOne(item);
+ },
+ }, {
+ icon: 'ti ti-x',
+ text: i18n.ts.remove,
+ action: () => {
+ removeItem(item);
+ },
+ });
+ } else if (item.uploading) {
+ menu.push({
+ type: 'divider',
+ }, {
+ icon: 'ti ti-cloud-pause',
+ text: i18n.ts.abort,
+ danger: true,
+ action: () => {
+ if (item.abort != null) {
+ item.abort();
+ }
+ },
+ });
+ }
+
+ return menu;
+ }
+
+ async function uploadOne(item: UploaderItem): Promise<void> {
+ item.uploadFailed = false;
+ item.uploading = true;
+
+ const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, {
+ name: item.uploadName ?? item.name,
+ folderId: options.folderId,
+ onProgress: (progress) => {
+ if (item.progress == null) {
+ item.progress = { max: progress.total, value: progress.loaded };
+ } else {
+ item.progress.value = progress.loaded;
+ item.progress.max = progress.total;
+ }
+ },
+ });
+
+ item.abort = () => {
+ item.abort = null;
+ abort();
+ item.uploading = false;
+ item.uploadFailed = true;
+ };
+
+ await filePromise.then((file) => {
+ item.uploaded = file;
+ item.abort = null;
+ events.emit('itemUploaded', { item });
+ }).catch(err => {
+ item.uploadFailed = true;
+ item.progress = null;
+ if (!(err instanceof UploadAbortedError)) {
+ throw err;
+ }
+ }).finally(() => {
+ item.uploading = false;
+ });
+ }
+
+ async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
+ items.value = items.value.map(item => ({
+ ...item,
+ aborted: false,
+ uploadFailed: false,
+ uploading: false,
+ }));
+
+ for (const item of items.value.filter(item => item.uploaded == null)) {
+ // アップロード処理途中で値が変わる場合(途中で全キャンセルされたりなど)もあるので、Array filterではなくここでチェック
+ if (item.aborted) {
+ continue;
+ }
+
+ await uploadOne(item);
+ }
+ }
+
+ function abortAll() {
+ for (const item of items.value) {
+ if (item.uploaded != null) {
+ continue;
+ }
+
+ if (item.abort != null) {
+ item.abort();
+ }
+ item.aborted = true;
+ item.uploadFailed = true;
+ }
+ }
+
+ async function preprocess(item: UploaderItem): Promise<void> {
+ item.preprocessing = true;
+
+ let file: Blob | File = item.file;
+ const imageBitmap = await window.createImageBitmap(file);
+
+ const needsWatermark = item.watermarkPresetId != null && WATERMARK_SUPPORTED_TYPES.includes(file.type);
+ const preset = prefer.s.watermarkPresets.find(p => p.id === item.watermarkPresetId);
+ if (needsWatermark && preset != null) {
+ const canvas = window.document.createElement('canvas');
+ const renderer = new WatermarkRenderer({
+ canvas: canvas,
+ renderWidth: imageBitmap.width,
+ renderHeight: imageBitmap.height,
+ image: imageBitmap,
+ });
+
+ await renderer.setLayers(preset.layers);
+
+ renderer.render();
+
+ file = await new Promise<Blob>((resolve) => {
+ canvas.toBlob((blob) => {
+ if (blob == null) {
+ throw new Error('Failed to convert canvas to blob');
+ }
+ resolve(blob);
+ renderer.destroy();
+ }, 'image/png');
+ });
+ }
+
+ const compressionSettings = getCompressionSettings(item.compressionLevel);
+ const needsCompress = item.compressionLevel !== 0 && compressionSettings && COMPRESSION_SUPPORTED_TYPES.includes(file.type) && !(await isAnimated(file));
+
+ if (needsCompress) {
+ const config = {
+ mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
+ maxWidth: compressionSettings.maxWidth,
+ maxHeight: compressionSettings.maxHeight,
+ quality: isWebpSupported() ? 0.85 : 0.8,
+ };
+
+ try {
+ const result = await readAndCompressImage(file, config);
+ if (result.size < file.size || file.type === 'image/webp') {
+ // The compression may not always reduce the file size
+ // (and WebP is not browser safe yet)
+ file = result;
+ item.compressedSize = result.size;
+ item.uploadName = file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
+ }
+ } catch (err) {
+ console.error('Failed to resize image', err);
+ }
+ } else {
+ item.compressedSize = null;
+ item.uploadName = item.name;
+ }
+
+ URL.revokeObjectURL(item.thumbnail);
+ item.thumbnail = window.URL.createObjectURL(file);
+ item.preprocessedFile = markRaw(file);
+ item.preprocessing = false;
+
+ imageBitmap.close();
+ }
+
+ onUnmounted(() => {
+ for (const item of items.value) {
+ URL.revokeObjectURL(item.thumbnail);
+ }
+ });
+
+ return {
+ items,
+ addFiles,
+ removeItem,
+ abortAll,
+ upload,
+ getMenu,
+ uploading: computed(() => items.value.some(item => item.uploading)),
+ readyForUpload: computed(() => items.value.length > 0 && items.value.some(item => item.uploaded == null) && !items.value.some(item => item.uploading || item.preprocessing)),
+ allItemsUploaded: computed(() => items.value.every(item => item.uploaded != null)),
+ events,
+ };
+}
+
diff --git a/packages/frontend/src/tips.ts b/packages/frontend/src/tips.ts
index a6850d0406..7218f4c19a 100644
--- a/packages/frontend/src/tips.ts
+++ b/packages/frontend/src/tips.ts
@@ -8,6 +8,7 @@ import { store } from '@/store.js';
export const TIPS = [
'drive',
'uploader',
+ 'postFormUploader',
'clips',
'userLists',
'tl.home',