summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkImageFrameEditorDialog.vue
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-11-06 20:25:17 +0900
committerGitHub <noreply@github.com>2025-11-06 20:25:17 +0900
commit4ba18690d7abd7eea086bb59e6cbcc8ead9e121a (patch)
tree7d25ec47d8711d945b08e3903642f2e982f40048 /packages/frontend/src/components/MkImageFrameEditorDialog.vue
parentfix(frontend): improve startViewTransition handling (diff)
downloadmisskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.gz
misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.tar.bz2
misskey-4ba18690d7abd7eea086bb59e6cbcc8ead9e121a.zip
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
Diffstat (limited to 'packages/frontend/src/components/MkImageFrameEditorDialog.vue')
-rw-r--r--packages/frontend/src/components/MkImageFrameEditorDialog.vue509
1 files changed, 509 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkImageFrameEditorDialog.vue b/packages/frontend/src/components/MkImageFrameEditorDialog.vue
new file mode 100644
index 0000000000..2a91c85952
--- /dev/null
+++ b/packages/frontend/src/components/MkImageFrameEditorDialog.vue
@@ -0,0 +1,509 @@
+<!--
+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-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
+
+ <div :class="$style.root">
+ <div :class="$style.container">
+ <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">
+ <MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
+ </MkRange>
+
+ <MkInput :modelValue="getHex(params.bgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.bgColor = c; }">
+ <template #label>{{ i18n.ts._imageFrameEditor.backgroundColor }}</template>
+ </MkInput>
+
+ <MkInput :modelValue="getHex(params.fgColor)" type="color" @update:modelValue="v => { const c = getRgb(v); if (c != null) params.fgColor = c; }">
+ <template #label>{{ i18n.ts._imageFrameEditor.textColor }}</template>
+ </MkInput>
+
+ <MkSelect
+ v-model="params.font" :items="[
+ { label: i18n.ts._imageFrameEditor.fontSansSerif, value: 'sans-serif' },
+ { label: i18n.ts._imageFrameEditor.fontSerif, value: 'serif' },
+ ]"
+ >
+ <template #label>{{ i18n.ts._imageFrameEditor.font }}</template>
+ </MkSelect>
+
+ <MkFolder :defaultOpen="params.labelTop.enabled">
+ <template #label>{{ i18n.ts._imageFrameEditor.header }}</template>
+
+ <div class="_gaps">
+ <MkSwitch v-model="params.labelTop.enabled">
+ <template #label>{{ i18n.ts.show }}</template>
+ </MkSwitch>
+
+ <MkRange v-model="params.labelTop.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
+ </MkRange>
+
+ <MkRange v-model="params.labelTop.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
+ </MkRange>
+
+ <MkSwitch v-model="params.labelTop.centered">
+ <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
+ </MkSwitch>
+
+ <MkInput v-model="params.labelTop.textBig">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="params.labelTop.textSmall">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
+ </MkTextarea>
+
+ <MkSwitch v-model="params.labelTop.withQrCode">
+ <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
+
+ <MkFolder :defaultOpen="params.labelBottom.enabled">
+ <template #label>{{ i18n.ts._imageFrameEditor.footer }}</template>
+
+ <div class="_gaps">
+ <MkSwitch v-model="params.labelBottom.enabled">
+ <template #label>{{ i18n.ts.show }}</template>
+ </MkSwitch>
+
+ <MkRange v-model="params.labelBottom.padding" :min="0.01" :max="0.5" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelThickness }}</template>
+ </MkRange>
+
+ <MkRange v-model="params.labelBottom.scale" :min="0.5" :max="2.0" :step="0.01" :continuousUpdate="true">
+ <template #label>{{ i18n.ts._imageFrameEditor.labelScale }}</template>
+ </MkRange>
+
+ <MkSwitch v-model="params.labelBottom.centered">
+ <template #label>{{ i18n.ts._imageFrameEditor.centered }}</template>
+ </MkSwitch>
+
+ <MkInput v-model="params.labelBottom.textBig">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionMain }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="params.labelBottom.textSmall">
+ <template #label>{{ i18n.ts._imageFrameEditor.captionSub }}</template>
+ </MkTextarea>
+
+ <MkSwitch v-model="params.labelBottom.withQrCode">
+ <template #label>{{ i18n.ts._imageFrameEditor.withQrCode }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
+
+ <MkInfo>
+ <div>{{ i18n.ts._imageFrameEditor.availableVariables }}:</div>
+ <div><code class="_selectableAtomic">{filename}</code> - {{ i18n.ts._imageEditing._vars.filename }}</div>
+ <div><code class="_selectableAtomic">{filename_without_ext}</code> - {{ i18n.ts._imageEditing._vars.filename_without_ext }}</div>
+ <div><code class="_selectableAtomic">{caption}</code> - {{ i18n.ts._imageEditing._vars.caption }}</div>
+ <div><code class="_selectableAtomic">{year}</code> - {{ i18n.ts._imageEditing._vars.year }}</div>
+ <div><code class="_selectableAtomic">{month}</code> - {{ i18n.ts._imageEditing._vars.month }}</div>
+ <div><code class="_selectableAtomic">{day}</code> - {{ i18n.ts._imageEditing._vars.day }}</div>
+ <div><code class="_selectableAtomic">{hour}</code> - {{ i18n.ts._imageEditing._vars.hour }}</div>
+ <div><code class="_selectableAtomic">{minute}</code> - {{ i18n.ts._imageEditing._vars.minute }}</div>
+ <div><code class="_selectableAtomic">{second}</code> - {{ i18n.ts._imageEditing._vars.second }}</div>
+ <div><code class="_selectableAtomic">{0month}</code> - {{ i18n.ts._imageEditing._vars.month }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0day}</code> - {{ i18n.ts._imageEditing._vars.day }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0hour}</code> - {{ i18n.ts._imageEditing._vars.hour }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0minute}</code> - {{ i18n.ts._imageEditing._vars.minute }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{0second}</code> - {{ i18n.ts._imageEditing._vars.second }} ({{ i18n.ts.zeroPadding }})</div>
+ <div><code class="_selectableAtomic">{camera_model}</code> - {{ i18n.ts._imageEditing._vars.camera_model }}</div>
+ <div><code class="_selectableAtomic">{camera_lens_model}</code> - {{ i18n.ts._imageEditing._vars.camera_lens_model }}</div>
+ <div><code class="_selectableAtomic">{camera_mm}</code> - {{ i18n.ts._imageEditing._vars.camera_mm }}</div>
+ <div><code class="_selectableAtomic">{camera_mm_35}</code> - {{ i18n.ts._imageEditing._vars.camera_mm_35 }}</div>
+ <div><code class="_selectableAtomic">{camera_f}</code> - {{ i18n.ts._imageEditing._vars.camera_f }}</div>
+ <div><code class="_selectableAtomic">{camera_s}</code> - {{ i18n.ts._imageEditing._vars.camera_s }}</div>
+ <div><code class="_selectableAtomic">{camera_iso}</code> - {{ i18n.ts._imageEditing._vars.camera_iso }}</div>
+ <div><code class="_selectableAtomic">{gps_lat}</code> - {{ i18n.ts._imageEditing._vars.gps_lat }}</div>
+ <div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
+ </MkInfo>
+ </div>
+ </div>
+ </div>
+ </div>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { ref, useTemplateRef, watch, onMounted, onUnmounted, reactive, nextTick } from 'vue';
+import ExifReader from 'exifreader';
+import { throttle } from 'throttle-debounce';
+import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
+import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.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 MkSwitch from '@/components/MkSwitch.vue';
+import MkRange from '@/components/MkRange.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import * as os from '@/os.js';
+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();
+
+const props = defineProps<{
+ presetEditMode?: boolean;
+ preset?: ImageFramePreset | null;
+ params?: ImageFrameParams | null;
+ image?: File | null;
+ imageCaption?: string | null;
+ imageFilename?: string | null;
+}>();
+
+const preset = deepClone(props.preset) ?? {
+ id: genId(),
+ name: '',
+};
+
+const params = reactive<ImageFrameParams>(deepClone(props.params) ?? {
+ borderThickness: 0.05,
+ borderRadius: 0,
+ labelTop: {
+ enabled: false,
+ scale: 1.0,
+ padding: 0.2,
+ textBig: '',
+ textSmall: '',
+ centered: false,
+ withQrCode: false,
+ },
+ labelBottom: {
+ enabled: true,
+ scale: 1.0,
+ padding: 0.2,
+ textBig: '{year}/{0month}/{0day}',
+ textSmall: '{camera_mm}mm f/{camera_f} {camera_s}s ISO{camera_iso}',
+ centered: false,
+ withQrCode: true,
+ },
+ bgColor: [1, 1, 1],
+ fgColor: [0, 0, 0],
+ font: 'sans-serif',
+});
+
+const emit = defineEmits<{
+ (ev: 'ok', frame: ImageFrameParams): void;
+ (ev: 'presetOk', preset: ImageFramePreset): void;
+ (ev: 'cancel'): void;
+ (ev: 'closed'): void;
+}>();
+
+const dialog = useTemplateRef('dialog');
+
+async function cancel() {
+ if (props.presetEditMode) {
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.ts._imageFrameEditor.quitWithoutSaveConfirm,
+ });
+ if (canceled) return;
+ }
+
+ dialog.value?.close();
+}
+
+const updateThrottled = throttle(50, () => {
+ if (renderer != null) {
+ renderer.render(params);
+ }
+});
+
+watch(params, async (newValue, oldValue) => {
+ updateThrottled();
+}, { 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 (sampleImageType.value === 'provided') return;
+ if (renderer != null) {
+ renderer.destroy(false);
+ renderer = null;
+ initRenderer();
+ }
+});
+
+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: ImageFrameRenderer | null = null;
+let imageBitmap: ImageBitmap | null = null;
+
+async function initRenderer() {
+ if (canvasEl.value == null) return;
+
+ if (sampleImageType.value === '3_2') {
+ renderer = new ImageFrameRenderer({
+ canvas: canvasEl.value,
+ image: sampleImage_3_2,
+ exif: null,
+ caption: 'Example caption',
+ filename: 'example_file_name.jpg',
+ renderAsPreview: true,
+ });
+ } else if (sampleImageType.value === '2_3') {
+ renderer = new ImageFrameRenderer({
+ canvas: canvasEl.value,
+ image: sampleImage_2_3,
+ exif: null,
+ caption: 'Example caption',
+ filename: 'example_file_name.jpg',
+ renderAsPreview: true,
+ });
+ } else if (imageFile != null) {
+ imageBitmap = await window.createImageBitmap(imageFile);
+
+ const exif = ExifReader.load(await imageFile.arrayBuffer());
+
+ renderer = new ImageFrameRenderer({
+ canvas: canvasEl.value,
+ image: imageBitmap,
+ exif: exif,
+ caption: props.imageCaption ?? null,
+ filename: props.imageFilename ?? null,
+ renderAsPreview: true,
+ });
+ }
+
+ await renderer!.render(params);
+}
+
+onMounted(async () => {
+ const closeWaiting = os.waiting();
+
+ await nextTick(); // waitingがレンダリングされるまで待つ
+
+ await sampleImage_3_2_loading;
+ await sampleImage_2_3_loading;
+
+ try {
+ await initRenderer();
+ } catch (err) {
+ console.error(err);
+ os.alert({
+ type: 'error',
+ text: i18n.ts._imageFrameEditor.failedToLoadImage,
+ });
+ }
+
+ closeWaiting();
+});
+
+onUnmounted(() => {
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+ if (imageBitmap != null) {
+ imageBitmap.close();
+ imageBitmap = null;
+ }
+});
+
+async function save() {
+ if (props.presetEditMode) {
+ 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('presetOk', {
+ ...preset,
+ params: deepClone(params),
+ });
+ } else {
+ dialog.value?.close();
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+
+ emit('ok', params);
+ }
+}
+
+function getHex(c: [number, number, number]) {
+ return `#${c.map(x => (x * 255).toString(16).padStart(2, '0')).join('')}`;
+}
+
+function getRgb(hex: string | number): [number, number, number] | null {
+ if (
+ typeof hex === 'number' ||
+ typeof hex !== 'string' ||
+ !/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex)
+ ) {
+ return null;
+ }
+
+ const m = hex.slice(1).match(/[0-9a-fA-F]{2}/g);
+ if (m == null) return [0, 0, 0];
+ return m.map(x => parseInt(x, 16) / 255) as [number, number, number];
+}
+</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-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 {
+ 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>