From b43dfa260b1416da153d28bcd46a8bcbce02c18d Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Wed, 4 Jun 2025 16:22:09 +0900
Subject: fix/refactor(frontend): 画像編集機能の修正・型強化 (#16156)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* enhance: refine uploadFile

* fix: missing locale

* refactor: harden types

* refactor: シェーダーファイルをlazy-loadingできるように

* fix(frontend): omit console.log in production environment

* fix: glslのバージョン表記は最初の行になければならない

* fix: シェーダーの読み込みが完了してからレンダリングを行うように

* fix merge failure

* fix: ウォーターマークのプリセットがない場合にdividerが2重に表示される問題を修正

* fix: アップローダーダイアログの機能設定でウォーターマークが無効な場合でもデフォルトのプリセットが適用されてしまう問題を修正

* fix lint

* Revert "fix: シェーダーの読み込みが完了してからレンダリングを行うように"

This reverts commit e06f37a7d453ca581858252eae422d8a9e470dc3.

* Revert "fix: glslのバージョン表記は最初の行になければならない"

This reverts commit afcc37d886106c4acd545e4c2922e67f94e1037b.

* Revert "refactor: シェーダーファイルをlazy-loadingできるように"

This reverts commit a1ab2fa38c2b7485c069f9cd089bc7de59416c9d.

* fix: ウォーターマークのFX定義を分ける

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

* Update packages/frontend/src/components/MkWatermarkEditorDialog.vue

---------

Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
---
 packages/frontend/src/components/MkPostForm.vue | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

(limited to 'packages/frontend/src/components/MkPostForm.vue')

diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 982ed88003..cd4fabea02 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -120,7 +120,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 { selectFiles } from '@/utility/drive.js';
+import { selectFile } from '@/utility/drive.js';
 import { store } from '@/store.js';
 import MkInfo from '@/components/MkInfo.vue';
 import { i18n } from '@/i18n.js';
@@ -437,7 +437,11 @@ function focus() {
 function chooseFileFrom(ev) {
 	if (props.mock) return;
 
-	selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
+	selectFile({
+		anchorElement: ev.currentTarget ?? ev.target,
+		multiple: true,
+		label: i18n.ts.attachFile,
+	}).then(files_ => {
 		for (const file of files_) {
 			files.value.push(file);
 		}
-- 
cgit v1.2.3-freya


From 9bd5f887de6515f93c7db48d7d1370898b2d7b78 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 7 Jun 2025 07:47:43 +0900
Subject: enhance(frontend): 投稿フォームにアップローダーを埋め込み (#16173)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* wip

* Update MkPostForm.vue

* wip

* wip

* Update MkPostForm.vue

* wip

* wip

* add tip

* Update tips.ts

* Update MkPostForm.vue
---
 locales/index.d.ts                                 |   8 +
 locales/ja-JP.yml                                  |   2 +
 packages/frontend/src/components/MkPostForm.vue    |  97 ++-
 .../frontend/src/components/MkPostFormDialog.vue   |  15 +-
 .../frontend/src/components/MkUploaderDialog.vue   | 676 ++-------------------
 .../frontend/src/components/MkUploaderItems.vue    | 196 ++++++
 packages/frontend/src/composables/use-uploader.ts  | 535 ++++++++++++++++
 packages/frontend/src/tips.ts                      |   1 +
 8 files changed, 869 insertions(+), 661 deletions(-)
 create mode 100644 packages/frontend/src/components/MkUploaderItems.vue
 create mode 100644 packages/frontend/src/composables/use-uploader.ts

(limited to 'packages/frontend/src/components/MkPostForm.vue')

diff --git a/locales/index.d.ts b/locales/index.d.ts
index a4671aa812..1462e933d0 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9584,6 +9584,14 @@ export interface Locale extends ILocale {
         "disableFederationDescription": string;
     };
     "_postForm": {
+        /**
+         * アップロードされていないファイルがありますが、破棄してフォームを閉じますか？
+         */
+        "quitInspiteOfThereAreUnuploadedFilesConfirm": string;
+        /**
+         * ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。
+         */
+        "uploaderTip": string;
         /**
          * このノートに返信...
          */
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 4988bfc259..171eb62b0f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2522,6 +2522,8 @@ _visibility:
   disableFederationDescription: "他サーバーへの配信を行いません"
 
 _postForm:
+  quitInspiteOfThereAreUnuploadedFilesConfirm: "アップロードされていないファイルがありますが、破棄してフォームを閉じますか？"
+  uploaderTip: "ファイルはまだアップロードされていません。ファイルのメニューから、リネームや画像のクロップ、ウォーターマークの付与、圧縮の有無などを設定できます。ファイルはノート投稿時に自動でアップロードされます。"
   replyPlaceholder: "このノートに返信..."
   quotePlaceholder: "このノートを引用..."
   channelPlaceholder: "チャンネルに投稿..."
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 items = ref<UploaderItem[]>([]);
-
 const dialog = useTemplateRef('dialog');
 
+const uploader = useUploader({
+	multiple: props.multiple,
+	folderId: props.folderId,
+	features: props.features,
+});
+
+onMounted(() => {
+	uploader.addFiles(props.files);
+});
+
+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',
-- 
cgit v1.2.3-freya


From 4906f1f45c4724526d113af46fb9b35ba1376b15 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 7 Jun 2025 08:07:23 +0900
Subject: 🎨
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 packages/frontend/src/components/MkPostForm.vue   | 1 +
 packages/frontend/src/composables/use-uploader.ts | 1 +
 packages/frontend/src/pages/timeline.vue          | 4 ++++
 3 files changed, 6 insertions(+)

(limited to 'packages/frontend/src/components/MkPostForm.vue')

diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 46893a0752..e319c9bacb 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -593,6 +593,7 @@ function showOtherSettings() {
 		},
 	}, { type: 'divider' }, {
 		type: 'switch',
+		icon: 'ti ti-eye',
 		text: i18n.ts.preview,
 		ref: showPreview,
 	}, {
diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts
index 0dbc3052df..ab6db4568e 100644
--- a/packages/frontend/src/composables/use-uploader.ts
+++ b/packages/frontend/src/composables/use-uploader.ts
@@ -174,6 +174,7 @@ export function useUploader(options: {
 			}, {
 				type: 'switch',
 				text: i18n.ts.sensitive,
+				icon: 'ti ti-eye-exclamation',
 				ref: computed({
 					get: () => item.isSensitive ?? false,
 					set: (value) => item.isSensitive = value,
diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue
index 5696d1dd89..b783f7ee0b 100644
--- a/packages/frontend/src/pages/timeline.vue
+++ b/packages/frontend/src/pages/timeline.vue
@@ -226,6 +226,7 @@ const headerActions = computed(() => {
 
 				menuItems.push({
 					type: 'switch',
+					icon: 'ti ti-repeat',
 					text: i18n.ts.showRenotes,
 					ref: withRenotes,
 				});
@@ -233,6 +234,7 @@ const headerActions = computed(() => {
 				if (isBasicTimeline(src.value) && hasWithReplies(src.value)) {
 					menuItems.push({
 						type: 'switch',
+						icon: 'ti ti-messages',
 						text: i18n.ts.showRepliesToOthersInTimeline,
 						ref: withReplies,
 						disabled: onlyFiles,
@@ -241,10 +243,12 @@ const headerActions = computed(() => {
 
 				menuItems.push({
 					type: 'switch',
+					icon: 'ti ti-eye-exclamation',
 					text: i18n.ts.withSensitive,
 					ref: withSensitive,
 				}, {
 					type: 'switch',
+					icon: 'ti ti-photo',
 					text: i18n.ts.fileAttachedOnly,
 					ref: onlyFiles,
 					disabled: isBasicTimeline(src.value) && hasWithReplies(src.value) ? withReplies : false,
-- 
cgit v1.2.3-freya

