summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkUploaderDialog.vue
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-06-03 19:18:29 +0900
committerGitHub <noreply@github.com>2025-06-03 19:18:29 +0900
commitcd9322a8243b12632db2dd9a29a702d7531a5aa0 (patch)
tree2828957ed7c27c537386cda13ace2372903185b8 /packages/frontend/src/components/MkUploaderDialog.vue
parentchore(frontend): remove duplicate declarations (diff)
downloadmisskey-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.vue292
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>