mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-19 22:55:29 +02:00
feat(frontend): EXIFフレーム機能 (#16725)
* wip
* wip
* Update ImageEffector.ts
* Update image-label-renderer.ts
* Update image-label-renderer.ts
* wip
* Update image-label-renderer.ts
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update use-uploader.ts
* Update watermark.ts
* wip
* wu
* wip
* Update image-frame-renderer.ts
* wip
* wip
* Update image-frame-renderer.ts
* Create ImageCompositor.ts
* Update ImageCompositor.ts
* wip
* wip
* Update ImageEffector.ts
* wip
* Update use-uploader.ts
* wip
* wip
* wip
* wip
* Update fxs.ts
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* wip
* Update MkImageEffectorDialog.vue
* Update MkImageEffectorDialog.vue
* Update MkImageFrameEditorDialog.vue
* Update use-uploader.ts
* improve error handling
* Update use-uploader.ts
* 🎨
* wip
* wip
* lazy load
* lazy load
* wip
* wip
* wip
This commit is contained in:
@@ -18,20 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.preview">
|
||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
||||
<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>
|
||||
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<MkFolder v-for="(layer, i) in 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>
|
||||
@@ -49,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<XLayer
|
||||
v-model:layer="preset.layers[i]"
|
||||
v-model:layer="layers[i]"
|
||||
></XLayer>
|
||||
</MkFolder>
|
||||
|
||||
@@ -64,8 +65,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<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 type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
@@ -77,6 +78,7 @@ import { deepClone } from '@/utility/clone.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
@@ -161,18 +163,22 @@ function createCheckerLayer(): WatermarkPreset['layers'][number] {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
presetEditMode?: boolean;
|
||||
preset?: WatermarkPreset | null;
|
||||
layers?: WatermarkLayers | null;
|
||||
image?: File | null;
|
||||
}>();
|
||||
|
||||
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
|
||||
const preset = deepClone(props.preset) ?? {
|
||||
id: genId(),
|
||||
name: '',
|
||||
layers: [],
|
||||
});
|
||||
};
|
||||
|
||||
const layers = reactive<WatermarkLayers>(props.layers ?? []);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', preset: WatermarkPreset): void;
|
||||
(ev: 'ok', layers: WatermarkLayers): void;
|
||||
(ev: 'presetOk', preset: WatermarkPreset): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
@@ -180,19 +186,21 @@ const emit = defineEmits<{
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
async function cancel() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (props.presetEditMode) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts._watermarkEditor.quitWithoutSaveConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
emit('cancel');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
watch(preset, async (newValue, oldValue) => {
|
||||
watch(layers, async (newValue, oldValue) => {
|
||||
if (renderer != null) {
|
||||
renderer.setLayers(preset.layers);
|
||||
renderer.render(layers);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
@@ -212,6 +220,7 @@ const sampleImage_2_3_loading = new Promise<void>(resolve => {
|
||||
|
||||
const sampleImageType = ref(props.image != null ? 'provided' : '3_2');
|
||||
watch(sampleImageType, async () => {
|
||||
if (sampleImageType.value === 'provided') return;
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
@@ -219,6 +228,20 @@ watch(sampleImageType, async () => {
|
||||
}
|
||||
});
|
||||
|
||||
let imageFile = props.image;
|
||||
|
||||
async function choiceImage() {
|
||||
const files = await os.chooseFileFromPc({ multiple: false });
|
||||
if (files.length === 0) return;
|
||||
imageFile = files[0];
|
||||
sampleImageType.value = 'provided';
|
||||
if (renderer != null) {
|
||||
renderer.destroy(false);
|
||||
renderer = null;
|
||||
initRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
let renderer: WatermarkRenderer | null = null;
|
||||
let imageBitmap: ImageBitmap | null = null;
|
||||
|
||||
@@ -239,8 +262,8 @@ async function initRenderer() {
|
||||
renderHeight: 1500,
|
||||
image: sampleImage_2_3,
|
||||
});
|
||||
} else if (props.image != null) {
|
||||
imageBitmap = await window.createImageBitmap(props.image);
|
||||
} else if (imageFile != null) {
|
||||
imageBitmap = await window.createImageBitmap(imageFile);
|
||||
|
||||
const MAX_W = 1000;
|
||||
const MAX_H = 1000;
|
||||
@@ -249,8 +272,8 @@ async function initRenderer() {
|
||||
|
||||
if (w > MAX_W || h > MAX_H) {
|
||||
const scale = Math.min(MAX_W / w, MAX_H / h);
|
||||
w *= scale;
|
||||
h *= scale;
|
||||
w = Math.floor(w * scale);
|
||||
h = Math.floor(h * scale);
|
||||
}
|
||||
|
||||
renderer = new WatermarkRenderer({
|
||||
@@ -261,9 +284,7 @@ async function initRenderer() {
|
||||
});
|
||||
}
|
||||
|
||||
await renderer!.setLayers(preset.layers);
|
||||
|
||||
renderer!.render();
|
||||
await renderer!.render(layers);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -274,7 +295,15 @@ onMounted(async () => {
|
||||
await sampleImage_3_2_loading;
|
||||
await sampleImage_2_3_loading;
|
||||
|
||||
await initRenderer();
|
||||
try {
|
||||
await initRenderer();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts._watermarkEditor.failedToLoadImage,
|
||||
});
|
||||
}
|
||||
|
||||
closeWaiting();
|
||||
});
|
||||
@@ -291,77 +320,93 @@ onUnmounted(() => {
|
||||
});
|
||||
|
||||
async function save() {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (props.presetEditMode) {
|
||||
const { canceled, result: name } = await os.inputText({
|
||||
title: i18n.ts.name,
|
||||
default: preset.name,
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
preset.name = name || '';
|
||||
preset.name = name || '';
|
||||
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('presetOk', {
|
||||
...preset,
|
||||
layers: deepClone(layers),
|
||||
});
|
||||
} else {
|
||||
dialog.value?.close();
|
||||
if (renderer != null) {
|
||||
renderer.destroy();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
emit('ok', layers);
|
||||
}
|
||||
|
||||
emit('ok', preset);
|
||||
}
|
||||
|
||||
function addLayer(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts._watermarkEditor.text,
|
||||
action: () => {
|
||||
preset.layers.push(createTextLayer());
|
||||
layers.push(createTextLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.image,
|
||||
action: () => {
|
||||
preset.layers.push(createImageLayer());
|
||||
layers.push(createImageLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.qr,
|
||||
action: () => {
|
||||
preset.layers.push(createQrLayer());
|
||||
layers.push(createQrLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.stripe,
|
||||
action: () => {
|
||||
preset.layers.push(createStripeLayer());
|
||||
layers.push(createStripeLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.polkadot,
|
||||
action: () => {
|
||||
preset.layers.push(createPolkadotLayer());
|
||||
layers.push(createPolkadotLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.checker,
|
||||
action: () => {
|
||||
preset.layers.push(createCheckerLayer());
|
||||
layers.push(createCheckerLayer());
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function swapUpLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
const index = preset.layers.findIndex(l => l.id === layer.id);
|
||||
const index = 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;
|
||||
const tmp = layers[index - 1];
|
||||
layers[index - 1] = layers[index];
|
||||
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;
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index < layers.length - 1) {
|
||||
const tmp = layers[index + 1];
|
||||
layers[index + 1] = layers[index];
|
||||
layers[index] = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
preset.layers = preset.layers.filter(l => l.id !== layer.id);
|
||||
const index = layers.findIndex(l => l.id === layer.id);
|
||||
if (index !== -1) {
|
||||
layers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -380,8 +425,17 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||
.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);
|
||||
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.animatedBg {
|
||||
animation: bg 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bg {
|
||||
0% { background-position: 0 0; }
|
||||
100% { background-position: -20px -20px; }
|
||||
}
|
||||
|
||||
.previewContainer {
|
||||
|
||||
Reference in New Issue
Block a user