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/MkWatermarkEditorDialog.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/MkWatermarkEditorDialog.vue')
| -rw-r--r-- | packages/frontend/src/components/MkWatermarkEditorDialog.vue | 455 |
1 files changed, 455 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue new file mode 100644 index 0000000000..4cfb4a72bc --- /dev/null +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -0,0 +1,455 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialog" + :width="1000" + :height="600" + :scroll="false" + :withOkButton="true" + @close="cancel()" + @ok="save()" + @closed="emit('closed')" +> + <template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template> + + <div :class="$style.root"> + <div :class="$style.container"> + <div :class="$style.preview"> + <canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> + <div :class="$style.previewContainer"> + <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> + <div v-if="props.image == null" class="_acrylic" :class="$style.previewControls"> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '3_2' ? $style.active : null]" @click="sampleImageType = '3_2'"><i class="ti ti-crop-landscape"></i></button> + <button class="_button" :class="[$style.previewControlsButton, sampleImageType === '2_3' ? $style.active : null]" @click="sampleImageType = '2_3'"><i class="ti ti-crop-portrait"></i></button> + </div> + </div> + </div> + <div :class="$style.controls"> + <div class="_spacer _gaps"> + <MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]"> + <template #label>{{ i18n.ts._watermarkEditor.type }}</template> + </MkSelect> + + <div v-if="type === 'text' || type === 'image'"> + <XLayer + v-for="(layer, i) in preset.layers" + :key="layer.id" + v-model:layer="preset.layers[i]" + ></XLayer> + </div> + <div v-else-if="type === 'advanced'" class="_gaps_s"> + <MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false"> + <template #label> + <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> + <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> + <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> + <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> + <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> + </template> + <template #footer> + <div class="_buttons"> + <MkButton iconOnly @click="removeLayer(layer)"><i class="ti ti-trash"></i></MkButton> + <MkButton iconOnly @click="swapUpLayer(layer)"><i class="ti ti-arrow-up"></i></MkButton> + <MkButton iconOnly @click="swapDownLayer(layer)"><i class="ti ti-arrow-down"></i></MkButton> + </div> + </template> + + <XLayer + v-model:layer="preset.layers[i]" + ></XLayer> + </MkFolder> + + <MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton> + </div> + </div> + </div> + </div> + </div> +</MkModalWindow> +</template> + +<script setup lang="ts"> +import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue'; +import type { WatermarkPreset } from '@/utility/watermark.js'; +import { WatermarkRenderer } from '@/utility/watermark.js'; +import { i18n } from '@/i18n.js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import XLayer from '@/components/MkWatermarkEditorDialog.Layer.vue'; +import * as os from '@/os.js'; +import { deepClone } from '@/utility/clone.js'; +import { ensureSignin } from '@/i.js'; +import { genId } from '@/utility/id.js'; + +const $i = ensureSignin(); + +function createTextLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'text', + text: `(c) @${$i.username}`, + align: { x: 'right', y: 'bottom' }, + scale: 0.3, + angle: 0, + opacity: 0.75, + repeat: false, + }; +} + +function createImageLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'image', + imageId: null, + imageUrl: null, + align: { x: 'right', y: 'bottom' }, + scale: 0.3, + angle: 0, + opacity: 0.75, + repeat: false, + cover: false, + }; +} + +function createStripeLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'stripe', + angle: 0.5, + frequency: 10, + threshold: 0.1, + black: false, + opacity: 0.75, + }; +} + +function createPolkadotLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'polkadot', + angle: 0.5, + scale: 3, + majorRadius: 0.1, + minorRadius: 0.25, + majorOpacity: 0.75, + minorOpacity: 0.5, + minorDivisions: 4, + black: false, + opacity: 0.75, + }; +} + +function createCheckerLayer(): WatermarkPreset['layers'][number] { + return { + id: genId(), + type: 'checker', + angle: 0.5, + scale: 3, + black: false, + opacity: 0.75, + }; +} + +const props = defineProps<{ + preset?: WatermarkPreset | null; + image?: File | null; +}>(); + +const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? { + id: genId(), + name: '', + layers: [createTextLayer()], +}); + +const emit = defineEmits<{ + (ev: 'ok', preset: WatermarkPreset): void; + (ev: 'cancel'): void; + (ev: 'closed'): void; +}>(); + +const dialog = useTemplateRef('dialog'); + +async function cancel() { + const { canceled } = await os.confirm({ + text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm, + }); + if (canceled) return; + + emit('cancel'); + dialog.value?.close(); +} + +const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type); +watch(type, () => { + if (type.value === 'text') { + preset.layers = [createTextLayer()]; + } else if (type.value === 'image') { + preset.layers = [createImageLayer()]; + } else if (type.value === 'advanced') { + // nop + } +}); + +watch(preset, async (newValue, oldValue) => { + if (renderer != null) { + renderer.setLayers(preset.layers); + } +}, { deep: true }); + +const canvasEl = useTemplateRef('canvasEl'); + +const sampleImage_3_2 = new Image(); +sampleImage_3_2.src = '/client-assets/sample/3-2.jpg'; +const sampleImage_3_2_loading = new Promise<void>(resolve => { + sampleImage_3_2.onload = () => resolve(); +}); + +const sampleImage_2_3 = new Image(); +sampleImage_2_3.src = '/client-assets/sample/2-3.jpg'; +const sampleImage_2_3_loading = new Promise<void>(resolve => { + sampleImage_2_3.onload = () => resolve(); +}); + +const sampleImageType = ref(props.image != null ? 'provided' : '3_2'); +watch(sampleImageType, async () => { + if (renderer != null) { + renderer.destroy(false); + renderer = null; + initRenderer(); + } +}); + +let renderer: WatermarkRenderer | null = null; +let imageBitmap: ImageBitmap | null = null; + +async function initRenderer() { + if (canvasEl.value == null) return; + + if (sampleImageType.value === '3_2') { + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1500, + renderHeight: 1000, + image: sampleImage_3_2, + }); + } else if (sampleImageType.value === '2_3') { + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: 1000, + renderHeight: 1500, + image: sampleImage_2_3, + }); + } else if (props.image != null) { + imageBitmap = await window.createImageBitmap(props.image); + + const MAX_W = 1000; + const MAX_H = 1000; + let w = imageBitmap.width; + let h = imageBitmap.height; + + if (w > MAX_W || h > MAX_H) { + const scale = Math.min(MAX_W / w, MAX_H / h); + w *= scale; + h *= scale; + } + + renderer = new WatermarkRenderer({ + canvas: canvasEl.value, + renderWidth: w, + renderHeight: h, + image: imageBitmap, + }); + } + + await renderer!.setLayers(preset.layers); + + renderer!.render(); +} + +onMounted(async () => { + const closeWaiting = os.waiting(); + + await nextTick(); // waitingがレンダリングされるまで待つ + + await sampleImage_3_2_loading; + await sampleImage_2_3_loading; + + await initRenderer(); + + closeWaiting(); +}); + +onUnmounted(() => { + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + if (imageBitmap != null) { + imageBitmap.close(); + imageBitmap = null; + } +}); + +async function save() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.name, + default: preset.name, + }); + if (canceled) return; + + preset.name = name || ''; + + dialog.value?.close(); + if (renderer != null) { + renderer.destroy(); + renderer = null; + } + + emit('ok', preset); +} + +function addLayer(ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts._watermarkEditor.text, + action: () => { + preset.layers.push(createTextLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.image, + action: () => { + preset.layers.push(createImageLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.stripe, + action: () => { + preset.layers.push(createStripeLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.polkadot, + action: () => { + preset.layers.push(createPolkadotLayer()); + }, + }, { + text: i18n.ts._watermarkEditor.checker, + action: () => { + preset.layers.push(createCheckerLayer()); + }, + }], ev.currentTarget ?? ev.target); +} + +function swapUpLayer(layer: WatermarkPreset['layers'][number]) { + const index = preset.layers.findIndex(l => l.id === layer.id); + if (index > 0) { + const tmp = preset.layers[index - 1]; + preset.layers[index - 1] = preset.layers[index]; + preset.layers[index] = tmp; + } +} + +function swapDownLayer(layer: WatermarkPreset['layers'][number]) { + const index = preset.layers.findIndex(l => l.id === layer.id); + if (index < preset.layers.length - 1) { + const tmp = preset.layers[index + 1]; + preset.layers[index + 1] = preset.layers[index]; + preset.layers[index] = tmp; + } +} + +function removeLayer(layer: WatermarkPreset['layers'][number]) { + preset.layers = preset.layers.filter(l => l.id !== layer.id); +} +</script> + +<style module> +.root { + container-type: inline-size; + height: 100%; +} + +.container { + height: 100%; + display: grid; + grid-template-columns: 1fr 400px; +} + +.preview { + position: relative; + background-color: var(--MI_THEME-bg); + background-size: auto auto; + background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); +} + +.previewContainer { + display: flex; + flex-direction: column; + height: 100%; + user-select: none; + -webkit-user-drag: none; +} + +.previewTitle { + position: absolute; + z-index: 100; + top: 8px; + left: 8px; + padding: 6px 10px; + border-radius: 6px; + font-size: 85%; +} + +.previewControls { + position: absolute; + z-index: 100; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; +} + +.previewControlsButton { + &.active { + color: var(--MI_THEME-accent); + } +} + +.previewSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.previewCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + padding: 20px; + box-sizing: border-box; + object-fit: contain; +} + +.controls { + overflow-y: scroll; +} + +@container (max-width: 800px) { + .container { + grid-template-columns: 1fr; + grid-template-rows: 1fr 1fr; + } +} +</style> |