@@ -79,107 +49,49 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
diff --git a/packages/frontend/src/components/MkUploaderItems.vue b/packages/frontend/src/components/MkUploaderItems.vue
new file mode 100644
index 0000000000..02f8aee8ce
--- /dev/null
+++ b/packages/frontend/src/components/MkUploaderItems.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.name }}
+
+ {{ item.file.type }}
+ ({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})
+ {{ bytes(item.file.size) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/composables/use-uploader.ts b/packages/frontend/src/composables/use-uploader.ts
new file mode 100644
index 0000000000..a9490bc278
--- /dev/null
+++ b/packages/frontend/src/composables/use-uploader.ts
@@ -0,0 +1,510 @@
+/*
+ * 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 { 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 uploaderFeatures = computed
>(() => {
+ return {
+ effect: options.features?.effect ?? true,
+ watermark: options.features?.watermark ?? true,
+ crop: options.features?.crop ?? true,
+ };
+ });
+
+ const items = ref([]);
+
+ 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 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);
+ 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-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();
+ }
+ },
+ });
+ }
+
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
+ }
+
+ 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;
+ }
+
+ 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;
+ }).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 preprocess(item: UploaderItem): Promise {
+ 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((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,
+ showMenu,
+ uploading: computed(() => items.value.some(item => item.uploading)),
+ };
+}
+