diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-06-03 19:18:29 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-03 19:18:29 +0900 |
| commit | cd9322a8243b12632db2dd9a29a702d7531a5aa0 (patch) | |
| tree | 2828957ed7c27c537386cda13ace2372903185b8 /packages/frontend/src/components/MkUploaderDialog.vue | |
| parent | chore(frontend): remove duplicate declarations (diff) | |
| download | misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.tar.gz misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.tar.bz2 misskey-cd9322a8243b12632db2dd9a29a702d7531a5aa0.zip | |
feat(frontend): 画像編集機能 (#16121)
* wip
* wip
* wip
* wip
* Update watermarker.ts
* wip
* wip
* Update watermarker.ts
* Update MkUploaderDialog.vue
* wip
* Update ImageEffector.ts
* Update ImageEffector.ts
* wip
* wip
* wip
* wip
* wip
* wip
* Update MkRange.vue
* Update MkRange.vue
* wip
* wip
* Update MkImageEffectorDialog.vue
* Update MkImageEffectorDialog.Layer.vue
* wip
* Update zoomLines.ts
* Update zoomLines.ts
* wip
* wip
* Update ImageEffector.ts
* wip
* Update ImageEffector.ts
* wip
* Update ImageEffector.ts
* swip
* wip
* Update ImageEffector.ts
* wop
* Update MkUploaderDialog.vue
* Update ImageEffector.ts
* wip
* wip
* wip
* Update def.ts
* Update def.ts
* test
* test
* Update manager.ts
* Update manager.ts
* Update manager.ts
* Update manager.ts
* Update MkImageEffectorDialog.vue
* wip
* use WEBGL_lose_context
* wip
* Update MkUploaderDialog.vue
* Update drive.vue
* wip
* Update MkUploaderDialog.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
Diffstat (limited to 'packages/frontend/src/components/MkUploaderDialog.vue')
| -rw-r--r-- | packages/frontend/src/components/MkUploaderDialog.vue | 292 |
1 files changed, 230 insertions, 62 deletions
diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue index a0d25d08d3..b2e4896ed3 100644 --- a/packages/frontend/src/components/MkUploaderDialog.vue +++ b/packages/frontend/src/components/MkUploaderDialog.vue @@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-for="ctx in items" :key="ctx.id" v-panel - :class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]" + :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"> @@ -40,8 +40,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div> <div :class="$style.itemInfo"> <span>{{ ctx.file.type }}</span> - <span>{{ bytes(ctx.file.size) }}</span> <span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span> + <span v-else>{{ bytes(ctx.file.size) }}</span> </div> <div> </div> @@ -59,19 +59,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton> </div> - <MkSelect - v-if="items.length > 0" - v-model="compressionLevel" - :items="[ - { value: 0, label: i18n.ts.none }, - { value: 1, label: i18n.ts.low }, - { value: 2, label: i18n.ts.middle }, - { value: 3, label: i18n.ts.high }, - ]" - > - <template #label>{{ i18n.ts.compress }}</template> - </MkSelect> - <div>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div> <!-- クライアントで検出するMIME typeとサーバーで検出するMIME typeが異なる場合があり、混乱の元になるのでとりあえず隠しとく --> @@ -93,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, markRaw, onMounted, ref, useTemplateRef, watch } from 'vue'; +import { computed, defineAsyncComponent, markRaw, onMounted, onUnmounted, ref, triggerRef, useTemplateRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import { genId } from '@/utility/id.js'; import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; @@ -109,6 +96,7 @@ 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'; const $i = ensureSignin(); @@ -125,6 +113,14 @@ const CROPPING_SUPPORTED_TYPES = [ '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', @@ -148,16 +144,19 @@ const emit = defineEmits<{ const items = ref<{ id: string; name: string; + uploadName?: string; progress: { max: number; value: number } | null; thumbnail: string; - waiting: boolean; + preprocessing: boolean; uploading: boolean; uploaded: Misskey.entities.DriveFile | null; uploadFailed: boolean; aborted: boolean; + compressionLevel: number; compressedSize?: number | null; - compressedImage?: Blob | null; + preprocessedFile?: Blob | null; file: File; + watermarkPresetId: string | null; abort?: (() => void) | null; }[]>([]); @@ -165,7 +164,7 @@ const dialog = useTemplateRef('dialog'); const firstUploadAttempted = ref(false); const isUploading = computed(() => items.value.some(item => item.uploading)); -const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null)); +const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.preprocessing) && items.value.some(item => item.uploaded == null)); const canDone = computed(() => items.value.some(item => item.uploaded != null)); const overallProgress = computed(() => { const max = items.value.length; @@ -178,19 +177,18 @@ const overallProgress = computed(() => { return Math.round((v / max) * 100); }); -const compressionLevel = ref<0 | 1 | 2 | 3>(2); -const compressionSettings = computed(() => { - if (compressionLevel.value === 1) { +function getCompressionSettings(level: 0 | 1 | 2 | 3) { + if (level === 1) { return { maxWidth: 2000, maxHeight: 2000, }; - } else if (compressionLevel.value === 2) { + } else if (level === 2) { return { maxWidth: 2000 * 0.75, // =1500 maxHeight: 2000 * 0.75, // =1500 }; - } else if (compressionLevel.value === 3) { + } else if (level === 3) { return { maxWidth: 2000 * 0.75 * 0.75, // =1125 maxHeight: 2000 * 0.75 * 0.75, // =1125 @@ -198,7 +196,7 @@ const compressionSettings = computed(() => { } else { return null; } -}); +} watch(items, () => { if (items.value.length === 0) { @@ -274,31 +272,151 @@ function showMenu(ev: MouseEvent, item: typeof items.value[0]) { }, }); - if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) { + if (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 }); - items.value.splice(items.value.indexOf(item), 1, { + 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 (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 (!item.waiting && !item.uploading && !item.uploaded) { + if (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, + 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', + text: preset.name, + active: computed(() => item.watermarkPresetId === preset.id), + action: () => changeWatermarkPreset(preset.id), + })), { + type: 'divider', + }, { + 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: i18n.ts.compress, + 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, @@ -320,7 +438,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ ...item, aborted: false, uploadFailed: false, - waiting: false, uploading: false, })); @@ -330,40 +447,13 @@ async function upload() { // エラーハンドリングなどを考慮してシ continue; } - item.waiting = true; item.uploadFailed = false; - - const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && !(await isAnimated(item.file)); - - if (shouldCompress) { - const config = { - mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg', - maxWidth: compressionSettings.value.maxWidth, - maxHeight: compressionSettings.value.maxHeight, - quality: isWebpSupported() ? 0.85 : 0.8, - }; - - try { - const result = await readAndCompressImage(item.file, config); - if (result.size < item.file.size || item.file.type === 'image/webp') { - // The compression may not always reduce the file size - // (and WebP is not browser safe yet) - item.compressedImage = markRaw(result); - item.compressedSize = result.size; - item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; - } - } catch (err) { - console.error('Failed to resize image', err); - } - } - item.uploading = true; - const { filePromise, abort } = uploadFile(item.compressedImage ?? item.file, { - name: item.name, + const { filePromise, abort } = uploadFile(item.preprocessedFile ?? item.file, { + name: item.uploadName ?? item.name, folderId: props.folderId, onProgress: (progress) => { - item.waiting = false; if (item.progress == null) { item.progress = { max: progress.total, value: progress.loaded }; } else { @@ -377,7 +467,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ item.abort = null; abort(); item.uploading = false; - item.waiting = false; item.uploadFailed = true; }; @@ -392,7 +481,6 @@ async function upload() { // エラーハンドリングなどを考慮してシ } }).finally(() => { item.uploading = false; - item.waiting = false; }); } } @@ -419,21 +507,95 @@ async function chooseFile(ev: MouseEvent) { } } +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 initializeFile(file: File) { const id = genId(); const filename = file.name ?? 'untitled'; const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; - items.value.push({ + const item = { id, name: prefer.s.keepOriginalFilename ? filename : id + extension, progress: null, thumbnail: window.URL.createObjectURL(file), - waiting: false, + preprocessing: false, uploading: false, aborted: false, uploaded: null, uploadFailed: false, + compressionLevel: prefer.s.defaultImageCompressionLevel, + watermarkPresetId: prefer.s.defaultWatermarkPresetId, file: markRaw(file), + }; + items.value.push(item); + preprocess(item).then(() => { + triggerRef(items); }); } @@ -442,6 +604,12 @@ onMounted(() => { initializeFile(file); } }); + +onUnmounted(() => { + for (const item of items.value) { + URL.revokeObjectURL(item.thumbnail); + } +}); </script> <style lang="scss" module> |