summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkEmbedCodeGenDialog.vue23
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.Layer.vue12
-rw-r--r--packages/frontend/src/components/MkImageEffectorDialog.vue87
-rw-r--r--packages/frontend/src/components/MkImageFrameEditorDialog.vue509
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue2
-rw-r--r--packages/frontend/src/components/MkWatermarkEditorDialog.vue162
6 files changed, 690 insertions, 105 deletions
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
index 0cb8499699..4f16149caa 100644
--- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -24,9 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="$style.transition_x_leaveTo"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
- <div
- :class="$style.embedCodeGenPreviewRoot"
- >
+ <div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
@@ -91,20 +89,18 @@ import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
-
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
-
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';
-
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
+import { prefer } from '@/preferences.js';
const emit = defineEmits<{
(ev: 'ok'): void;
@@ -314,10 +310,19 @@ onUnmounted(() => {
.embedCodeGenPreviewRoot {
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);
cursor: not-allowed;
+ 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; }
}
.embedCodeGenPreviewWrapper {
diff --git a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue
index f734325039..bc7e8b0946 100644
--- a/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue
+++ b/packages/frontend/src/components/MkImageEffectorDialog.Layer.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkFolder :defaultOpen="true" :canPage="false">
- <template #label>{{ fx.name }}</template>
+ <template #label>{{ fx.uiDefinition.name }}</template>
<template #footer>
<div class="_buttons">
<MkButton iconOnly @click="emit('del')"><i class="ti ti-trash"></i></MkButton>
@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
- <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.params" />
+ <MkImageEffectorFxForm v-model="layer.params" :paramDefs="fx.uiDefinition.params"/>
</MkFolder>
</template>
@@ -26,14 +26,14 @@ import MkImageEffectorFxForm from '@/components/MkImageEffectorFxForm.vue';
import { FXS } from '@/utility/image-effector/fxs.js';
const layer = defineModel<ImageEffectorLayer>('layer', { required: true });
-const fx = FXS.find((fx) => fx.id === layer.value.fxId);
+const fx = FXS[layer.value.fxId];
if (fx == null) {
throw new Error(`Unrecognized effect: ${layer.value.fxId}`);
}
const emit = defineEmits<{
- (e: 'del'): void;
- (e: 'swapUp'): void;
- (e: 'swapDown'): void;
+ (ev: 'del'): void;
+ (ev: 'swapUp'): void;
+ (ev: 'swapDown'): void;
}>();
</script>
diff --git a/packages/frontend/src/components/MkImageEffectorDialog.vue b/packages/frontend/src/components/MkImageEffectorDialog.vue
index 19ddb81919..3d7801f925 100644
--- a/packages/frontend/src/components/MkImageEffectorDialog.vue
+++ b/packages/frontend/src/components/MkImageEffectorDialog.vue
@@ -18,7 +18,7 @@ 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" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
@@ -64,6 +64,7 @@ import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { FXS } from '@/utility/image-effector/fxs.js';
import { genId } from '@/utility/id.js';
+import { prefer } from '@/preferences.js';
const props = defineProps<{
image: File;
@@ -94,19 +95,19 @@ const layers = reactive<ImageEffectorLayer[]>([]);
watch(layers, async () => {
if (renderer != null) {
- renderer.setLayers(layers);
+ renderer.render(layers);
}
}, { deep: true });
function addEffect(ev: MouseEvent) {
- os.popupMenu(FXS.map((fx) => ({
- text: fx.name,
+ os.popupMenu(Object.entries(FXS).map(([id, fx]) => ({
+ text: fx.uiDefinition.name,
action: () => {
layers.push({
id: genId(),
- fxId: fx.id,
- params: Object.fromEntries(Object.entries(fx.params).map(([k, v]) => [k, v.default])),
- });
+ fxId: id as keyof typeof FXS,
+ params: Object.fromEntries(Object.entries(fx.uiDefinition.params).map(([k, v]) => [k, v.default])),
+ } as ImageEffectorLayer);
},
})), ev.currentTarget ?? ev.target);
}
@@ -136,7 +137,7 @@ function onLayerDelete(layer: ImageEffectorLayer) {
const canvasEl = useTemplateRef('canvasEl');
-let renderer: ImageEffector<typeof FXS> | null = null;
+let renderer: ImageEffector | null = null;
let imageBitmap: ImageBitmap | null = null;
onMounted(async () => {
@@ -146,30 +147,35 @@ onMounted(async () => {
await nextTick(); // waitingがレンダリングされるまで待つ
- imageBitmap = await window.createImageBitmap(props.image);
+ try {
+ imageBitmap = await window.createImageBitmap(props.image);
- const MAX_W = 1000;
- const MAX_H = 1000;
- let w = imageBitmap.width;
- let h = imageBitmap.height;
+ 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 ImageEffector({
- canvas: canvasEl.value,
- renderWidth: w,
- renderHeight: h,
- image: imageBitmap,
- fxs: FXS,
- });
+ if (w > MAX_W || h > MAX_H) {
+ const scale = Math.min(MAX_W / w, MAX_H / h);
+ w = Math.floor(w * scale);
+ h = Math.floor(h * scale);
+ }
- await renderer.setLayers(layers);
+ renderer = new ImageEffector({
+ canvas: canvasEl.value,
+ renderWidth: w,
+ renderHeight: h,
+ image: imageBitmap,
+ });
- renderer.render();
+ await renderer.render(layers);
+ } catch (err) {
+ console.error(err);
+ os.alert({
+ type: 'error',
+ text: i18n.ts._imageEffector.failedToLoadImage,
+ });
+ }
closeWaiting();
});
@@ -196,7 +202,7 @@ async function save() {
await nextTick(); // waitingがレンダリングされるまで待つ
renderer.changeResolution(imageBitmap.width, imageBitmap.height); // 本番レンダリングのためオリジナル画質に戻す
- renderer.render(); // toBlobの直前にレンダリングしないと何故か壊れる
+ await renderer.render(layers); // toBlobの直前にレンダリングしないと何故か壊れる
canvasEl.value.toBlob((blob) => {
emit('ok', new File([blob!], `image-${Date.now()}.png`, { type: 'image/png' }));
dialog.value?.close();
@@ -208,11 +214,10 @@ const enabled = ref(true);
watch(enabled, () => {
if (renderer != null) {
if (enabled.value) {
- renderer.setLayers(layers);
+ renderer.render(layers);
} else {
- renderer.setLayers([]);
+ renderer.render([]);
}
- renderer.render();
}
});
@@ -281,6 +286,7 @@ function onImagePointerdown(ev: PointerEvent) {
angle: 0,
opacity: 1,
color: [1, 1, 1],
+ ellipse: false,
},
});
} else if (penMode.value === 'blur') {
@@ -294,6 +300,7 @@ function onImagePointerdown(ev: PointerEvent) {
scaleY: 0.1,
angle: 0,
radius: 3,
+ ellipse: false,
},
});
} else if (penMode.value === 'pixelate') {
@@ -307,6 +314,7 @@ function onImagePointerdown(ev: PointerEvent) {
scaleY: 0.1,
angle: 0,
strength: 0.2,
+ ellipse: false,
},
});
}
@@ -329,7 +337,7 @@ function onImagePointerdown(ev: PointerEvent) {
const scaleY = Math.abs(y - startY);
const layerIndex = layers.findIndex((l) => l.id === id);
- const layer = layerIndex !== -1 ? layers[layerIndex] : null;
+ const layer = layerIndex !== -1 ? (layers[layerIndex] as Extract<ImageEffectorLayer, { fxId: 'fill' } | { fxId: 'blur' } | { fxId: 'pixelate' }>) : null;
if (layer != null) {
layer.params.offsetX = (x + startX) - 1;
layer.params.offsetY = (y + startY) - 1;
@@ -373,8 +381,17 @@ function onImagePointerdown(ev: PointerEvent) {
.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 {
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>
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
index b34181e5cc..154b3ffc27 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.Layer.vue
@@ -345,7 +345,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import * as Misskey from 'misskey-js';
-import type { WatermarkPreset } from '@/utility/watermark.js';
+import type { WatermarkPreset } from '@/utility/watermark/WatermarkRenderer.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
index 3b3f20d8d1..6cd2111598 100644
--- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue
+++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue
@@ -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('ok', preset);
+ emit('presetOk', {
+ ...preset,
+ layers: deepClone(layers),
+ });
+ } else {
+ dialog.value?.close();
+ if (renderer != null) {
+ renderer.destroy();
+ renderer = null;
+ }
+
+ emit('ok', layers);
+ }
}
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 {